feat(incidents): add incident lifecycle api and tests
This commit is contained in:
230
tests/api/test_incidents.py
Normal file
230
tests/api/test_incidents.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""Integration tests for incident endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core import security
|
||||
from app.repositories.incident import IncidentRepository
|
||||
from tests.api import helpers
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
API_PREFIX = "/v1"
|
||||
|
||||
|
||||
async def _create_service(conn: asyncpg.Connection, org_id: UUID, slug: str = "api") -> UUID:
|
||||
service_id = uuid4()
|
||||
await conn.execute(
|
||||
"INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)",
|
||||
service_id,
|
||||
org_id,
|
||||
"API",
|
||||
slug,
|
||||
)
|
||||
return service_id
|
||||
|
||||
|
||||
async def _create_incident(
|
||||
conn: asyncpg.Connection,
|
||||
org_id: UUID,
|
||||
service_id: UUID,
|
||||
title: str,
|
||||
severity: str = "low",
|
||||
created_at: datetime | None = None,
|
||||
) -> UUID:
|
||||
repo = IncidentRepository(conn)
|
||||
incident_id = uuid4()
|
||||
incident = await repo.create(
|
||||
incident_id,
|
||||
org_id,
|
||||
service_id,
|
||||
title,
|
||||
description=None,
|
||||
severity=severity,
|
||||
)
|
||||
if created_at:
|
||||
await conn.execute(
|
||||
"UPDATE incidents SET created_at = $1 WHERE id = $2",
|
||||
created_at,
|
||||
incident_id,
|
||||
)
|
||||
return incident["id"]
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, *, email: str, password: str) -> dict:
|
||||
response = await client.post(
|
||||
f"{API_PREFIX}/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def test_create_incident_requires_member_role(
|
||||
api_client: AsyncClient, db_admin: asyncpg.Connection
|
||||
) -> None:
|
||||
owner_tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="owner-inc@example.com",
|
||||
password="OwnerInc1!",
|
||||
org_name="Incident Org",
|
||||
)
|
||||
payload = security.decode_access_token(owner_tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
service_id = await _create_service(db_admin, org_id)
|
||||
|
||||
viewer_password = "Viewer123!"
|
||||
viewer_id = uuid4()
|
||||
await db_admin.execute(
|
||||
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
|
||||
viewer_id,
|
||||
"viewer@example.com",
|
||||
security.hash_password(viewer_password),
|
||||
)
|
||||
await db_admin.execute(
|
||||
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
viewer_id,
|
||||
org_id,
|
||||
"viewer",
|
||||
)
|
||||
|
||||
viewer_tokens = await _login(api_client, email="viewer@example.com", password=viewer_password)
|
||||
|
||||
forbidden = await api_client.post(
|
||||
f"{API_PREFIX}/services/{service_id}/incidents",
|
||||
json={"title": "View only", "description": None, "severity": "low"},
|
||||
headers={"Authorization": f"Bearer {viewer_tokens['access_token']}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
created = await api_client.post(
|
||||
f"{API_PREFIX}/services/{service_id}/incidents",
|
||||
json={"title": "Database down", "description": "Primary unavailable", "severity": "critical"},
|
||||
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
incident_id = UUID(created.json()["id"])
|
||||
|
||||
row = await db_admin.fetchrow(
|
||||
"SELECT status, org_id FROM incidents WHERE id = $1",
|
||||
incident_id,
|
||||
)
|
||||
assert row is not None and row["status"] == "triggered" and row["org_id"] == org_id
|
||||
|
||||
event = await db_admin.fetchrow(
|
||||
"SELECT event_type FROM incident_events WHERE incident_id = $1",
|
||||
incident_id,
|
||||
)
|
||||
assert event is not None and event["event_type"] == "created"
|
||||
|
||||
|
||||
async def test_list_incidents_paginates_and_isolates_org(
|
||||
api_client: AsyncClient, db_admin: asyncpg.Connection
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="pager@example.com",
|
||||
password="Pager123!",
|
||||
org_name="Pager Org",
|
||||
)
|
||||
payload = security.decode_access_token(tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
service_id = await _create_service(db_admin, org_id)
|
||||
now = datetime.now(UTC)
|
||||
await _create_incident(db_admin, org_id, service_id, "Old", created_at=now - timedelta(minutes=3))
|
||||
await _create_incident(db_admin, org_id, service_id, "Mid", created_at=now - timedelta(minutes=2))
|
||||
await _create_incident(db_admin, org_id, service_id, "New", created_at=now - timedelta(minutes=1))
|
||||
|
||||
# Noise in another org
|
||||
other_org = await helpers.create_org(db_admin, name="Other", slug="other")
|
||||
other_service = await _create_service(db_admin, other_org, slug="other-api")
|
||||
await _create_incident(db_admin, other_org, other_service, "Other incident")
|
||||
|
||||
response = await api_client.get(
|
||||
f"{API_PREFIX}/incidents",
|
||||
params={"limit": 2},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
titles = [item["title"] for item in body["items"]]
|
||||
assert titles == ["New", "Mid"]
|
||||
assert body["has_more"] is True
|
||||
assert body["next_cursor"] is not None
|
||||
|
||||
|
||||
async def test_transition_incident_enforces_version_and_updates_status(
|
||||
api_client: AsyncClient, db_admin: asyncpg.Connection
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="trans@example.com",
|
||||
password="Trans123!",
|
||||
org_name="Trans Org",
|
||||
)
|
||||
payload = security.decode_access_token(tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
service_id = await _create_service(db_admin, org_id)
|
||||
|
||||
incident_id = await _create_incident(db_admin, org_id, service_id, "Queue backlog")
|
||||
|
||||
conflict = await api_client.post(
|
||||
f"{API_PREFIX}/incidents/{incident_id}/transition",
|
||||
json={"to_status": "acknowledged", "version": 5, "note": None},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
|
||||
ok = await api_client.post(
|
||||
f"{API_PREFIX}/incidents/{incident_id}/transition",
|
||||
json={"to_status": "acknowledged", "version": 1, "note": "Looking"},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["status"] == "acknowledged"
|
||||
assert ok.json()["version"] == 2
|
||||
|
||||
|
||||
async def test_add_comment_appends_event(
|
||||
api_client: AsyncClient, db_admin: asyncpg.Connection
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="commenter@example.com",
|
||||
password="Commenter1!",
|
||||
org_name="Comment Org",
|
||||
)
|
||||
payload = security.decode_access_token(tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
service_id = await _create_service(db_admin, org_id)
|
||||
incident_id = await _create_incident(db_admin, org_id, service_id, "Add comment")
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/incidents/{incident_id}/comment",
|
||||
json={"content": "Monitoring"},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert body["event_type"] == "comment_added"
|
||||
assert body["payload"] == {"content": "Monitoring"}
|
||||
|
||||
event_row = await db_admin.fetchrow(
|
||||
"SELECT event_type, actor_user_id FROM incident_events WHERE id = $1",
|
||||
UUID(body["id"]),
|
||||
)
|
||||
assert event_row is not None
|
||||
assert event_row["event_type"] == "comment_added"
|
||||
238
tests/api/test_org.py
Normal file
238
tests/api/test_org.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Integration tests for org endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core import security
|
||||
from tests.api import helpers
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
API_PREFIX = "/v1/org"
|
||||
|
||||
|
||||
async def _create_user_in_org(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
org_id: UUID,
|
||||
email: str,
|
||||
password: str,
|
||||
role: str,
|
||||
) -> UUID:
|
||||
user_id = uuid4()
|
||||
await conn.execute(
|
||||
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
|
||||
user_id,
|
||||
email,
|
||||
security.hash_password(password),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
user_id,
|
||||
org_id,
|
||||
role,
|
||||
)
|
||||
return user_id
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, *, email: str, password: str) -> dict:
|
||||
response = await client.post(
|
||||
"/v1/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def test_get_org_returns_active_org(api_client: AsyncClient) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="org-owner@example.com",
|
||||
password="OrgOwner1!",
|
||||
org_name="Org Owner Inc",
|
||||
)
|
||||
|
||||
response = await api_client.get(
|
||||
API_PREFIX,
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}",},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
payload = security.decode_access_token(tokens["access_token"])
|
||||
assert data["id"] == payload["org_id"]
|
||||
assert data["name"] == "Org Owner Inc"
|
||||
|
||||
|
||||
async def test_get_members_requires_admin(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
owner_tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="owner@example.com",
|
||||
password="OwnerPass1!",
|
||||
org_name="Members Co",
|
||||
)
|
||||
payload = security.decode_access_token(owner_tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
member_password = "MemberPass1!"
|
||||
await _create_user_in_org(
|
||||
db_admin,
|
||||
org_id=org_id,
|
||||
email="member@example.com",
|
||||
password=member_password,
|
||||
role="member",
|
||||
)
|
||||
member_tokens = await _login(api_client, email="member@example.com", password=member_password)
|
||||
|
||||
admin_response = await api_client.get(
|
||||
f"{API_PREFIX}/members",
|
||||
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
|
||||
)
|
||||
assert admin_response.status_code == 200
|
||||
emails = {item["email"] for item in admin_response.json()}
|
||||
assert emails == {"owner@example.com", "member@example.com"}
|
||||
|
||||
member_response = await api_client.get(
|
||||
f"{API_PREFIX}/members",
|
||||
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
|
||||
)
|
||||
assert member_response.status_code == 403
|
||||
|
||||
|
||||
async def test_create_service_allows_member_and_persists(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
owner_tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="service-owner@example.com",
|
||||
password="ServiceOwner1!",
|
||||
org_name="Service Org",
|
||||
)
|
||||
payload = security.decode_access_token(owner_tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
member_password = "CreateSvc1!"
|
||||
await _create_user_in_org(
|
||||
db_admin,
|
||||
org_id=org_id,
|
||||
email="svc-member@example.com",
|
||||
password=member_password,
|
||||
role="member",
|
||||
)
|
||||
member_tokens = await _login(api_client, email="svc-member@example.com", password=member_password)
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/services",
|
||||
json={"name": "API Gateway", "slug": "api-gateway"},
|
||||
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
row = await db_admin.fetchrow(
|
||||
"SELECT org_id, slug FROM services WHERE id = $1",
|
||||
UUID(body["id"]),
|
||||
)
|
||||
assert row is not None and row["org_id"] == org_id and row["slug"] == "api-gateway"
|
||||
|
||||
|
||||
async def test_create_service_rejects_duplicate_slug(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="dup-owner@example.com",
|
||||
password="DupOwner1!",
|
||||
org_name="Dup Org",
|
||||
)
|
||||
payload = security.decode_access_token(tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
await db_admin.execute(
|
||||
"INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)",
|
||||
uuid4(),
|
||||
org_id,
|
||||
"Existing",
|
||||
"duplicate",
|
||||
)
|
||||
|
||||
response = await api_client.post(
|
||||
f"{API_PREFIX}/services",
|
||||
json={"name": "New", "slug": "duplicate"},
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
async def test_notification_targets_admin_only_and_validation(
|
||||
api_client: AsyncClient,
|
||||
db_admin: asyncpg.Connection,
|
||||
) -> None:
|
||||
owner_tokens = await helpers.register_user(
|
||||
api_client,
|
||||
email="notify-owner@example.com",
|
||||
password="NotifyOwner1!",
|
||||
org_name="Notify Org",
|
||||
)
|
||||
payload = security.decode_access_token(owner_tokens["access_token"])
|
||||
org_id = UUID(payload["org_id"])
|
||||
|
||||
member_password = "NotifyMember1!"
|
||||
await _create_user_in_org(
|
||||
db_admin,
|
||||
org_id=org_id,
|
||||
email="notify-member@example.com",
|
||||
password=member_password,
|
||||
role="member",
|
||||
)
|
||||
member_tokens = await _login(api_client, email="notify-member@example.com", password=member_password)
|
||||
|
||||
forbidden = await api_client.post(
|
||||
f"{API_PREFIX}/notification-targets",
|
||||
json={"name": "Webhook", "target_type": "webhook", "webhook_url": "https://example.com"},
|
||||
headers={"Authorization": f"Bearer {member_tokens['access_token']}"},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
|
||||
missing_url = await api_client.post(
|
||||
f"{API_PREFIX}/notification-targets",
|
||||
json={"name": "Bad", "target_type": "webhook"},
|
||||
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
|
||||
)
|
||||
assert missing_url.status_code == 400
|
||||
|
||||
created = await api_client.post(
|
||||
f"{API_PREFIX}/notification-targets",
|
||||
json={"name": "Pager", "target_type": "webhook", "webhook_url": "https://example.com/hook"},
|
||||
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
|
||||
)
|
||||
|
||||
assert created.status_code == 201
|
||||
target_id = UUID(created.json()["id"])
|
||||
|
||||
row = await db_admin.fetchrow(
|
||||
"SELECT org_id, name FROM notification_targets WHERE id = $1",
|
||||
target_id,
|
||||
)
|
||||
assert row is not None and row["org_id"] == org_id
|
||||
|
||||
listing = await api_client.get(
|
||||
f"{API_PREFIX}/notification-targets",
|
||||
headers={"Authorization": f"Bearer {owner_tokens['access_token']}"},
|
||||
)
|
||||
assert listing.status_code == 200
|
||||
names = [item["name"] for item in listing.json()]
|
||||
assert names == ["Pager"]
|
||||
Reference in New Issue
Block a user