"""Per-run ledger (identity_sync_run_items) — capture + query + retention. The ledger is the person-by-person cutover testing surface: every vendor-touching operation a sweep / single push performs gets one row with the rendered request payload and the vendor response detail. These tests pin: * sweep capture: created/updated/failed recorded; cron sweeps do NOT record hash-skips, manual runs DO * out-of-scope deactivate captured * single-person push records an item AND echoes request_payload back through ``outcome.detail`` * HTTP: per-run drill-down + cross-run person history + the filter-required guard + employee 403 * retention: ``cleanup_run_items`` deletes only rows past the window """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.identity_sync.models import ( IdentitySyncRun, IdentitySyncRunItem, IdentitySyncState, IdentitySyncTarget, ) from app.domains.identity_sync.runners.base import PersonOutcome, SyncRunner from app.domains.identity_sync.service import IdentitySyncService from app.identity.person_models import IdentityPerson pytestmark = pytest.mark.asyncio class _StubRunner(SyncRunner): supports_deactivate = True supports_delete = True def __init__(self, *, fail_emails: set[str] | None = None) -> None: self.fail_emails = fail_emails or set() async def upsert(self, *, person_email, payload, prior_external_id): if person_email in self.fail_emails: return PersonOutcome( status="failed", error="vendor said no", detail={"http_status": 400}, ) return PersonOutcome( status="success", external_id=f"ext-{person_email}", detail={"http_status": 200}, ) async def deactivate(self, *, person_email, prior_external_id): return PersonOutcome(status="deactivated", detail={"http_status": 200}) async def delete(self, *, person_email, prior_external_id): return PersonOutcome(status="deleted") def describe(self) -> str: return "stub" async def _person(db: AsyncSession, *, email: str, status: str = "active") -> IdentityPerson: p = IdentityPerson( id=uuid.uuid4(), work_email=email, display_name=email.split("@")[0], employment_status=status, source_system="successfactors", hris_person_id=f"sf-{email}", ) db.add(p) await db.flush() return p async def _target(db: AsyncSession, *, slug: str) -> IdentitySyncTarget: t = IdentitySyncTarget( id=uuid.uuid4(), name=f"Ledger {slug}", slug=slug, kind="scim", enabled=True, on_offboard="deactivate", mapping_config={"rules": [ {"source_template": "{work_email}", "target_field": "userName"}, ]}, filter_config={ "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "employment_statuses": [], "job_title_includes": [], "email_suffixes": [], "person_ids": [], "mode": "and", }, schedule_config={ "trigger": "manual", "cron_expr": None, "timezone": "UTC", }, runner_options={}, ) db.add(t) await db.flush() return t async def _run_row(db: AsyncSession, target_id) -> IdentitySyncRun: r = IdentitySyncRun( id=uuid.uuid4(), target_id=target_id, started_at=datetime.now(UTC), status="running", trigger_source="manual:test", ) db.add(r) await db.flush() return r async def _items(db: AsyncSession, run_id) -> list[IdentitySyncRunItem]: return list((await db.execute( select(IdentitySyncRunItem).where( IdentitySyncRunItem.run_id == run_id, ).order_by(IdentitySyncRunItem.created_at) )).scalars().all()) async def test_sweep_records_created_failed_and_payload( db_session: AsyncSession, ): db = db_session await _person(db, email="ledger-a@pingcap.com") await _person(db, email="ledger-b@pingcap.com") target = await _target(db, slug="ledger-sweep") run = await _run_row(db, target.id) await db.commit() svc = IdentitySyncService(db) runner = _StubRunner(fail_emails={"ledger-b@pingcap.com"}) summary, status = await svc._sweep( target=target, runner=runner, run_id=run.id, record_skipped=False, ) await db.commit() items = await _items(db, run.id) by_subject = {i.subject_display: i for i in items} a = by_subject["ledger-a@pingcap.com"] assert a.outcome == "created" assert a.item_type == "person" assert a.request_payload == {"userName": "ledger-a@pingcap.com"} assert a.response_detail == {"http_status": 200} assert a.external_key == "ext-ledger-a@pingcap.com" assert a.duration_ms is not None b = by_subject["ledger-b@pingcap.com"] assert b.outcome == "failed" assert "vendor said no" in (b.error or "") async def test_cron_sweep_skips_hash_skip_rows_manual_records_them( db_session: AsyncSession, ): db = db_session await _person(db, email="ledger-skip@pingcap.com") target = await _target(db, slug="ledger-skip") run1 = await _run_row(db, target.id) await db.commit() svc = IdentitySyncService(db) await svc._sweep( target=target, runner=_StubRunner(), run_id=run1.id, record_skipped=False, ) await db.commit() assert {i.outcome for i in await _items(db, run1.id)} == {"created"} # Second sweep — payload unchanged → hash-skip. Cron mode records # nothing; manual mode records the skip WITH the payload. run2 = await _run_row(db, target.id) await svc._sweep( target=target, runner=_StubRunner(), run_id=run2.id, record_skipped=False, ) await db.commit() assert await _items(db, run2.id) == [] run3 = await _run_row(db, target.id) await svc._sweep( target=target, runner=_StubRunner(), run_id=run3.id, record_skipped=True, ) await db.commit() skips = await _items(db, run3.id) assert len(skips) == 1 assert skips[0].outcome == "skipped" assert skips[0].request_payload == { "userName": "ledger-skip@pingcap.com", } async def test_out_of_scope_deactivate_recorded(db_session: AsyncSession): db = db_session leaver = await _person(db, email="ledger-leaver@pingcap.com") target = await _target(db, slug="ledger-oos") # Previously synced state row; person now out of filter (left). db.add(IdentitySyncState( id=uuid.uuid4(), target_id=target.id, person_id=leaver.id, last_status="success", external_id="ext-leaver", last_payload_hash="x", )) # Keep the person ACTIVE and push them out of scope purely via the # person_ids override. (A ``leaver_pending`` person is now DEFERRED # from out-of-scope deactivation until their local last working day # passes — #1735 notice-period guard — so this ledger-recording test, # which only needs an out-of-scope person the sweep WILL deactivate, # uses an active one instead. An active out-of-scope person is still # deactivated; the #1735 guard only shields leaver_pending.) await db.flush() # Pin scope via person_ids so the person is provably out of filter. target.filter_config = { **target.filter_config, "person_ids": [str(uuid.uuid4())], "mode": "and", } run = await _run_row(db, target.id) await db.commit() svc = IdentitySyncService(db) await svc._sweep( target=target, runner=_StubRunner(), run_id=run.id, record_skipped=False, ) await db.commit() items = await _items(db, run.id) assert [i.outcome for i in items] == ["deactivated"] assert items[0].subject_display == "ledger-leaver@pingcap.com" async def test_push_one_person_records_item_and_echoes_payload( db_session: AsyncSession, ): db = db_session p = await _person(db, email="ledger-push@pingcap.com") target = await _target(db, slug="ledger-push") run = await _run_row(db, target.id) await db.commit() svc = IdentitySyncService(db) outcome = await svc._push_one_person( target=target, runner=_StubRunner(), person_id=p.id, force_in_scope=True, run_id=run.id, ) await db.commit() assert outcome.status == "success" assert outcome.detail is not None assert outcome.detail["request_payload"] == { "userName": "ledger-push@pingcap.com", } items = await _items(db, run.id) assert len(items) == 1 assert items[0].outcome == "created" assert items[0].request_payload == { "userName": "ledger-push@pingcap.com", } async def test_run_items_http_endpoints_and_rbac( client, admin_headers, employee_headers, db_session: AsyncSession, ): db = db_session p = await _person(db, email="ledger-http@pingcap.com") target = await _target(db, slug="ledger-http") run = await _run_row(db, target.id) db.add(IdentitySyncRunItem( id=uuid.uuid4(), run_id=run.id, target_id=target.id, item_type="person", person_id=p.id, subject_display="ledger-http@pingcap.com", external_key="ext-1", outcome="created", request_payload={"userName": "ledger-http@pingcap.com"}, response_detail={"http_status": 200}, )) await db.commit() resp = await client.get( f"/api/v1/identity-sync/runs/{run.id}/items", headers=admin_headers, ) assert resp.status_code == 200, resp.text rows = resp.json()["data"] assert len(rows) == 1 assert rows[0]["outcome"] == "created" assert rows[0]["request_payload"] == { "userName": "ledger-http@pingcap.com", } # Cross-run person history. resp = await client.get( "/api/v1/identity-sync/items", params={"person_id": str(p.id)}, headers=admin_headers, ) assert resp.status_code == 200 assert len(resp.json()["data"]) == 1 # Unfiltered scan rejected. resp = await client.get( "/api/v1/identity-sync/items", headers=admin_headers, ) assert resp.status_code == 400 # RBAC. resp = await client.get( f"/api/v1/identity-sync/runs/{run.id}/items", headers=employee_headers, ) assert resp.status_code == 403 async def test_cleanup_deletes_only_past_retention( db_session: AsyncSession, ): db = db_session target = await _target(db, slug="ledger-retention") run = await _run_row(db, target.id) old = IdentitySyncRunItem( id=uuid.uuid4(), run_id=run.id, target_id=target.id, item_type="person", subject_display="old@pingcap.com", outcome="created", created_at=datetime.now(UTC) - timedelta(days=120), ) fresh = IdentitySyncRunItem( id=uuid.uuid4(), run_id=run.id, target_id=target.id, item_type="person", subject_display="fresh@pingcap.com", outcome="created", ) db.add_all([old, fresh]) await db.commit() deleted = await IdentitySyncService(db).cleanup_run_items( retention_days=90, ) assert deleted == 1 remaining = await _items(db, run.id) assert [i.subject_display for i in remaining] == ["fresh@pingcap.com"]