"""The error console API — platform-owner only, grouped, and audited. The console is the reason the error table exists at all: an error nobody can read is not observability, it is a write-only table. These tests cover both halves — that a regular customer can never reach it, and that the platform owner can actually triage with it. """ from __future__ import annotations from collections.abc import AsyncIterator import pytest_asyncio from httpx import AsyncClient from app.domain.audit.models import ErrorSeverity, ErrorSource from app.infrastructure.error_log import record_error from tests.conftest import auth_header, signup CONSOLE = "/api/admin/errors" @pytest_asyncio.fixture(autouse=True) async def _isolated_error_log(redirect_error_log) -> AsyncIterator[None]: yield async def _seed_error( *, kind: str = "unhandled_exception", message: str = "boom", error_type: str = "RuntimeError", severity: str = ErrorSeverity.ERROR, source: str = ErrorSource.HTTP, path: str = "/api/organizations/{id}/projects", organization_id: str | None = None, method: str = "GET", status_code: int | None = 500, ) -> str | None: """Write one error row through the real recorder, so fingerprints are real.""" exc: BaseException | None = None if error_type: exc = type(error_type, (Exception,), {})(message) event_id = await record_error( kind=kind, message=message, exc=exc, severity=severity, source=source, path=path, method=method, status_code=status_code, organization_id=organization_id, ) assert event_id is not None, "the recorder failed to write a row" return event_id # --------------------------------------------------------------------------- # Access control # --------------------------------------------------------------------------- async def test_a_regular_customer_cannot_reach_the_console(client: AsyncClient): """The console exposes every tenant's failures; it is platform-owner only.""" token = (await signup(client, "nosy@example.com"))["access_token"] for path in ( CONSOLE, f"{CONSOLE}/summary", f"{CONSOLE}/groups", f"{CONSOLE}/some-id", ): response = await client.get(path, headers=auth_header(token)) assert response.status_code == 403, f"{path} leaked to a customer" async def test_an_anonymous_caller_cannot_reach_the_console(client: AsyncClient): response = await client.get(CONSOLE) assert response.status_code == 401 async def test_resolving_requires_a_platform_owner(client: AsyncClient): event_id = await _seed_error() token = (await signup(client, "not-owner@example.com"))["access_token"] response = await client.post( f"{CONSOLE}/{event_id}/resolve", json={"note": "nope"}, headers=auth_header(token) ) assert response.status_code == 403 # --------------------------------------------------------------------------- # Listing # --------------------------------------------------------------------------- async def test_errors_are_listed_newest_first( client: AsyncClient, platform_owner_token: str ): await _seed_error(message="first failure") await _seed_error(message="second failure") response = await client.get(CONSOLE, headers=auth_header(platform_owner_token)) assert response.status_code == 200, response.text body = response.json() assert body["total"] == 2 messages = [item["message"] for item in body["items"]] assert messages == ["second failure", "first failure"] async def test_the_list_carries_everything_needed_to_diagnose( client: AsyncClient, platform_owner_token: str ): await _seed_error( kind="task_run_failed", message="provider returned garbage", error_type="ValueError", status_code=None, method="", path="", ) item = ( await client.get(CONSOLE, headers=auth_header(platform_owner_token)) ).json()["items"][0] for field in ( "id", "kind", "message", "stack", "severity", "source", "error_type", "fingerprint", "status_code", "method", "path", "organization_id", "user_id", "request_id", "ip", "user_agent", "metadata_json", "resolved_at", "resolved_note", "created_at", ): assert field in item, f"the console row is missing {field}" assert item["kind"] == "task_run_failed" assert item["error_type"] == "ValueError" async def test_filters_narrow_the_list( client: AsyncClient, platform_owner_token: str ): await _seed_error(message="http thing", source=ErrorSource.HTTP) await _seed_error(message="worker thing", source=ErrorSource.WORKER, method="", path="") headers = auth_header(platform_owner_token) by_source = await client.get(f"{CONSOLE}?source=worker", headers=headers) assert [i["message"] for i in by_source.json()["items"]] == ["worker thing"] by_severity = await client.get(f"{CONSOLE}?severity=error", headers=headers) assert by_severity.json()["total"] == 2 by_search = await client.get(f"{CONSOLE}?search=worker", headers=headers) assert [i["message"] for i in by_search.json()["items"]] == ["worker thing"] async def test_the_page_size_is_capped(client: AsyncClient, platform_owner_token: str): """An uncapped limit would let one request pull every stack trace at once.""" response = await client.get( f"{CONSOLE}?limit=100000", headers=auth_header(platform_owner_token) ) assert response.status_code == 422 async def test_unknown_error_id_is_a_404(client: AsyncClient, platform_owner_token: str): response = await client.get(f"{CONSOLE}/nope", headers=auth_header(platform_owner_token)) assert response.status_code == 404 assert response.json()["error"]["code"] == "not_found" async def test_summary_counts_open_failures_by_severity( client: AsyncClient, platform_owner_token: str ): await _seed_error(severity=ErrorSeverity.CRITICAL) await _seed_error(severity=ErrorSeverity.ERROR) await _seed_error(severity=ErrorSeverity.WARNING) body = ( await client.get(f"{CONSOLE}/summary", headers=auth_header(platform_owner_token)) ).json() assert body["total_open"] == 3 assert body["open_by_severity"] == {"critical": 1, "error": 1, "warning": 1} # --------------------------------------------------------------------------- # Grouping — the view that makes the console usable # --------------------------------------------------------------------------- async def test_identical_failures_collapse_into_one_group( client: AsyncClient, platform_owner_token: str ): """A bug that fired 4 000 times must be one line with a count, not 4 000.""" for entity in ("aaaa1111bbbb2222cccc3333dddd4444", "9999eeee8888ffff7777aaaa6666bbbb"): await _seed_error( message="task not found while starting a run", path=f"/api/organizations/{entity}/projects", ) await _seed_error(message="a completely different failure", path="/api/other") groups = ( await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token)) ).json() assert len(groups) == 2 biggest = max(groups, key=lambda g: g["occurrences"]) assert biggest["occurrences"] == 2 assert biggest["open_count"] == 2 assert biggest["sample_message"] == "task not found while starting a run" assert biggest["first_seen"] and biggest["last_seen"] async def test_group_listing_is_reachable_before_the_id_route( client: AsyncClient, platform_owner_token: str ): """"groups" must not be swallowed by ``/errors/{error_id}``.""" response = await client.get(f"{CONSOLE}/groups", headers=auth_header(platform_owner_token)) assert response.status_code == 200 assert isinstance(response.json(), list) # --------------------------------------------------------------------------- # Triage # --------------------------------------------------------------------------- async def test_resolving_a_group_closes_every_open_occurrence( client: AsyncClient, platform_owner_token: str ): await _seed_error(message="the same bug", path="/api/x") await _seed_error(message="the same bug", path="/api/x") headers = auth_header(platform_owner_token) fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] resolved = await client.post( f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "fixed in v2"}, headers=headers ) assert resolved.status_code == 200, resolved.text assert resolved.json()["resolved"] == 2 # Nothing is left open… assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 0 # …and the note is on the records. closed = (await client.get(f"{CONSOLE}?status=resolved", headers=headers)).json()["items"] assert all(item["resolved_note"] == "fixed in v2" for item in closed) assert all(item["resolved_at"] for item in closed) async def test_resolving_a_group_is_audited( client: AsyncClient, platform_owner_token: str, session_factory ): """"Who decided this bug was fixed?" must have an answer.""" from sqlalchemy import select from app.infrastructure.db.models import AuditEventRow await _seed_error(message="auditable bug", path="/api/y") headers = auth_header(platform_owner_token) fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] await client.post( f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "done"}, headers=headers ) async with session_factory() as session: entries = list( ( await session.execute( select(AuditEventRow).where(AuditEventRow.action == "error.resolve") ) ).scalars() ) assert len(entries) == 1, "triaging an error must leave a trail" assert entries[0].target_id == fingerprint assert entries[0].actor_user_id assert entries[0].metadata_json["resolved"] == 1 assert entries[0].metadata_json["note"] == "done" async def test_reopening_a_group_brings_the_bug_back( client: AsyncClient, platform_owner_token: str ): await _seed_error(message="flaky bug", path="/api/z") headers = auth_header(platform_owner_token) fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] await client.post( f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": "ok"}, headers=headers ) reopened = await client.post(f"{CONSOLE}/groups/{fingerprint}/reopen", headers=headers) assert reopened.json()["resolved"] == 1 assert (await client.get(f"{CONSOLE}?status=open", headers=headers)).json()["total"] == 1 async def test_resolving_an_already_closed_group_is_a_404( client: AsyncClient, platform_owner_token: str ): """Rather than silently reporting success on a no-op.""" await _seed_error(message="close me twice", path="/api/w") headers = auth_header(platform_owner_token) fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers) second = await client.post( f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers ) assert second.status_code == 404 async def test_the_open_filter_hides_resolved_groups( client: AsyncClient, platform_owner_token: str ): await _seed_error(message="will be closed", path="/api/closed") headers = auth_header(platform_owner_token) fingerprint = (await client.get(f"{CONSOLE}/groups", headers=headers)).json()[0]["fingerprint"] await client.post(f"{CONSOLE}/groups/{fingerprint}/resolve", json={"note": ""}, headers=headers) assert (await client.get(f"{CONSOLE}/groups?status=open", headers=headers)).json() == [] resolved = (await client.get(f"{CONSOLE}/groups?status=resolved", headers=headers)).json() assert len(resolved) == 1 assert resolved[0]["open_count"] == 0