1120 lines
40 KiB
Python
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)
|