231 lines
7.4 KiB
Python
231 lines
7.4 KiB
Python
|
|
"""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"
|