feat(incidents): add incident lifecycle api and tests

This commit is contained in:
2026-01-03 10:18:21 +00:00
parent ad94833830
commit f427d191e0
10 changed files with 1456 additions and 2 deletions

230
tests/api/test_incidents.py Normal file
View 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
View 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"]