"""Tests for the per-person forensic lifecycle timeline. Covers: 1. unknown person → 404 (admin) 2. happy path: HR + provisioning + audit rows merge time-desc 3. seat allocation produces two timeline rows (allocated + revoked) 4. cloud grant: ``person_id`` is String(36) — bound as ``str(uuid)`` 5. 4 KPIs computed (days_at_company, current_resources, requests, in_flight) 6. soft-deleted person is still queryable (forensic case-file requirement) 7. evidence-pack download returns a ZIP and writes an audit row 8. employee role cannot reach the forensic endpoint (RBAC) """ from __future__ import annotations import io import zipfile from datetime import UTC, date, datetime, timedelta from decimal import Decimal from uuid import uuid4 import pytest from sqlalchemy import select from app.audit.models import AuditLog from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) from app.domains.cmdb.models import CmdbCloudAccessGrant, CmdbCloudScope from app.domains.hr.models import HrLifecycleEvent from app.domains.provisioning.models import STATUS_PENDING, ProvisioningTask from app.identity.person_models import IdentityPerson from app.integrations.models import Integration async def _mkplan(db_session) -> SoftwareLicensePlan: sw = AssetSoftwareCatalog( id=uuid4(), name=f"Forensic SaaS {uuid4().hex[:6]}", asset_type="SaaS", status="active", ) db_session.add(sw) await db_session.flush() plan = SoftwareLicensePlan( id=uuid4(), software_id=sw.id, name="Standard", price=Decimal("0.00"), billing_cycle="monthly", status="active", max_seats=None, over_quota_policy="reject", ) db_session.add(plan) await db_session.flush() return plan async def _make_person( db_session, *, employment_status="active", hire_date: date | None = None, termination_date: date | None = None, deleted: bool = False, ) -> IdentityPerson: p = IdentityPerson( id=uuid4(), source_system="successfactors", hris_person_id=f"sf-forensic-{uuid4().hex[:8]}", employee_no=f"EMP-{uuid4().hex[:6]}", work_email=f"forensic-{uuid4().hex[:6]}@company.com", display_name="Forensic Subject", employment_status=employment_status, hire_date=hire_date or (date.today() - timedelta(days=120)), termination_date=termination_date, deleted_at=datetime.now(UTC) if deleted else None, ) db_session.add(p) await db_session.commit() await db_session.refresh(p) return p @pytest.mark.asyncio async def test_forensic_unknown_person_returns_404(client, admin_user): _, token = admin_user resp = await client.get( f"/api/v1/lifecycle/forensic/{uuid4()}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_forensic_employee_role_forbidden(client, employee_user): _, token = employee_user resp = await client.get( f"/api/v1/lifecycle/forensic/{uuid4()}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_forensic_merges_hr_provisioning_audit_in_time_desc( client, admin_user, db_session, ): _, token = admin_user person = await _make_person(db_session) # HR lifecycle event (older) hr_evt = HrLifecycleEvent( person_id=person.id, event_type="onboard", effective_date=date.today() - timedelta(days=100), detail={"source": "hris"}, ) db_session.add(hr_evt) # Provisioning task (mid) pt = ProvisioningTask( user_id=person.id, action="grant", target_type="software_seat", target_id=uuid4(), source="onboarding", execution_mode="auto", status=STATUS_PENDING, ) db_session.add(pt) await db_session.commit() # Audit log (newest — actor is the person themselves) audit_row = AuditLog( trace_id=uuid4(), actor_user_id=person.id, actor_type="user", action="self_service.profile_updated", resource_type="identity_person", resource_id=str(person.id), status="ok", detail={}, ) db_session.add(audit_row) await db_session.commit() resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200, resp.text payload = resp.json()["data"] assert payload["person"]["id"] == str(person.id) sources = {row["source"] for row in payload["timeline"]} assert "hr_lifecycle_events" in sources assert "provisioning_tasks" in sources assert "audit_logs" in sources # time-desc invariant: every row.time >= the next row.time times = [row["time"] for row in payload["timeline"]] assert times == sorted(times, reverse=True), times @pytest.mark.asyncio async def test_forensic_seat_allocation_emits_two_rows_on_revoke( client, admin_user, db_session, ): """Allocated_at and revoked_at each produce a timeline row.""" _, token = admin_user person = await _make_person(db_session) plan = await _mkplan(db_session) alloc = SoftwareSeatAllocation( user_id=person.id, plan_id=plan.id, status="revoked", allocated_at=datetime.now(UTC) - timedelta(days=30), revoked_at=datetime.now(UTC) - timedelta(days=1), ) db_session.add(alloc) await db_session.commit() resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 payload = resp.json()["data"] seat_rows = [r for r in payload["timeline"] if r["source"] == "software_seat_allocations"] phases = sorted(r["detail"]["phase"] for r in seat_rows) assert phases == ["allocated", "revoked"], phases @pytest.mark.asyncio async def test_forensic_cloud_grant_binds_string_uuid( client, admin_user, db_session, ): """``cmdb_cloud_access_grants.person_id`` is String(36) — must be queried by ``str(uuid)`` (see reference_asyncmy_uuid_bind).""" _, token = admin_user person = await _make_person(db_session) integ = Integration( id=uuid4(), kind="cloud", provider="aws", name="forensic-test-aws", slug=f"forensic-aws-{uuid4().hex[:6]}", enabled=True, is_primary=False, ) db_session.add(integ) await db_session.flush() scope = CmdbCloudScope( id=uuid4(), integration_id=str(integ.id), scope_type="aws_account", external_id=f"acct-{uuid4().hex[:8]}", status="active", ) db_session.add(scope) await db_session.flush() grant = CmdbCloudAccessGrant( scope_id=str(scope.id), person_id=str(person.id), role_key="aws:Reader", role_display_name="AWS Reader", status="active", granted_at=datetime.now(UTC) - timedelta(days=10), ) db_session.add(grant) await db_session.commit() resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 payload = resp.json()["data"] grant_rows = [ r for r in payload["timeline"] if r["source"] == "cmdb_cloud_access_grants" ] assert len(grant_rows) == 1 assert grant_rows[0]["status_badge"] == "active" @pytest.mark.asyncio async def test_forensic_kpis_present(client, admin_user, db_session): _, token = admin_user hire = date.today() - timedelta(days=365) person = await _make_person(db_session, hire_date=hire) resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 kpis = resp.json()["data"]["kpis"] # Days at company: hire was 365 days ago + inclusive end = 366 assert kpis["days_at_company"] is not None assert kpis["days_at_company"] >= 365 assert "current_resources_held" in kpis assert kpis["current_resources_held"]["of"] == 7 assert "dims" in kpis["current_resources_held"] assert "lifetime_request_count" in kpis assert "in_flight" in kpis @pytest.mark.asyncio async def test_forensic_returns_soft_deleted_person( client, admin_user, db_session, ): """Soft-deleted leaver MUST be queryable for retrospective evidence.""" _, token = admin_user person = await _make_person( db_session, employment_status="left", termination_date=date.today() - timedelta(days=30), deleted=True, ) resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 payload = resp.json()["data"] assert payload["person"]["id"] == str(person.id) assert payload["person"]["deleted_at"] is not None @pytest.mark.asyncio async def test_forensic_truncated_by_source_flag_set_when_cap_hit( client, admin_user, db_session, ): """When a source produces more rows than ``per_source_limit``, the payload MUST surface ``truncated_by_source[source] = True``. The review caught a silent-truncation hazard where the JSON looked comprehensive but the oldest rows had been dropped.""" _, token = admin_user person = await _make_person(db_session) # Seed 6 HR events; cap will be 5 via querystring override. # We can't pass ?limit= from the HTTP route (it's hard-coded), so # instead we exercise the service layer directly with a low cap # so the test stays fast and deterministic. from app.hr_lifecycle.forensic_service import ForensicTimelineService for i in range(6): db_session.add(HrLifecycleEvent( person_id=person.id, event_type="onboard" if i % 2 == 0 else "offboard", effective_date=date.today() - timedelta(days=i), detail={"i": i}, )) await db_session.commit() svc = ForensicTimelineService(db_session) payload = await svc.build_payload(person, per_source_limit=5) assert payload["per_source_limit"] == 5 assert payload["truncated_by_source"]["hr_lifecycle_events"] is True # Non-seeded sources should be False, not missing assert payload["truncated_by_source"]["provisioning_tasks"] is False @pytest.mark.asyncio async def test_forensic_evidence_pack_download_returns_zip_and_logs( client, admin_user, db_session, ): _, token = admin_user person = await _make_person(db_session) db_session.add(HrLifecycleEvent( person_id=person.id, event_type="onboard", effective_date=person.hire_date, detail={}, )) await db_session.commit() resp = await client.get( f"/api/v1/lifecycle/forensic/{person.id}/evidence-pack", headers={"Authorization": f"Bearer {token}"}, ) assert resp.status_code == 200 assert resp.headers["content-type"] == "application/zip" assert f"forensic-{person.id}.zip" in resp.headers["content-disposition"] with zipfile.ZipFile(io.BytesIO(resp.content)) as zf: names = set(zf.namelist()) assert {"manifest.json", "timeline.json", "timeline.csv", "README.txt"} <= names # Audit row written audit_row = ( await db_session.execute( select(AuditLog).where( AuditLog.action == "lifecycle.forensic.evidence_pack_downloaded", AuditLog.resource_id == str(person.id), ) ) ).scalar_one_or_none() assert audit_row is not None assert audit_row.status == "ok"