dotb_ai-platform/tests/test_retention.py
2026-09-14 09:57:12 +07:00

1120 lines
40 KiB
Python

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)