"""HTTP surface: RBAC, ordering, and the four manual transitions. The RBAC shape under test is the platform's standard split — reads open to the admin-read audience so an auditor can see what automation is broken without gaining the ability to change it, every transition ``global_admin`` only. """ from __future__ import annotations import uuid from datetime import UTC, datetime import pytest from sqlalchemy import select from app.domains.automation_exception.collectors.base import ( Candidate, CollectorResult, ) from app.domains.automation_exception.ingest import ingest_source from app.domains.automation_exception.models import AutomationException from app.domains.automation_exception.taxonomy import ( CAUSE_VENDOR_POLICY, REMEDY_VENDOR_OR_FINANCE, SEVERITY_CRITICAL, SEVERITY_LOW, SOURCE_PROVISIONING, STATUS_ACKNOWLEDGED, STATUS_OPEN, STATUS_WONT_FIX, ) BASE = "/api/v1/admin/automation-exceptions" NOW = datetime(2026, 9, 6, 12, 0, tzinfo=UTC) async def _seed( db, *, ref: str = "t1", error: str = "401 unauthorized", scope: str = "software:zoom", severity: str | None = None, ) -> AutomationException: await ingest_source( db, source=SOURCE_PROVISIONING, result=CollectorResult( candidates=[ Candidate( source_ref_type="provisioning_task", source_ref_id=ref, occurred_at=NOW, error_text=error, scope_key=scope, replayable=True, ), ], absence_means_recovery=True, ), now=NOW, ) row = ( await db.execute( select(AutomationException).where( AutomationException.scope_key == scope, ).limit(1), ) ).scalar_one() if severity is not None: row.severity = severity await db.flush() await db.commit() return row async def _seed_many(db, *, refs: tuple[tuple[str, str], ...]) -> None: """Seed several clusters in a single ingest pass.""" await ingest_source( db, source=SOURCE_PROVISIONING, result=CollectorResult( candidates=[ Candidate( source_ref_type="provisioning_task", source_ref_id=ref, occurred_at=NOW, error_text="401 unauthorized", scope_key=scope, replayable=True, ) for ref, scope in refs ], absence_means_recovery=True, ), now=NOW, ) await db.commit() @pytest.mark.asyncio class TestReadRbac: async def test_employee_cannot_read_the_workbench( self, client, db_session, employee_headers, ): await _seed(db_session) for path in (BASE, f"{BASE}/overview", f"{BASE}/vocabulary"): resp = await client.get(path, headers=employee_headers) assert resp.status_code == 403, path async def test_admin_can_read(self, client, db_session, admin_headers): await _seed(db_session) resp = await client.get(BASE, headers=admin_headers) assert resp.status_code == 200 body = resp.json() assert body["success"] is True assert len(body["data"]) == 1 async def test_auditor_can_read_but_not_write( self, client, db_session, auditor_headers, ): row = await _seed(db_session) assert (await client.get(BASE, headers=auditor_headers)).status_code == 200 # Governance-relevant to see, not theirs to change. resp = await client.post( f"{BASE}/{row.id}/acknowledge", json={}, headers=auditor_headers, ) assert resp.status_code == 403 @pytest.mark.asyncio class TestListAndDetail: async def test_worst_first_ordering_is_not_alphabetical( self, client, db_session, admin_headers, ): # ``critical`` < ``high`` < ``low`` < ``normal`` alphabetically, # so a naive ORDER BY would put ``low`` above ``normal`` and, # worse, invite the assumption that string order is severity # order. The first screen must be the worst screen. await _seed(db_session, ref="a", scope="s:low", severity=SEVERITY_LOW) await _seed( db_session, ref="b", scope="s:crit", severity=SEVERITY_CRITICAL, ) resp = await client.get(BASE, headers=admin_headers) severities = [item["severity"] for item in resp.json()["data"]] assert severities[0] == SEVERITY_CRITICAL async def test_every_row_carries_a_remedy_derived_from_its_cause( self, client, db_session, admin_headers, ): row = await _seed( db_session, error="No available license for this user", ) resp = await client.get(f"{BASE}/{row.id}", headers=admin_headers) data = resp.json()["data"] assert data["root_cause"] == CAUSE_VENDOR_POLICY assert data["remedy"] == REMEDY_VENDOR_OR_FINANCE async def test_detail_returns_the_evidence( self, client, db_session, admin_headers, ): row = await _seed(db_session) data = ( await client.get(f"{BASE}/{row.id}", headers=admin_headers) ).json()["data"] assert len(data["occurrences"]) == 1 assert data["occurrences"][0]["source_ref_id"] == "t1" assert data["occurrences"][0]["replayable"] is True async def test_unowned_filter_surfaces_the_gap_rather_than_hiding_it( self, client, db_session, admin_headers, ): await _seed(db_session) resp = await client.get( f"{BASE}?unowned_only=true", headers=admin_headers, ) assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 assert resp.json()["data"][0]["owner"] is None async def test_missing_exception_is_a_404(self, client, admin_headers): resp = await client.get(f"{BASE}/{uuid.uuid4()}", headers=admin_headers) assert resp.status_code == 404 async def test_page_size_cannot_exceed_the_backend_cap( self, client, admin_headers, ): # A frontend page-size selector that can ask for more than the # backend returns silently truncates lists. resp = await client.get(f"{BASE}?page_size=5000", headers=admin_headers) assert resp.status_code == 422 @pytest.mark.asyncio class TestVocabulary: async def test_the_frontend_never_has_to_hardcode_a_vocabulary( self, client, admin_headers, ): data = ( await client.get(f"{BASE}/vocabulary", headers=admin_headers) ).json()["data"] for key in ( "sources", "statuses", "severities", "root_causes", "remedies", "remedy_by_cause", "owner_sources", "source_ref_types", ): assert data[key], key # Every cause maps to a remedy, over the wire as well as in Python. assert set(data["remedy_by_cause"]) == set(data["root_causes"]) @pytest.mark.asyncio class TestTransitions: async def test_acknowledge_records_who_and_when( self, client, db_session, admin_headers, ): row = await _seed(db_session) resp = await client.post( f"{BASE}/{row.id}/acknowledge", json={"note": "looking at it"}, headers=admin_headers, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == STATUS_ACKNOWLEDGED assert resp.json()["data"]["acknowledged_at"] is not None async def test_a_human_verdict_on_the_cause_updates_the_remedy( self, client, db_session, admin_headers, ): row = await _seed(db_session, error="401 unauthorized") resp = await client.post( f"{BASE}/{row.id}/root-cause", json={"root_cause": CAUSE_VENDOR_POLICY}, headers=admin_headers, ) data = resp.json()["data"] assert data["root_cause"] == CAUSE_VENDOR_POLICY assert data["remedy"] == REMEDY_VENDOR_OR_FINANCE assert data["cause_source"] == "manual" async def test_an_unknown_root_cause_is_rejected_with_the_valid_set( self, client, db_session, admin_headers, ): row = await _seed(db_session) resp = await client.post( f"{BASE}/{row.id}/root-cause", json={"root_cause": "vibes"}, headers=admin_headers, ) assert resp.status_code == 422 async def test_closing_without_a_fix_requires_a_reason( self, client, db_session, admin_headers, ): row = await _seed(db_session) blank = await client.post( f"{BASE}/{row.id}/wont-fix", json={"reason": " "}, headers=admin_headers, ) assert blank.status_code == 422 ok = await client.post( f"{BASE}/{row.id}/wont-fix", json={"reason": "vendor behaves this way by design"}, headers=admin_headers, ) assert ok.status_code == 200 assert ok.json()["data"]["status"] == STATUS_WONT_FIX assert ok.json()["data"]["resolution_note"] async def test_acknowledging_a_closed_exception_is_a_conflict( self, client, db_session, admin_headers, ): row = await _seed(db_session) await client.post( f"{BASE}/{row.id}/wont-fix", json={"reason": "accepted"}, headers=admin_headers, ) resp = await client.post( f"{BASE}/{row.id}/acknowledge", json={}, headers=admin_headers, ) assert resp.status_code == 409 async def test_reopen_only_applies_to_a_closed_exception( self, client, db_session, admin_headers, ): row = await _seed(db_session) assert ( await client.post( f"{BASE}/{row.id}/reopen", json={}, headers=admin_headers, ) ).status_code == 409 await client.post( f"{BASE}/{row.id}/wont-fix", json={"reason": "accepted"}, headers=admin_headers, ) resp = await client.post( f"{BASE}/{row.id}/reopen", json={}, headers=admin_headers, ) assert resp.status_code == 200 assert resp.json()["data"]["status"] == STATUS_OPEN async def test_assigning_and_clearing_an_owner( self, client, db_session, admin_headers, employee_user, ): row = await _seed(db_session) person = employee_user[0] assigned = await client.post( f"{BASE}/{row.id}/owner", json={"owner_person_id": str(person.id)}, headers=admin_headers, ) assert assigned.status_code == 200 assert assigned.json()["data"]["owner"]["person_id"] == str(person.id) assert assigned.json()["data"]["owner_source"] == "manual" cleared = await client.post( f"{BASE}/{row.id}/owner", json={"owner_person_id": None}, headers=admin_headers, ) assert cleared.json()["data"]["owner"] is None async def test_assigning_a_nonexistent_person_is_a_404( self, client, db_session, admin_headers, ): row = await _seed(db_session) resp = await client.post( f"{BASE}/{row.id}/owner", json={"owner_person_id": str(uuid.uuid4())}, headers=admin_headers, ) assert resp.status_code == 404 @pytest.mark.asyncio class TestOverview: async def test_counters_and_window(self, client, db_session, admin_headers): # Both in ONE ingest pass. Two separate passes would be correct # engine behaviour with the wrong meaning here: the second pass # does not report "a", and absence-based recovery would resolve # it — leaving one active row, not two. await _seed_many(db_session, refs=(("a", "s:1"), ("b", "s:2"))) data = ( await client.get(f"{BASE}/overview?window_days=7", headers=admin_headers) ).json()["data"] assert data["active_total"] == 2 assert data["unowned_active"] == 2 assert data["new_in_window"] == 2 assert data["window_days"] == 7 async def test_a_closed_exception_leaves_the_active_count( self, client, db_session, admin_headers, ): row = await _seed(db_session) await client.post( f"{BASE}/{row.id}/wont-fix", json={"reason": "accepted"}, headers=admin_headers, ) data = ( await client.get(f"{BASE}/overview", headers=admin_headers) ).json()["data"] assert data["active_total"] == 0 assert data["by_status"].get(STATUS_WONT_FIX) == 1