diff --git a/app/api/v1/incidents.py b/app/api/v1/incidents.py new file mode 100644 index 0000000..6c457b5 --- /dev/null +++ b/app/api/v1/incidents.py @@ -0,0 +1,103 @@ +"""Incident API endpoints.""" + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from app.api.deps import CurrentUser, get_current_user, require_role +from app.schemas.common import PaginatedResponse +from app.schemas.incident import ( + CommentRequest, + IncidentEventResponse, + IncidentResponse, + IncidentStatus, + TransitionRequest, + IncidentCreate, +) +from app.services import IncidentService + + +router = APIRouter(tags=["incidents"]) +incident_service = IncidentService() + + +@router.get("/incidents", response_model=PaginatedResponse[IncidentResponse]) +async def list_incidents( + status: IncidentStatus | None = Query(default=None), + cursor: datetime | None = Query(default=None, description="Cursor (created_at)"), + limit: int = Query(default=20, ge=1, le=100), + current_user: CurrentUser = Depends(get_current_user), +) -> PaginatedResponse[IncidentResponse]: + """List incidents for the active organization.""" + + return await incident_service.get_incidents( + current_user, + status=status, + cursor=cursor, + limit=limit, + ) + + +@router.post( + "/services/{service_id}/incidents", + response_model=IncidentResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_incident( + service_id: UUID, + payload: IncidentCreate, + current_user: CurrentUser = Depends(require_role("member")), +) -> IncidentResponse: + """Create a new incident for the given service (member+).""" + + return await incident_service.create_incident(current_user, service_id, payload) + + +@router.get("/incidents/{incident_id}", response_model=IncidentResponse) +async def get_incident( + incident_id: UUID, + current_user: CurrentUser = Depends(get_current_user), +) -> IncidentResponse: + """Fetch a single incident by ID.""" + + return await incident_service.get_incident(current_user, incident_id) + + +@router.get("/incidents/{incident_id}/events", response_model=list[IncidentEventResponse]) +async def get_incident_events( + incident_id: UUID, + current_user: CurrentUser = Depends(get_current_user), +) -> list[IncidentEventResponse]: + """Get the event timeline for an incident.""" + + return await incident_service.get_incident_events(current_user, incident_id) + + +@router.post( + "/incidents/{incident_id}/transition", + response_model=IncidentResponse, +) +async def transition_incident( + incident_id: UUID, + payload: TransitionRequest, + current_user: CurrentUser = Depends(require_role("member")), +) -> IncidentResponse: + """Transition an incident status (member+).""" + + return await incident_service.transition_incident(current_user, incident_id, payload) + + +@router.post( + "/incidents/{incident_id}/comment", + response_model=IncidentEventResponse, + status_code=status.HTTP_201_CREATED, +) +async def add_comment( + incident_id: UUID, + payload: CommentRequest, + current_user: CurrentUser = Depends(require_role("member")), +) -> IncidentEventResponse: + """Add a comment to the incident timeline (member+).""" + + return await incident_service.add_comment(current_user, incident_id, payload) diff --git a/app/api/v1/org.py b/app/api/v1/org.py new file mode 100644 index 0000000..d930eb1 --- /dev/null +++ b/app/api/v1/org.py @@ -0,0 +1,72 @@ +"""Organization API endpoints.""" + +from fastapi import APIRouter, Depends, status + +from app.api.deps import CurrentUser, get_current_user, require_role +from app.schemas.org import ( + MemberResponse, + NotificationTargetCreate, + NotificationTargetResponse, + OrgResponse, + ServiceCreate, + ServiceResponse, +) +from app.services import OrgService + + +router = APIRouter(prefix="/org", tags=["org"]) +org_service = OrgService() + + +@router.get("", response_model=OrgResponse) +async def get_org(current_user: CurrentUser = Depends(get_current_user)) -> OrgResponse: + """Return the active organization summary for the authenticated user.""" + + return await org_service.get_current_org(current_user) + + +@router.get("/members", response_model=list[MemberResponse]) +async def list_members(current_user: CurrentUser = Depends(require_role("admin"))) -> list[MemberResponse]: + """List members of the current organization (admin only).""" + + return await org_service.get_members(current_user) + + +@router.get("/services", response_model=list[ServiceResponse]) +async def list_services(current_user: CurrentUser = Depends(get_current_user)) -> list[ServiceResponse]: + """List services for the current organization.""" + + return await org_service.get_services(current_user) + + +@router.post("/services", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED) +async def create_service( + payload: ServiceCreate, + current_user: CurrentUser = Depends(require_role("member")), +) -> ServiceResponse: + """Create a new service within the current organization (member+).""" + + return await org_service.create_service(current_user, payload) + + +@router.get("/notification-targets", response_model=list[NotificationTargetResponse]) +async def list_notification_targets( + current_user: CurrentUser = Depends(require_role("admin")), +) -> list[NotificationTargetResponse]: + """List notification targets for the current organization (admin only).""" + + return await org_service.get_notification_targets(current_user) + + +@router.post( + "/notification-targets", + response_model=NotificationTargetResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_notification_target( + payload: NotificationTargetCreate, + current_user: CurrentUser = Depends(require_role("admin")), +) -> NotificationTargetResponse: + """Create a notification target for the current organization (admin only).""" + + return await org_service.create_notification_target(current_user, payload) diff --git a/app/main.py b/app/main.py index 9395cb9..8eb70d7 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from typing import AsyncGenerator from fastapi import FastAPI from fastapi.openapi.utils import get_openapi -from app.api.v1 import auth, health +from app.api.v1 import auth, health, incidents, org from app.config import settings from app.db import db, redis_client @@ -35,6 +35,8 @@ app = FastAPI( app.openapi_tags = [ {"name": "auth", "description": "Registration, login, token lifecycle"}, + {"name": "org", "description": "Organization membership, services, and notifications"}, + {"name": "incidents", "description": "Incident lifecycle and timelines"}, {"name": "health", "description": "Service health probes"}, ] @@ -67,4 +69,6 @@ app.openapi = custom_openapi # type: ignore[assignment] # Include routers app.include_router(auth.router, prefix=settings.api_v1_prefix) +app.include_router(incidents.router, prefix=settings.api_v1_prefix) +app.include_router(org.router, prefix=settings.api_v1_prefix) app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"]) diff --git a/app/services/__init__.py b/app/services/__init__.py index aef1bc2..7bed06c 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,5 +1,7 @@ """Service layer entrypoints.""" from app.services.auth import AuthService +from app.services.incident import IncidentService +from app.services.org import OrgService -__all__ = ["AuthService"] +__all__ = ["AuthService", "OrgService", "IncidentService"] diff --git a/app/services/incident.py b/app/services/incident.py new file mode 100644 index 0000000..2874888 --- /dev/null +++ b/app/services/incident.py @@ -0,0 +1,219 @@ +"""Incident service implementing incident lifecycle operations.""" + +from __future__ import annotations + +from datetime import datetime +from typing import cast +from uuid import UUID, uuid4 + +import asyncpg +from asyncpg.pool import PoolConnectionProxy + +from app.api.deps import CurrentUser, ensure_org_access +from app.core import exceptions as exc +from app.db import Database, db +from app.repositories import IncidentRepository, ServiceRepository +from app.schemas.common import PaginatedResponse +from app.schemas.incident import ( + CommentRequest, + IncidentCreate, + IncidentEventResponse, + IncidentResponse, + TransitionRequest, +) + + +_ALLOWED_TRANSITIONS: dict[str, set[str]] = { + "triggered": {"acknowledged"}, + "acknowledged": {"mitigated"}, + "mitigated": {"resolved"}, + "resolved": set(), +} + + +def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection: + """Helper to satisfy typing when a pool proxy is returned.""" + + return cast(asyncpg.Connection, conn) + + +class IncidentService: + """Encapsulates incident lifecycle operations within an org context.""" + + def __init__(self, database: Database | None = None) -> None: + self.db = database or db + + async def create_incident( + self, + current_user: CurrentUser, + service_id: UUID, + data: IncidentCreate, + ) -> IncidentResponse: + """Create an incident for a service in the active org and record the creation event.""" + + async with self.db.transaction() as conn: + db_conn = _as_conn(conn) + service_repo = ServiceRepository(db_conn) + incident_repo = IncidentRepository(db_conn) + + service = await service_repo.get_by_id(service_id) + if service is None: + raise exc.NotFoundError("Service not found") + ensure_org_access(service["org_id"], current_user) + + incident_id = uuid4() + incident = await incident_repo.create( + incident_id=incident_id, + org_id=current_user.org_id, + service_id=service_id, + title=data.title, + description=data.description, + severity=data.severity, + ) + + await incident_repo.add_event( + uuid4(), + incident_id, + "created", + actor_user_id=current_user.user_id, + payload={ + "title": data.title, + "severity": data.severity, + "description": data.description, + }, + ) + + return IncidentResponse(**incident) + + async def get_incidents( + self, + current_user: CurrentUser, + *, + status: str | None = None, + cursor: datetime | None = None, + limit: int = 20, + ) -> PaginatedResponse[IncidentResponse]: + """Return paginated incidents for the active organization.""" + + async with self.db.connection() as conn: + incident_repo = IncidentRepository(_as_conn(conn)) + rows = await incident_repo.get_by_org( + org_id=current_user.org_id, + status=status, + cursor=cursor, + limit=limit, + ) + + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = items[-1]["created_at"].isoformat() if has_more and items else None + + incidents = [IncidentResponse(**row) for row in items] + return PaginatedResponse[IncidentResponse]( + items=incidents, + next_cursor=next_cursor, + has_more=has_more, + ) + + async def get_incident(self, current_user: CurrentUser, incident_id: UUID) -> IncidentResponse: + """Return a single incident, ensuring it belongs to the active org.""" + + async with self.db.connection() as conn: + incident_repo = IncidentRepository(_as_conn(conn)) + incident = await incident_repo.get_by_id(incident_id) + if incident is None: + raise exc.NotFoundError("Incident not found") + ensure_org_access(incident["org_id"], current_user) + return IncidentResponse(**incident) + + async def get_incident_events( + self, current_user: CurrentUser, incident_id: UUID + ) -> list[IncidentEventResponse]: + """Return the timeline events for an incident in the active org.""" + + async with self.db.connection() as conn: + incident_repo = IncidentRepository(_as_conn(conn)) + incident = await incident_repo.get_by_id(incident_id) + if incident is None: + raise exc.NotFoundError("Incident not found") + ensure_org_access(incident["org_id"], current_user) + + events = await incident_repo.get_events(incident_id) + return [IncidentEventResponse(**event) for event in events] + + async def transition_incident( + self, + current_user: CurrentUser, + incident_id: UUID, + data: TransitionRequest, + ) -> IncidentResponse: + """Transition an incident status with optimistic locking and event recording.""" + + async with self.db.transaction() as conn: + db_conn = _as_conn(conn) + incident_repo = IncidentRepository(db_conn) + + incident = await incident_repo.get_by_id(incident_id) + if incident is None: + raise exc.NotFoundError("Incident not found") + ensure_org_access(incident["org_id"], current_user) + self._validate_transition(incident["status"], data.to_status) + + updated = await incident_repo.update_status( + incident_id, + data.to_status, + data.version, + ) + if updated is None: + raise exc.ConflictError("Incident version mismatch") + + payload = {"from": incident["status"], "to": data.to_status} + if data.note: + payload["note"] = data.note + + await incident_repo.add_event( + uuid4(), + incident_id, + "status_changed", + actor_user_id=current_user.user_id, + payload=payload, + ) + + return IncidentResponse(**updated) + + async def add_comment( + self, + current_user: CurrentUser, + incident_id: UUID, + data: CommentRequest, + ) -> IncidentEventResponse: + """Add a comment event to the incident timeline.""" + + async with self.db.connection() as conn: + incident_repo = IncidentRepository(_as_conn(conn)) + incident = await incident_repo.get_by_id(incident_id) + if incident is None: + raise exc.NotFoundError("Incident not found") + ensure_org_access(incident["org_id"], current_user) + + event = await incident_repo.add_event( + uuid4(), + incident_id, + "comment_added", + actor_user_id=current_user.user_id, + payload={"content": data.content}, + ) + return IncidentEventResponse(**event) + + def _validate_transition(self, current_status: str, to_status: str) -> None: + """Validate a requested status transition against the allowed state machine.""" + + if current_status == to_status: + raise exc.BadRequestError("Incident is already in the requested status") + + allowed = _ALLOWED_TRANSITIONS.get(current_status, set()) + if to_status not in allowed: + raise exc.BadRequestError("Invalid incident status transition") + + +__all__ = ["IncidentService"] diff --git a/app/services/org.py b/app/services/org.py new file mode 100644 index 0000000..ce7d2d8 --- /dev/null +++ b/app/services/org.py @@ -0,0 +1,115 @@ +"""Organization service providing org-scoped operations.""" + +from __future__ import annotations + +from typing import cast +from uuid import UUID, uuid4 + +import asyncpg +from asyncpg.pool import PoolConnectionProxy + +from app.api.deps import CurrentUser +from app.core import exceptions as exc +from app.db import Database, db +from app.repositories import NotificationRepository, OrgRepository, ServiceRepository +from app.schemas.org import ( + MemberResponse, + NotificationTargetCreate, + NotificationTargetResponse, + OrgResponse, + ServiceCreate, + ServiceResponse, +) + + +def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection: + """Helper to satisfy typing when a pool proxy is returned.""" + + return cast(asyncpg.Connection, conn) + + +class OrgService: + """Encapsulates organization-level operations within the active org context.""" + + def __init__(self, database: Database | None = None) -> None: + self.db = database or db + + async def get_current_org(self, current_user: CurrentUser) -> OrgResponse: + """Return the active organization summary for the current user.""" + + async with self.db.connection() as conn: + org_repo = OrgRepository(_as_conn(conn)) + org = await org_repo.get_by_id(current_user.org_id) + if org is None: + raise exc.NotFoundError("Organization not found") + return OrgResponse(**org) + + async def get_members(self, current_user: CurrentUser) -> list[MemberResponse]: + """List members of the active organization.""" + + async with self.db.connection() as conn: + org_repo = OrgRepository(_as_conn(conn)) + members = await org_repo.get_members(current_user.org_id) + return [MemberResponse(**member) for member in members] + + async def create_service(self, current_user: CurrentUser, data: ServiceCreate) -> ServiceResponse: + """Create a new service within the active organization.""" + + async with self.db.connection() as conn: + service_repo = ServiceRepository(_as_conn(conn)) + + if await service_repo.slug_exists(current_user.org_id, data.slug): + raise exc.ConflictError("Service slug already exists in this organization") + + try: + service = await service_repo.create( + service_id=uuid4(), + org_id=current_user.org_id, + name=data.name, + slug=data.slug, + ) + except asyncpg.UniqueViolationError as err: # pragma: no cover - race protection + raise exc.ConflictError("Service slug already exists in this organization") from err + + return ServiceResponse(**service) + + async def get_services(self, current_user: CurrentUser) -> list[ServiceResponse]: + """List services for the active organization.""" + + async with self.db.connection() as conn: + service_repo = ServiceRepository(_as_conn(conn)) + services = await service_repo.get_by_org(current_user.org_id) + return [ServiceResponse(**svc) for svc in services] + + async def create_notification_target( + self, + current_user: CurrentUser, + data: NotificationTargetCreate, + ) -> NotificationTargetResponse: + """Create a notification target for the active organization.""" + + if data.target_type == "webhook" and data.webhook_url is None: + raise exc.BadRequestError("webhook_url is required for webhook targets") + + async with self.db.connection() as conn: + notification_repo = NotificationRepository(_as_conn(conn)) + target = await notification_repo.create_target( + target_id=uuid4(), + org_id=current_user.org_id, + name=data.name, + target_type=data.target_type, + webhook_url=str(data.webhook_url) if data.webhook_url else None, + enabled=data.enabled, + ) + return NotificationTargetResponse(**target) + + async def get_notification_targets(self, current_user: CurrentUser) -> list[NotificationTargetResponse]: + """List notification targets for the active organization.""" + + async with self.db.connection() as conn: + notification_repo = NotificationRepository(_as_conn(conn)) + targets = await notification_repo.get_targets_by_org(current_user.org_id) + return [NotificationTargetResponse(**target) for target in targets] + + +__all__ = ["OrgService"] diff --git a/tests/api/test_incidents.py b/tests/api/test_incidents.py new file mode 100644 index 0000000..79b5eca --- /dev/null +++ b/tests/api/test_incidents.py @@ -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" diff --git a/tests/api/test_org.py b/tests/api/test_org.py new file mode 100644 index 0000000..6d7dbaa --- /dev/null +++ b/tests/api/test_org.py @@ -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"] diff --git a/tests/services/test_incident_service.py b/tests/services/test_incident_service.py new file mode 100644 index 0000000..bcfd9a9 --- /dev/null +++ b/tests/services/test_incident_service.py @@ -0,0 +1,252 @@ +"""Unit tests for IncidentService.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from app.api.deps import CurrentUser +from app.core import exceptions as exc, security +from app.db import Database +from app.schemas.incident import CommentRequest, IncidentCreate, TransitionRequest +from app.services.incident import IncidentService + + +pytestmark = pytest.mark.asyncio + + +class _SingleConnectionDatabase(Database): + """Database stub that reuses a single asyncpg connection.""" + + def __init__(self, conn) -> None: # type: ignore[override] + self._conn = conn + + @asynccontextmanager + async def connection(self): # type: ignore[override] + yield self._conn + + @asynccontextmanager + async def transaction(self): # type: ignore[override] + tr = self._conn.transaction() + await tr.start() + try: + yield self._conn + except Exception: + await tr.rollback() + raise + else: + await tr.commit() + + +@pytest.fixture +async def incident_service(db_conn: asyncpg.Connection): + """IncidentService bound to the per-test database connection.""" + + return IncidentService(database=_SingleConnectionDatabase(db_conn)) + + +async def _seed_user_org_service(conn: asyncpg.Connection) -> tuple[CurrentUser, UUID]: + """Create a user, org, and service and return the CurrentUser + service_id.""" + + user_id = uuid4() + org_id = uuid4() + service_id = uuid4() + + await conn.execute( + "INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)", + user_id, + "owner@example.com", + security.hash_password("Passw0rd!"), + ) + await conn.execute( + "INSERT INTO orgs (id, name, slug) VALUES ($1, $2, $3)", + org_id, + "Test Org", + "test-org", + ) + await conn.execute( + "INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)", + uuid4(), + user_id, + org_id, + "member", + ) + await conn.execute( + "INSERT INTO services (id, org_id, name, slug) VALUES ($1, $2, $3, $4)", + service_id, + org_id, + "API", + "api", + ) + + current_user = CurrentUser( + user_id=user_id, + email="owner@example.com", + org_id=org_id, + org_role="member", + token="token", + ) + return current_user, service_id + + +async def test_create_incident_persists_and_records_event( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + + incident = await incident_service.create_incident( + current_user, + service_id, + IncidentCreate(title="API outage", description="Gateway 502s", severity="critical"), + ) + + row = await db_conn.fetchrow( + "SELECT status, org_id, service_id FROM incidents WHERE id = $1", + incident.id, + ) + assert row is not None + assert row["status"] == "triggered" + assert row["org_id"] == current_user.org_id + assert row["service_id"] == service_id + + event = await db_conn.fetchrow( + "SELECT event_type, actor_user_id FROM incident_events WHERE incident_id = $1", + incident.id, + ) + assert event is not None + assert event["event_type"] == "created" + assert event["actor_user_id"] == current_user.user_id + + +async def test_get_incidents_paginates_by_created_at( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + + first = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="First", description=None, severity="low") + ) + second = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Second", description=None, severity="medium") + ) + third = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Third", description=None, severity="high") + ) + + # Stagger created_at for deterministic ordering + now = datetime.now(UTC) + await db_conn.execute( + "UPDATE incidents SET created_at = $1 WHERE id = $2", + now - timedelta(minutes=3), + first.id, + ) + await db_conn.execute( + "UPDATE incidents SET created_at = $1 WHERE id = $2", + now - timedelta(minutes=2), + second.id, + ) + await db_conn.execute( + "UPDATE incidents SET created_at = $1 WHERE id = $2", + now - timedelta(minutes=1), + third.id, + ) + + page = await incident_service.get_incidents(current_user, limit=2) + + titles = [item.title for item in page.items] + assert titles == ["Third", "Second"] + assert page.has_more is True + assert page.next_cursor is not None + + +async def test_transition_incident_updates_status_and_records_event( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + incident = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Escalation", severity="high", description=None) + ) + + updated = await incident_service.transition_incident( + current_user, + incident.id, + TransitionRequest(to_status="acknowledged", version=incident.version, note="On it"), + ) + + assert updated.status == "acknowledged" + assert updated.version == incident.version + 1 + + event = await db_conn.fetchrow( + """ + SELECT payload + FROM incident_events + WHERE incident_id = $1 AND event_type = 'status_changed' + ORDER BY created_at DESC + LIMIT 1 + """, + incident.id, + ) + assert event is not None + payload = event["payload"] + if isinstance(payload, str): + import json + + payload = json.loads(payload) + + assert payload["from"] == "triggered" + assert payload["to"] == "acknowledged" + assert payload["note"] == "On it" + + +async def test_transition_incident_rejects_invalid_transition( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + incident = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Invalid", severity="low", description=None) + ) + + with pytest.raises(exc.BadRequestError): + await incident_service.transition_incident( + current_user, + incident.id, + TransitionRequest(to_status="resolved", version=incident.version, note=None), + ) + + +async def test_transition_incident_conflict_on_version_mismatch( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + incident = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Version", severity="medium", description=None) + ) + + with pytest.raises(exc.ConflictError): + await incident_service.transition_incident( + current_user, + incident.id, + TransitionRequest(to_status="acknowledged", version=999, note=None), + ) + + +async def test_add_comment_creates_event( + incident_service: IncidentService, db_conn: asyncpg.Connection +) -> None: + current_user, service_id = await _seed_user_org_service(db_conn) + incident = await incident_service.create_incident( + current_user, service_id, IncidentCreate(title="Comment", severity="low", description=None) + ) + + event = await incident_service.add_comment( + current_user, + incident.id, + CommentRequest(content="Investigating"), + ) + + assert event.event_type == "comment_added" + assert event.payload == {"content": "Investigating"} diff --git a/tests/services/test_org_service.py b/tests/services/test_org_service.py new file mode 100644 index 0000000..5970d2f --- /dev/null +++ b/tests/services/test_org_service.py @@ -0,0 +1,219 @@ +"""Unit tests covering OrgService flows.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from uuid import UUID, uuid4 + +import pytest + +from app.api.deps import CurrentUser +from app.core import exceptions as exc, security +from app.db import Database +from app.repositories import NotificationRepository, OrgRepository, ServiceRepository +from app.schemas.org import NotificationTargetCreate, ServiceCreate +from app.services.org import OrgService + + +pytestmark = pytest.mark.asyncio + + +class _SingleConnectionDatabase(Database): + """Database stub that reuses a single asyncpg connection.""" + + def __init__(self, conn) -> None: # type: ignore[override] + self._conn = conn + + @asynccontextmanager + async def connection(self): # type: ignore[override] + yield self._conn + + @asynccontextmanager + async def transaction(self): # type: ignore[override] + tr = self._conn.transaction() + await tr.start() + try: + yield self._conn + except Exception: + await tr.rollback() + raise + else: + await tr.commit() + + +@pytest.fixture +async def org_service(db_conn): + """OrgService bound to the per-test database connection.""" + + return OrgService(database=_SingleConnectionDatabase(db_conn)) + + +async def _create_user(conn, email: 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("Password123!"), + ) + return user_id + + +async def _create_org(conn, name: str, slug: str | None = None) -> UUID: + org_id = uuid4() + org_repo = OrgRepository(conn) + await org_repo.create(org_id, name, slug or name.lower().replace(" ", "-")) + return org_id + + +async def _add_membership(conn, user_id: UUID, org_id: UUID, role: str) -> None: + await conn.execute( + "INSERT INTO org_members (id, user_id, org_id, role) VALUES ($1, $2, $3, $4)", + uuid4(), + user_id, + org_id, + role, + ) + + +async def _create_service(conn, org_id: UUID, name: str, slug: str) -> None: + repo = ServiceRepository(conn) + await repo.create(uuid4(), org_id, name, slug) + + +async def _create_notification_target(conn, org_id: UUID, name: str) -> None: + repo = NotificationRepository(conn) + await repo.create_target(uuid4(), org_id, name, "webhook", "https://example.com/hook") + + +def _make_user(user_id: UUID, email: str, org_id: UUID, role: str) -> CurrentUser: + return CurrentUser(user_id=user_id, email=email, org_id=org_id, org_role=role, token="token") + + +async def test_get_current_org_returns_summary(org_service, db_conn): + org_id = await _create_org(db_conn, "Current Org", slug="current-org") + user_id = await _create_user(db_conn, "owner@example.com") + await _add_membership(db_conn, user_id, org_id, "admin") + + current_user = _make_user(user_id, "owner@example.com", org_id, "admin") + + result = await org_service.get_current_org(current_user) + + assert result.id == org_id + assert result.slug == "current-org" + + +async def test_get_current_org_raises_not_found(org_service, db_conn): + user_id = await _create_user(db_conn, "ghost@example.com") + missing_org = uuid4() + current_user = _make_user(user_id, "ghost@example.com", missing_org, "admin") + + with pytest.raises(exc.NotFoundError): + await org_service.get_current_org(current_user) + + +async def test_get_members_returns_org_members(org_service, db_conn): + org_id = await _create_org(db_conn, "Members Org", slug="members-org") + admin_id = await _create_user(db_conn, "admin@example.com") + member_id = await _create_user(db_conn, "member@example.com") + await _add_membership(db_conn, admin_id, org_id, "admin") + await _add_membership(db_conn, member_id, org_id, "member") + + current_user = _make_user(admin_id, "admin@example.com", org_id, "admin") + members = await org_service.get_members(current_user) + + emails = {m.email for m in members} + assert emails == {"admin@example.com", "member@example.com"} + + +async def test_create_service_rejects_duplicate_slug(org_service, db_conn): + org_id = await _create_org(db_conn, "Dup Org", slug="dup-org") + user_id = await _create_user(db_conn, "service@example.com") + await _add_membership(db_conn, user_id, org_id, "member") + await _create_service(db_conn, org_id, "Existing", "duplicate") + + current_user = _make_user(user_id, "service@example.com", org_id, "member") + with pytest.raises(exc.ConflictError): + await org_service.create_service(current_user, ServiceCreate(name="New", slug="duplicate")) + + +async def test_create_service_persists_service(org_service, db_conn): + org_id = await _create_org(db_conn, "Service Org", slug="service-org") + user_id = await _create_user(db_conn, "creator@example.com") + await _add_membership(db_conn, user_id, org_id, "member") + current_user = _make_user(user_id, "creator@example.com", org_id, "member") + + result = await org_service.create_service(current_user, ServiceCreate(name="API", slug="api")) + + assert result.name == "API" + row = await db_conn.fetchrow( + "SELECT name, org_id FROM services WHERE id = $1", + result.id, + ) + assert row is not None and row["org_id"] == org_id + + +async def test_get_services_returns_only_org_services(org_service, db_conn): + org_id = await _create_org(db_conn, "Own Org", slug="own-org") + other_org = await _create_org(db_conn, "Other Org", slug="other-org") + user_id = await _create_user(db_conn, "viewer@example.com") + await _add_membership(db_conn, user_id, org_id, "viewer") + + await _create_service(db_conn, org_id, "Owned", "owned") + await _create_service(db_conn, other_org, "Foreign", "foreign") + + current_user = _make_user(user_id, "viewer@example.com", org_id, "viewer") + services = await org_service.get_services(current_user) + + assert len(services) == 1 + assert services[0].name == "Owned" + + +async def test_create_notification_target_requires_webhook_url(org_service, db_conn): + org_id = await _create_org(db_conn, "Webhook Org", slug="webhook-org") + user_id = await _create_user(db_conn, "admin-webhook@example.com") + await _add_membership(db_conn, user_id, org_id, "admin") + current_user = _make_user(user_id, "admin-webhook@example.com", org_id, "admin") + + with pytest.raises(exc.BadRequestError): + await org_service.create_notification_target( + current_user, + NotificationTargetCreate(name="Hook", target_type="webhook", webhook_url=None), + ) + + +async def test_create_notification_target_persists_target(org_service, db_conn): + org_id = await _create_org(db_conn, "Notify Org", slug="notify-org") + user_id = await _create_user(db_conn, "notify@example.com") + await _add_membership(db_conn, user_id, org_id, "admin") + current_user = _make_user(user_id, "notify@example.com", org_id, "admin") + + target = await org_service.create_notification_target( + current_user, + NotificationTargetCreate( + name="Pager", target_type="webhook", webhook_url="https://example.com/hook" + ), + ) + + assert target.enabled is True + row = await db_conn.fetchrow( + "SELECT org_id, name FROM notification_targets WHERE id = $1", + target.id, + ) + assert row is not None and row["org_id"] == org_id + + +async def test_get_notification_targets_scopes_to_org(org_service, db_conn): + org_id = await _create_org(db_conn, "Scope Org", slug="scope-org") + other_org = await _create_org(db_conn, "Scope Other", slug="scope-other") + user_id = await _create_user(db_conn, "scope@example.com") + await _add_membership(db_conn, user_id, org_id, "admin") + + await _create_notification_target(db_conn, org_id, "Own Target") + await _create_notification_target(db_conn, other_org, "Other Target") + + current_user = _make_user(user_id, "scope@example.com", org_id, "admin") + targets = await org_service.get_notification_targets(current_user) + + assert len(targets) == 1 + assert targets[0].name == "Own Target"