import asyncio import logging import uuid from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any, cast import pytest from alembic import command from alembic.config import Config from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from lead_scoring.db.models.outbox_event import OutboxEvent from lead_scoring.db.models.review_audit import ReviewAudit from lead_scoring.db.models.scoring_run import ScoringRun from lead_scoring.retention import ( RetentionCleanupResult, TenantPurgeResult, execute_retention_cleanup, purge_tenant_data, ) def get_alembic_config(database_url: str) -> Config: root_dir = Path(__file__).resolve().parent.parent ini_path = root_dir / "alembic.ini" alembic_cfg = Config(str(ini_path)) alembic_cfg.attributes["sqlalchemy_url"] = database_url return alembic_cfg @pytest.fixture async def db_session_factory(temp_postgres_db: str): alembic_cfg = get_alembic_config(temp_postgres_db) await asyncio.to_thread(command.upgrade, alembic_cfg, "head") engine = create_async_engine(temp_postgres_db) session_factory = async_sessionmaker(engine, expire_on_commit=False) try: yield session_factory finally: async with engine.begin() as conn: await conn.execute( text("TRUNCATE TABLE review_audits, outbox_events, scoring_runs CASCADE;") ) await engine.dispose() def _sample_snapshot_with_pii() -> dict[str, Any]: return { "schema_version": "1.0.0", "lead_profile": { "ems_lead_id": "lead-pii-001", "full_name": "Nguyễn Văn Nhạy Cảm", "phone": "+84901234567", "email": "sensitive_user@example.com", "company": "Top Secret Corp", "title": "Director", }, "conversations": [ { "conversation_id": "conv-001", "assigned_agent_id": "agent-secret-01", "messages": [ { "message_id": "msg-001", "sender_role": "lead", "content": "Tôi muốn mua gói enterprise 100k USD ngay hôm nay!", "timestamp": "2026-05-01T10:00:00Z", } ], } ], "trigger_metadata": { "trigger_conversation_id": "conv-001", }, "hitl_policy": { "confidence_threshold": 0.8, "policy_version": "1.0.0", }, } @pytest.mark.asyncio async def test_retention_boundary_before_exact_after_90_days( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) cutoff = fixed_now - timedelta(days=90) # 2026-05-30 12:00:00 UTC # Run 1: 1 second before cutoff (created_at = 2026-05-30 11:59:59 UTC) -> EXPIRED -> Redact run_id_before = str(uuid.uuid4()) # Run 2: Exactly at cutoff (created_at = 2026-05-30 12:00:00 UTC) -> EXPIRED -> Redact run_id_exact = str(uuid.uuid4()) # Run 3: 1 second after cutoff (created_at = 2026-05-30 12:00:01 UTC) -> ACTIVE -> Keep intact run_id_after = str(uuid.uuid4()) async with db_session_factory() as session: session.add_all( [ ScoringRun( id=run_id_before, tenant_id="tenant_a", subject_ref="lead-pii-001", scoring_config_version="1.0.0", idempotency_key="key-before", payload_hash="hash-before-pii", status="completed", snapshot_data=_sample_snapshot_with_pii(), effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, rule_score=80, ai_score=90, final_score=85, effective_score=85, reviewed_by="agent_secret_before", review_note="Confidential reviewer note before", created_at=cutoff - timedelta(seconds=1), updated_at=cutoff - timedelta(seconds=1), ), ScoringRun( id=run_id_exact, tenant_id="tenant_a", subject_ref="lead-pii-001", scoring_config_version="1.0.0", idempotency_key="key-exact", payload_hash="hash-exact-pii", status="completed", snapshot_data=_sample_snapshot_with_pii(), effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, rule_score=75, ai_score=85, final_score=80, effective_score=80, reviewed_by="agent_secret_exact", review_note="Confidential reviewer note exact", created_at=cutoff, updated_at=cutoff, ), ScoringRun( id=run_id_after, tenant_id="tenant_a", subject_ref="lead-pii-001", scoring_config_version="1.0.0", idempotency_key="key-after", payload_hash="hash-after-pii", status="completed", snapshot_data=_sample_snapshot_with_pii(), effective_config={"rule_weight": 0.5, "ai_weight": 0.5}, rule_score=70, ai_score=80, final_score=75, effective_score=75, reviewed_by="agent_active", review_note="Active reviewer note", created_at=cutoff + timedelta(seconds=1), updated_at=cutoff + timedelta(seconds=1), ), ] ) await session.commit() # Execute retention cleanup with injected clock result: RetentionCleanupResult = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, ) assert result.runs_redacted == 2 # Verify each run's persistence state async with db_session_factory() as session: r_before = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_before)) ).scalar_one() r_exact = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_exact)) ).scalar_one() r_after = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_id_after)) ).scalar_one() # Run before cutoff: redacted assert r_before.snapshot_data == {} assert r_before.payload_hash == "redacted" assert r_before.reviewed_by is None assert r_before.review_note is None assert getattr(r_before, "redacted_at", None) is not None assert r_before.final_score == 85 assert r_before.effective_score == 85 # Run exact at cutoff: redacted assert r_exact.snapshot_data == {} assert r_exact.payload_hash == "redacted" assert r_exact.reviewed_by is None assert r_exact.review_note is None assert getattr(r_exact, "redacted_at", None) is not None assert r_exact.final_score == 80 assert r_exact.effective_score == 80 # Run after cutoff: preserved intact assert r_after.snapshot_data != {} assert "Nguyễn Văn Nhạy Cảm" in str(r_after.snapshot_data) assert r_after.payload_hash == "hash-after-pii" assert r_after.reviewed_by == "agent_active" assert r_after.review_note == "Active reviewer note" assert getattr(r_after, "redacted_at", None) is None @pytest.mark.asyncio async def test_retention_preserves_strict_audit_allowlist( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=95) run_id = str(uuid.uuid4()) parent_id = str(uuid.uuid4()) async with db_session_factory() as session: session.add( ScoringRun( id=parent_id, tenant_id="tenant_audit_test", subject_ref="subject_ref_123", scoring_config_version="1.2.0", idempotency_key="idempotency_parent_audit", payload_hash="redacted", status="superseded", snapshot_data={}, redacted_at=old_time, created_at=old_time - timedelta(days=1), updated_at=old_time - timedelta(days=1), ) ) session.add( ScoringRun( id=run_id, tenant_id="tenant_audit_test", subject_ref="subject_ref_123", scoring_config_version="1.2.0", parent_run_id=parent_id, idempotency_key="idempotency_key_audit", payload_hash="sensitive_hash_value_123456", status="completed", snapshot_data=_sample_snapshot_with_pii(), effective_config={ "version": "1.2.0", "rule_weight": 0.4, "ai_weight": 0.6, "confidence_threshold": 0.85, "effort_profile": "medium", "ai_model": "claude-3-5-sonnet", }, confidence_threshold=0.85, rule_score=88, ai_score=92, final_score=90, effective_score=90, scoring_mode="medium_ai", degraded=False, ai_metadata={ "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, }, confidence=0.88, error_code=None, retryable=None, version=2, review_decision="approve", reviewed_by="human_actor_999", reviewed_at=old_time + timedelta(hours=1), review_note="Internal reviewer private commentary", started_at=old_time, completed_at=old_time + timedelta(seconds=15), created_at=old_time, updated_at=old_time + timedelta(hours=1), ) ) await session.commit() result = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, ) assert result.runs_redacted == 1 async with db_session_factory() as session: run = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_id)) ).scalar_one() # Sensitive fields must be redacted assert run.snapshot_data == {} assert run.payload_hash == "redacted" assert run.reviewed_by is None assert run.review_note is None # Allowed structured audit fields must be preserved exactly assert run.id == run_id assert run.tenant_id == "tenant_audit_test" assert run.subject_ref == "subject_ref_123" assert run.scoring_config_version == "1.2.0" assert run.parent_run_id == parent_id assert run.idempotency_key == "idempotency_key_audit" assert run.status == "completed" assert run.rule_score == 88 assert run.ai_score == 92 assert run.final_score == 90 assert run.effective_score == 90 assert run.scoring_mode == "medium_ai" assert run.degraded is False assert run.confidence == 0.88 assert run.confidence_threshold == 0.85 assert run.version == 2 assert run.review_decision == "approve" assert run.reviewed_at == old_time + timedelta(hours=1) assert run.started_at == old_time assert run.completed_at == old_time + timedelta(seconds=15) assert run.created_at == old_time assert run.ai_metadata == { "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, } @pytest.mark.asyncio async def test_retention_review_audits_actor_and_note_redaction( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=91) recent_time = fixed_now - timedelta(days=10) audit_old_id = str(uuid.uuid4()) audit_recent_id = str(uuid.uuid4()) run_id = str(uuid.uuid4()) async with db_session_factory() as session: session.add_all( [ ReviewAudit( id=audit_old_id, run_id=run_id, tenant_id="tenant_audit", decision="override", actor_id="reviewer_private_id_123", actor_role="agent", idempotency_key="audit-idem-old", payload_hash="audit-hash-old", previous_version=1, new_version=2, calculated_score=60, effective_score=85, child_run_id=None, note="Confidential human explanation with private data", created_at=old_time, ), ReviewAudit( id=audit_recent_id, run_id=run_id, tenant_id="tenant_audit", decision="approve", actor_id="reviewer_recent_id_456", actor_role="administrator", idempotency_key="audit-idem-recent", payload_hash="audit-hash-recent", previous_version=2, new_version=3, calculated_score=85, effective_score=85, child_run_id=None, note="Recent unexpired note", created_at=recent_time, ), ] ) await session.commit() result = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, ) assert result.audits_redacted == 1 async with db_session_factory() as session: old_audit = ( await session.execute(select(ReviewAudit).where(ReviewAudit.id == audit_old_id)) ).scalar_one() recent_audit = ( await session.execute(select(ReviewAudit).where(ReviewAudit.id == audit_recent_id)) ).scalar_one() # Old audit: actor_id, note, payload_hash redacted assert old_audit.actor_id == "redacted" assert old_audit.note is None assert old_audit.payload_hash == "redacted" assert getattr(old_audit, "redacted_at", None) is not None # Old audit: non-personal structured audit fields preserved assert old_audit.actor_role == "agent" assert old_audit.decision == "override" assert old_audit.calculated_score == 60 assert old_audit.effective_score == 85 assert old_audit.previous_version == 1 assert old_audit.new_version == 2 assert old_audit.idempotency_key == "audit-idem-old" # Recent audit: completely preserved assert recent_audit.actor_id == "reviewer_recent_id_456" assert recent_audit.note == "Recent unexpired note" assert recent_audit.payload_hash == "audit-hash-recent" assert getattr(recent_audit, "redacted_at", None) is None @pytest.mark.asyncio async def test_retention_outbox_events_preserves_pending_and_cleans_delivered( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=92) recent_time = fixed_now - timedelta(days=5) ev_old_delivered_id = str(uuid.uuid4()) ev_old_dead_letter_id = str(uuid.uuid4()) ev_old_pending_id = str(uuid.uuid4()) ev_old_delivering_id = str(uuid.uuid4()) ev_recent_delivered_id = str(uuid.uuid4()) async with db_session_factory() as session: session.add_all( [ OutboxEvent( id=ev_old_delivered_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id="run-1", payload={"data": "old_delivered"}, occurred_at=old_time, delivery_status="delivered", delivered_at=old_time, ), OutboxEvent( id=ev_old_dead_letter_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id="run-2", payload={"data": "old_dead_letter"}, occurred_at=old_time, delivery_status="dead_letter", ), OutboxEvent( id=ev_old_pending_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id="run-3", payload={"data": "old_pending_must_not_drop"}, occurred_at=old_time, delivery_status="pending", ), OutboxEvent( id=ev_old_delivering_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id="run-4", payload={"data": "old_delivering_must_not_drop"}, occurred_at=old_time, delivery_status="delivering", lease_expires_at=fixed_now + timedelta(seconds=30), ), OutboxEvent( id=ev_recent_delivered_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id="run-5", payload={"data": "recent_delivered"}, occurred_at=recent_time, delivery_status="delivered", delivered_at=recent_time, ), ] ) await session.commit() result = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, ) # 2 old terminal events cleaned (delivered & dead_letter) assert result.outbox_events_cleaned == 2 async with db_session_factory() as session: events = (await session.execute(select(OutboxEvent.id))).scalars().all() assert ev_old_delivered_id not in events assert ev_old_dead_letter_id not in events # Pending and delivering events MUST remain assert ev_old_pending_id in events assert ev_old_delivering_id in events # Recent delivered event must remain assert ev_recent_delivered_id in events @pytest.mark.asyncio async def test_retention_idempotency_and_restart_safety( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=100) run_id = str(uuid.uuid4()) audit_id = str(uuid.uuid4()) event_id = str(uuid.uuid4()) async with db_session_factory() as session: session.add( ScoringRun( id=run_id, tenant_id="tenant_idem", subject_ref="lead-1", scoring_config_version="1.0.0", idempotency_key="run-idem", payload_hash="hash", status="completed", snapshot_data=_sample_snapshot_with_pii(), rule_score=80, final_score=80, effective_score=80, reviewed_by="agent_1", review_note="note_1", created_at=old_time, updated_at=old_time, ) ) session.add( ReviewAudit( id=audit_id, run_id=run_id, tenant_id="tenant_idem", decision="approve", actor_id="agent_1", actor_role="agent", idempotency_key="audit-idem", payload_hash="hash", previous_version=1, new_version=2, calculated_score=80, effective_score=80, note="audit note", created_at=old_time, ) ) session.add( OutboxEvent( id=event_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id=run_id, payload={"data": "delivered"}, occurred_at=old_time, delivery_status="delivered", delivered_at=old_time, ) ) await session.commit() # Pass 1 res1 = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) assert res1.runs_redacted == 1 assert res1.audits_redacted == 1 assert res1.outbox_events_cleaned == 1 # Pass 2 immediately (idempotency check) res2 = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) assert res2.runs_redacted == 0 assert res2.audits_redacted == 0 assert res2.outbox_events_cleaned == 0 @pytest.mark.asyncio async def test_tenant_offboarding_purges_all_target_tenant_data_only( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) tenant_target = "tenant_offboard_target" tenant_other = "tenant_other_keep" run_target_id = str(uuid.uuid4()) run_other_id = str(uuid.uuid4()) audit_target_id = str(uuid.uuid4()) audit_other_id = str(uuid.uuid4()) outbox_target_id = str(uuid.uuid4()) outbox_other_id = str(uuid.uuid4()) async with db_session_factory() as session: # Target tenant data session.add( ScoringRun( id=run_target_id, tenant_id=tenant_target, subject_ref="lead-target", scoring_config_version="1.0.0", idempotency_key="run-target", payload_hash="hash-target", status="completed", snapshot_data=_sample_snapshot_with_pii(), created_at=fixed_now, updated_at=fixed_now, ) ) session.add( ReviewAudit( id=audit_target_id, run_id=run_target_id, tenant_id=tenant_target, decision="approve", actor_id="agent_target", actor_role="agent", idempotency_key="audit-target", payload_hash="hash-audit-target", previous_version=1, new_version=2, created_at=fixed_now, ) ) session.add( OutboxEvent( id=outbox_target_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id=run_target_id, payload={"tenant_id": tenant_target, "subject": "lead-target"}, occurred_at=fixed_now, delivery_status="pending", ) ) # Other tenant data (must be preserved) session.add( ScoringRun( id=run_other_id, tenant_id=tenant_other, subject_ref="lead-other", scoring_config_version="1.0.0", idempotency_key="run-other", payload_hash="hash-other", status="completed", snapshot_data=_sample_snapshot_with_pii(), created_at=fixed_now, updated_at=fixed_now, ) ) session.add( ReviewAudit( id=audit_other_id, run_id=run_other_id, tenant_id=tenant_other, decision="approve", actor_id="agent_other", actor_role="agent", idempotency_key="audit-other", payload_hash="hash-audit-other", previous_version=1, new_version=2, created_at=fixed_now, ) ) session.add( OutboxEvent( id=outbox_other_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id=run_other_id, payload={"tenant_id": tenant_other, "subject": "lead-other"}, occurred_at=fixed_now, delivery_status="pending", ) ) await session.commit() purge_result: TenantPurgeResult = await purge_tenant_data( db_session_factory, tenant_id=tenant_target, clock=lambda: fixed_now, ) assert purge_result.tenant_id == tenant_target assert purge_result.runs_deleted == 1 assert purge_result.audits_deleted == 1 assert purge_result.outbox_events_deleted == 1 # Verify target tenant data is 100% gone async with db_session_factory() as session: target_runs = ( (await session.execute(select(ScoringRun).where(ScoringRun.tenant_id == tenant_target))) .scalars() .all() ) target_audits = ( ( await session.execute( select(ReviewAudit).where(ReviewAudit.tenant_id == tenant_target) ) ) .scalars() .all() ) target_events = ( (await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_target_id))) .scalars() .all() ) assert len(target_runs) == 0 assert len(target_audits) == 0 assert len(target_events) == 0 # Verify other tenant data is 100% preserved other_run = ( await session.execute(select(ScoringRun).where(ScoringRun.tenant_id == tenant_other)) ).scalar_one() other_audit = ( await session.execute(select(ReviewAudit).where(ReviewAudit.tenant_id == tenant_other)) ).scalar_one() other_event = ( await session.execute(select(OutboxEvent).where(OutboxEvent.run_id == run_other_id)) ).scalar_one() assert other_run.id == run_other_id assert other_audit.id == audit_other_id assert other_event.id == outbox_other_id @pytest.mark.asyncio async def test_tenant_offboarding_idempotency_and_nonexistent_tenant( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) # Purge non-existent tenant res_none = await purge_tenant_data( db_session_factory, tenant_id="non_existent_tenant_xyz", clock=lambda: fixed_now, ) assert res_none.runs_deleted == 0 assert res_none.audits_deleted == 0 assert res_none.outbox_events_deleted == 0 @pytest.mark.asyncio async def test_retention_and_offboarding_failure_signal_sanitizes_pii( db_session_factory: async_sessionmaker[AsyncSession], caplog: pytest.LogCaptureFixture, ): # Test that errors in retention or purge do not log sensitive values sentinel_pii = "SECRET_SENTINEL_PII_DATA_12345" class FailingSessionFactory: def __call__(self): raise RuntimeError(f"Database crash with payload context: {sentinel_pii}") caplog.set_level(logging.ERROR) with pytest.raises(RuntimeError): await execute_retention_cleanup( cast(async_sessionmaker[AsyncSession], FailingSessionFactory()), ) for record in caplog.records: assert sentinel_pii not in record.message @pytest.mark.asyncio async def test_retention_redacts_ai_metadata_detail_free_text( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=95) run_fail_id = str(uuid.uuid4()) run_success_id = str(uuid.uuid4()) async with db_session_factory() as session: session.add_all( [ ScoringRun( id=run_fail_id, tenant_id="tenant_ai_meta", subject_ref="lead_fail", scoring_config_version="1.0.0", idempotency_key="fail-key", payload_hash="hash-fail", status="review_required", snapshot_data=_sample_snapshot_with_pii(), rule_score=60, ai_score=None, final_score=60, effective_score=None, degraded=True, error_code="aihub_timeout", ai_metadata={ "error_code": "aihub_timeout", "detail": "Timeout with AIHub prompt containing customer private data", "fallback_policy": "rules_only_review", }, created_at=old_time, updated_at=old_time, ), ScoringRun( id=run_success_id, tenant_id="tenant_ai_meta", subject_ref="lead_succ", scoring_config_version="1.0.0", idempotency_key="succ-key", payload_hash="hash-succ", status="completed", snapshot_data=_sample_snapshot_with_pii(), rule_score=80, ai_score=90, final_score=85, effective_score=85, degraded=False, ai_metadata={ "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, }, created_at=old_time, updated_at=old_time, ), ] ) await session.commit() result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) assert result.runs_redacted == 2 async with db_session_factory() as session: r_fail = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_fail_id)) ).scalar_one() r_succ = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_success_id)) ).scalar_one() # Failed run: detail in ai_metadata must be redacted to "redacted" assert r_fail.ai_metadata is not None assert r_fail.ai_metadata["detail"] == "redacted" assert r_fail.ai_metadata["error_code"] == "aihub_timeout" assert r_fail.ai_metadata["fallback_policy"] == "rules_only_review" # Success run: structural non-free-text metadata preserved assert r_succ.ai_metadata == { "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, } @pytest.mark.asyncio async def test_retention_respects_batch_size_processing( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=100) run_ids = [str(uuid.uuid4()) for _ in range(5)] async with db_session_factory() as session: for idx, rid in enumerate(run_ids): session.add( ScoringRun( id=rid, tenant_id="tenant_batch", subject_ref=f"lead_batch_{idx}", scoring_config_version="1.0.0", idempotency_key=f"batch-key-{idx}", payload_hash=f"hash-{idx}", status="completed", snapshot_data=_sample_snapshot_with_pii(), created_at=old_time, updated_at=old_time, ) ) await session.commit() # Call 1 with batch_size=2: exactly 2 items redacted res1 = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, batch_size=2, ) assert res1.runs_redacted == 2 # Call 2 with batch_size=2: next 2 items redacted res2 = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, batch_size=2, ) assert res2.runs_redacted == 2 # Call 3 with batch_size=2: last 1 item redacted res3 = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, batch_size=2, ) assert res3.runs_redacted == 1 # Call 4: 0 items remaining res4 = await execute_retention_cleanup( db_session_factory, clock=lambda: fixed_now, batch_size=2, ) assert res4.runs_redacted == 0 async with db_session_factory() as session: runs = ( ( await session.execute( select(ScoringRun).where(ScoringRun.tenant_id == "tenant_batch") ) ) .scalars() .all() ) assert len(runs) == 5 for r in runs: assert r.snapshot_data == {} assert r.payload_hash == "redacted" assert r.redacted_at is not None @pytest.mark.asyncio async def test_tenant_offboarding_purges_orphan_outbox_events( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) target_tenant = "tenant_orphan_test" other_tenant = "tenant_other_preserve" orphan_target_event_id = str(uuid.uuid4()) orphan_other_event_id = str(uuid.uuid4()) async with db_session_factory() as session: # Orphan outbox event for target tenant (run_id does not exist in scoring_runs) session.add( OutboxEvent( id=orphan_target_event_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id=str(uuid.uuid4()), payload={"tenant_id": target_tenant, "subject": "lead-orphan"}, occurred_at=fixed_now, delivery_status="pending", ) ) # Orphan outbox event for other tenant (must be preserved) session.add( OutboxEvent( id=orphan_other_event_id, event_type="lead_scoring.run.terminal", event_version="1.0.0", run_id=str(uuid.uuid4()), payload={"tenant_id": other_tenant, "subject": "lead-preserve"}, occurred_at=fixed_now, delivery_status="pending", ) ) await session.commit() purge_res = await purge_tenant_data( db_session_factory, tenant_id=target_tenant, clock=lambda: fixed_now, ) assert purge_res.tenant_id == target_tenant assert purge_res.runs_deleted == 0 assert purge_res.outbox_events_deleted == 1 async with db_session_factory() as session: target_ev = ( await session.execute( select(OutboxEvent).where(OutboxEvent.id == orphan_target_event_id) ) ).scalar_one_or_none() other_ev = ( await session.execute( select(OutboxEvent).where(OutboxEvent.id == orphan_other_event_id) ) ).scalar_one_or_none() assert target_ev is None assert other_ev is not None assert other_ev.id == orphan_other_event_id @pytest.mark.asyncio async def test_retention_sanitizes_effective_config_and_ai_metadata_sentinels( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=95) run_id = str(uuid.uuid4()) sentinel_config_secret = "SENTINEL_CONFIG_SECRET_KEY_999" sentinel_ai_prompt = "SENTINEL_AI_PROMPT_SECRET_888" async with db_session_factory() as session: session.add( ScoringRun( id=run_id, tenant_id="tenant_sentinel_test", subject_ref="lead_sentinel", scoring_config_version="1.0.0", idempotency_key="sentinel-key", payload_hash="hash-sentinel", status="completed", snapshot_data=_sample_snapshot_with_pii(), effective_config={ "version": "1.0.0", "rule_weight": 0.5, "ai_weight": 0.5, "confidence_threshold": 0.8, "sentinel_custom_property": sentinel_config_secret, }, ai_metadata={ "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, "sentinel_custom_prompt_dump": sentinel_ai_prompt, }, rule_score=80, ai_score=90, final_score=85, effective_score=85, created_at=old_time, updated_at=old_time, ) ) await session.commit() result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) assert result.runs_redacted == 1 async with db_session_factory() as session: run = ( await session.execute(select(ScoringRun).where(ScoringRun.id == run_id)) ).scalar_one() assert run.effective_config is not None assert "sentinel_custom_property" not in run.effective_config assert run.effective_config == { "version": "1.0.0", "rule_weight": 0.5, "ai_weight": 0.5, "confidence_threshold": 0.8, } assert run.ai_metadata is not None assert "sentinel_custom_prompt_dump" not in run.ai_metadata assert run.ai_metadata == { "model": "claude-3-5-sonnet", "effort_profile": "medium", "pass_count": 2, } @pytest.mark.asyncio async def test_retention_redacts_reviewer_identity_in_pending_reviewed_outbox_events( db_session_factory: async_sessionmaker[AsyncSession], ): fixed_now = datetime(2026, 8, 28, 12, 0, 0, tzinfo=UTC) old_time = fixed_now - timedelta(days=95) ev_id = str(uuid.uuid4()) secret_reviewer = "secret_reviewer_identity_12345" async with db_session_factory() as session: session.add( OutboxEvent( id=ev_id, event_type="lead_scoring.run.reviewed", event_version="1.0.0", run_id=str(uuid.uuid4()), payload={ "event_id": ev_id, "event_type": "lead_scoring.run.reviewed", "tenant_id": "tenant_rev_test", "data": { "status": "completed", "decision": "approve", "version": 2, "reviewed_by": secret_reviewer, }, }, occurred_at=old_time, delivery_status="pending", ) ) await session.commit() result = await execute_retention_cleanup(db_session_factory, clock=lambda: fixed_now) assert result.outbox_events_cleaned == 0 # Pending event not deleted async with db_session_factory() as session: ev = ( await session.execute(select(OutboxEvent).where(OutboxEvent.id == ev_id)) ).scalar_one() assert ev.delivery_status == "pending" assert ev.payload["data"]["reviewed_by"] == "redacted" assert secret_reviewer not in str(ev.payload)