"""Unified findings engine (PR-A, migration 254). Covers: native reconcile lifecycle (create / re-seen / auto-resolve / recur-reopen), accept_risk expiry reopen, the decide write-path, mirrored exposure + sign-in projection, legacy-endpoint mirroring, and HTTP RBAC. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from httpx import AsyncClient from sqlalchemy import select from app.domains.auditor import constants as const from app.domains.auditor import findings_service as fs from app.domains.auditor.models import ( AuditorFinding, AuditorPublicExposureFinding, AuditorSigninEvent, ) from app.domains.auditor.services import ( ExposureService, SigninAcknowledgeService, ) from app.identity.person_models import IdentityPerson def _person(**kw) -> IdentityPerson: return IdentityPerson( id=uuid.uuid4(), source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:8]}", work_email=kw.get("email", f"u-{uuid.uuid4().hex[:6]}@x.com"), display_name=kw.get("name", "Person"), employment_status="active", ) def _spec(key: str, *, severity="high", title="t") -> fs.FindingSpec: return fs.FindingSpec( dedup_key=key, severity=severity, title=title, subject_type=const.FINDING_SUBJECT_PERSON, subject_ref=key, ) async def _reconcile(db, specs): return await fs.reconcile_findings( db, control_key="mfa_coverage", finding_type=const.FINDING_TYPE_MFA_NOT_ENROLLED, specs=specs, ) # ── Native reconcile lifecycle ────────────────────────────────────────── @pytest.mark.asyncio async def test_reconcile_create_reseen_autoresolve_recur(db_session): db = db_session # First sync: two findings created. c = await _reconcile(db, [_spec("a"), _spec("b")]) assert c["created"] == 2 await db.flush() # Second sync: same items → present, no duplicates. c = await _reconcile(db, [_spec("a"), _spec("b")]) assert c["created"] == 0 and c["present"] == 2 total = len((await db.execute(select(AuditorFinding))).scalars().all()) assert total == 2 # Third sync: 'b' gone → auto-resolved; 'a' stays open. c = await _reconcile(db, [_spec("a")]) assert c["auto_resolved"] == 1 b = (await db.execute( select(AuditorFinding).where(AuditorFinding.dedup_key == "b") )).scalar_one() assert b.status == const.FINDING_STATUS_RESOLVED and b.auto_resolved is True # Fourth sync: 'b' comes back → reopened (it was never fixed). c = await _reconcile(db, [_spec("a"), _spec("b")]) assert c["reopened"] == 1 await db.flush() await db.refresh(b) assert b.status == const.FINDING_STATUS_OPEN and b.auto_resolved is False @pytest.mark.asyncio async def test_accept_risk_expiry_reopens(db_session): db = db_session actor = _person() db.add(actor) await _reconcile(db, [_spec("x")]) await db.flush() row = (await db.execute( select(AuditorFinding).where(AuditorFinding.dedup_key == "x") )).scalar_one() # Accept risk for 7 days, then force the clock past expiry. await fs.decide_finding( db, finding_id=row.id, decision=const.FINDING_DECISION_ACCEPT_RISK, note="known, low blast radius", accepted_days=7, actor_user_id=actor.id, ) await db.flush() row.accepted_until = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1) await db.flush() # Re-seen after expiry → reopened. c = await _reconcile(db, [_spec("x")]) assert c["reopened"] == 1 await db.flush() await db.refresh(row) assert row.status == const.FINDING_STATUS_OPEN @pytest.mark.asyncio async def test_accepted_risk_within_window_kept(db_session): db = db_session actor = _person() db.add(actor) await _reconcile(db, [_spec("y")]) await db.flush() row = (await db.execute( select(AuditorFinding).where(AuditorFinding.dedup_key == "y") )).scalar_one() await fs.decide_finding( db, finding_id=row.id, decision=const.FINDING_DECISION_ACCEPT_RISK, note="accepted for 30 days", accepted_days=30, actor_user_id=actor.id, ) await db.flush() c = await _reconcile(db, [_spec("y")]) assert c["reopened"] == 0 await db.flush() await db.refresh(row) assert row.status == const.FINDING_STATUS_ACCEPTED_RISK # ── Decide write-path ─────────────────────────────────────────────────── @pytest.mark.asyncio async def test_decide_transitions(db_session): db = db_session actor = _person() db.add(actor) await _reconcile(db, [_spec("d")]) await db.flush() row = (await db.execute( select(AuditorFinding).where(AuditorFinding.dedup_key == "d") )).scalar_one() await fs.decide_finding( db, finding_id=row.id, decision=const.FINDING_DECISION_ACKNOWLEDGE, note="looking into it", accepted_days=None, actor_user_id=actor.id, ) await db.flush() await db.refresh(row) assert row.status == const.FINDING_STATUS_ACKNOWLEDGED assert row.decided_by_person_id == actor.id await fs.decide_finding( db, finding_id=row.id, decision=const.FINDING_DECISION_RESOLVE, note="fixed it", accepted_days=None, actor_user_id=actor.id, ) await db.flush() await db.refresh(row) assert row.status == const.FINDING_STATUS_RESOLVED assert row.auto_resolved is False and row.resolved_at is not None # ── Mirrored: public exposure projection + legacy mirror ──────────────── def _exposure(**kw) -> AuditorPublicExposureFinding: now = datetime.now(UTC) return AuditorPublicExposureFinding( id=uuid.uuid4(), scan_run_id=uuid.uuid4(), source=const.EXPOSURE_SOURCE_AWS_SG, resource_id=kw.get("resource_id", "sg-123"), resource_name="web-sg", exposure_type=const.EXPOSURE_TYPE_INGRESS_SENSITIVE, severity=const.EXPOSURE_SEVERITY_CRITICAL, first_seen_at=now, last_seen_at=now, status=kw.get("status", const.EXPOSURE_STATUS_OPEN), ) @pytest.mark.asyncio async def test_exposure_projection_and_autoresolve(db_session): db = db_session ex = _exposure() db.add(ex) await db.flush() await fs.project_exposure_findings(db) await db.flush() f = (await db.execute( select(AuditorFinding).where( AuditorFinding.control_key == "public_exposure_scan" ) )).scalar_one() assert f.status == const.FINDING_STATUS_OPEN assert f.source_table == fs.SOURCE_TABLE_EXPOSURE assert f.source_id == ex.id assert f.severity == const.EXPOSURE_SEVERITY_CRITICAL # Exposure resolved upstream → finding auto-resolved on next projection. ex.status = const.EXPOSURE_STATUS_RESOLVED await db.flush() await fs.project_exposure_findings(db) await db.flush() await db.refresh(f) assert f.status == const.FINDING_STATUS_RESOLVED and f.auto_resolved is True @pytest.mark.asyncio async def test_legacy_exposure_resolve_mirrors_finding(db_session): db = db_session actor = _person() db.add(actor) ex = _exposure() db.add(ex) await db.flush() await fs.project_exposure_findings(db) await db.flush() await ExposureService.resolve( db, finding_id=ex.id, resolution=const.EXPOSURE_STATUS_ACCEPTED_RISK, note="accepted, internal-only VPC", actor_user_id=actor.id, ) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.source_id == ex.id) )).scalar_one() assert f.status == const.FINDING_STATUS_ACCEPTED_RISK @pytest.mark.asyncio async def test_exposure_finding_rejects_acknowledge(db_session): db = db_session actor = _person() db.add(actor) ex = _exposure() db.add(ex) await db.flush() await fs.project_exposure_findings(db) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.source_id == ex.id) )).scalar_one() from app.core.exceptions import AppException with pytest.raises(AppException): await fs.decide_finding( db, finding_id=f.id, decision=const.FINDING_DECISION_ACKNOWLEDGE, note="should fail", accepted_days=None, actor_user_id=actor.id, ) # ── Mirrored: sign-in projection + legacy ack ─────────────────────────── def _signin(person_id=None, **kw) -> AuditorSigninEvent: return AuditorSigninEvent( id=uuid.uuid4(), person_id=person_id, provider=const.SIGNIN_PROVIDER_GOOGLE, provider_event_id=f"ev-{uuid.uuid4().hex[:8]}", provider_email="who@x.com", event_at=datetime.now(UTC), event_kind="login", anomaly_score=85, anomaly_level=const.ANOMALY_LEVEL_CRITICAL, anomaly_reasons=[const.ANOMALY_REASON_LOGIN_AFTER_OFFBOARD], ) @pytest.mark.asyncio async def test_signin_projection_and_ack_mirror(db_session): db = db_session actor = _person() db.add(actor) ev = _signin() db.add(ev) await db.flush() await fs.project_signin_findings(db) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.source_id == ev.id) )).scalar_one() assert f.status == const.FINDING_STATUS_OPEN assert f.severity == const.FINDING_SEVERITY_CRITICAL await SigninAcknowledgeService.acknowledge( db, event_id=ev.id, note="confirmed benign VPN egress", actor_user_id=actor.id, ) await db.flush() await db.refresh(f) assert f.status == const.FINDING_STATUS_RESOLVED # ── HTTP surface + RBAC ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_http_list_and_decide( client: AsyncClient, db_session, auditor_user, auditor_headers, ): db = db_session await _reconcile(db, [_spec("h1", severity="critical"), _spec("h2", severity="low")]) await db.flush() r = await client.get("/api/v1/auditor/findings?open_only=true", headers=auditor_headers) assert r.status_code == 200 body = r.json()["data"] assert body["meta"]["total"] == 2 # critical first assert body["items"][0]["severity"] == "critical" assert body["summary"]["open_total"] == 2 fid = body["items"][0]["id"] r = await client.post( f"/api/v1/auditor/findings/{fid}/decide", json={"decision": "resolve", "note": "handled"}, headers=auditor_headers, ) assert r.status_code == 200 assert r.json()["data"]["status"] == "resolved" @pytest.mark.asyncio async def test_http_requires_auditor(client: AsyncClient, db_session): r = await client.get("/api/v1/auditor/findings") assert r.status_code in (401, 403) # ── Review-fix regressions (PR-D) ─────────────────────────────────────── @pytest.mark.asyncio async def test_signin_decide_acknowledge_is_terminal(db_session): """Acknowledging a sign-in finding via the unified path resolves it (matches the legacy ack + projection), not 'acknowledged' which the next projection would auto-resolve and lose provenance.""" db = db_session actor = _person() db.add(actor) ev = _signin() db.add(ev) await db.flush() await fs.project_signin_findings(db) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.source_id == ev.id) )).scalar_one() await fs.decide_finding( db, finding_id=f.id, decision=const.FINDING_DECISION_ACKNOWLEDGE, note="reviewed, benign", accepted_days=None, actor_user_id=actor.id, ) await db.flush() await db.refresh(f) assert f.status == const.FINDING_STATUS_RESOLVED @pytest.mark.asyncio async def test_reopen_blocked_when_auto_resolved(db_session): db = db_session actor = _person() db.add(actor) await _reconcile(db, [_spec("gone")]) await db.flush() # Item disappears → auto-resolved. await _reconcile(db, []) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.dedup_key == "gone") )).scalar_one() assert f.auto_resolved is True from app.core.exceptions import AppException with pytest.raises(AppException): await fs.decide_finding( db, finding_id=f.id, decision=const.FINDING_DECISION_REOPEN, note="bring it back", accepted_days=None, actor_user_id=actor.id, ) @pytest.mark.asyncio async def test_accept_risk_expiry_ignored_for_mirrored(db_session): """Mirrored (exposure) source rows carry no expiry — accepted_days must not silently set a timer that reconcile can't honor.""" db = db_session actor = _person() db.add(actor) ex = _exposure() db.add(ex) await db.flush() await fs.project_exposure_findings(db) await db.flush() f = (await db.execute( select(AuditorFinding).where(AuditorFinding.source_id == ex.id) )).scalar_one() await fs.decide_finding( db, finding_id=f.id, decision=const.FINDING_DECISION_ACCEPT_RISK, note="intentional public asset", accepted_days=30, actor_user_id=actor.id, ) await db.flush() await db.refresh(f) assert f.status == const.FINDING_STATUS_ACCEPTED_RISK assert f.accepted_until is None