import asyncio import hashlib import hmac import json from datetime import UTC, datetime, timedelta from pathlib import Path from uuid import uuid4 import httpx import pytest from pydantic import SecretStr, ValidationError from sqlalchemy import delete from ai_platform.infrastructure.config.settings import Settings from ai_platform.infrastructure.persistence.database import make_session_factory from ai_platform.infrastructure.persistence.models import ScoringNotification, ScoringRun, TenantKey from ai_platform.infrastructure.persistence.notifications import SqlAlchemyNotificationRepository from ai_platform.infrastructure.webhook import OmniWebhook from ai_platform.lead_scoring.application.notifications import Notification, NotificationUseCases from ai_platform.platform.security import digest NOW = datetime(2026, 9, 28, tzinfo=UTC) SECRET = "test-only-webhook-secret-32-characters-long" def test_webhook_configuration_requires_pair_and_safe_url(): with pytest.raises(ValidationError, match="configured together"): Settings(webhook_url="https://omni.example.invalid/events") with pytest.raises(ValidationError, match="HTTPS outside local development"): Settings( webhook_url="http://omni.example.invalid/events", webhook_signing_secret=SecretStr(SECRET), ) with pytest.raises(ValidationError, match="HTTPS outside local development"): Settings( webhook_url="https://omni.example.invalid/events?token=secret", webhook_signing_secret=SecretStr(SECRET), ) assert Settings( webhook_url="https://omni.example.invalid/events", webhook_signing_secret=SecretStr(SECRET) ).webhook_url async def test_signature_contains_only_notification_identifiers_and_status(): fixture = json.loads( ( Path(__file__).resolve().parents[1] / "contracts/lead-scoring/v2/fixtures/webhook-delivery.json" ).read_text() ) body = json.loads(fixture["body"]) sent_at = datetime.fromisoformat(fixture["timestamp"]) requests: list[httpx.Request] = [] def receiver(request: httpx.Request) -> httpx.Response: requests.append(request) return httpx.Response(204) async with httpx.AsyncClient(transport=httpx.MockTransport(receiver)) as client: webhook = OmniWebhook( client, "https://omni.example.invalid/events", SECRET, lambda: sent_at ) event = Notification( body["event_id"], body["run_id"], body["tenant_id"], body["status"], datetime.fromisoformat(body["occurred_at"]), 0, ) assert await webhook.send(event) request = requests[0] assert request.method == fixture["method"] assert request.headers["Content-Type"] == fixture["content_type"] assert request.headers["X-Webhook-Timestamp"] == fixture["timestamp"] assert request.content.decode() == fixture["body"] assert request.headers["X-Webhook-Signature"] == fixture["signature"] assert hmac.new( SECRET.encode(), fixture["timestamp"].encode() + b"." + request.content, hashlib.sha256 ).hexdigest() == fixture["signature"].removeprefix("sha256=") assert "Authorization" not in request.headers async def test_retry_is_bounded_and_preserves_dead_letter(): class Repository: def __init__(self): self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, 0) self.dead = False self.next_at = NOW async def next_due(self, now): return self.event if not self.dead and self.next_at <= now else None async def delivered(self, event_id, now): pytest.fail("unexpected success") async def retry(self, event_id, attempts, next_at, dead): self.event = Notification("event-1", "run-1", "tenant-1", "completed", NOW, attempts) self.next_at = next_at self.dead = dead async def delete_expired_runs(self, cutoff): pass repo = Repository() async def failed(_event): return False async def noop(): pass current = NOW cases = NotificationUseCases(repo, failed, noop, noop, lambda: current) for attempt in range(1, 6): assert await cases.dispatch_one() assert repo.event.attempts == attempt current = repo.next_at assert repo.dead assert not await cases.dispatch_one() async def test_retention_deletes_old_run_and_its_outbox_but_keeps_recent_data(): settings = Settings.load() if not settings.database_url.rstrip("/").endswith("/ai_platform_test"): pytest.fail("Retention test requires isolated ai_platform_test database") factory = make_session_factory(settings.database_url) suffix = uuid4().hex tenant_id = f"retention-{suffix}" old_id, new_id = str(uuid4()), str(uuid4()) old_event_id, new_event_id = str(uuid4()), str(uuid4()) async def run(): try: async with factory() as db: db.add(TenantKey(tenant_id=tenant_id, verifier=digest("test-key"))) await db.flush() for run_id, event_id, created_at in ( (old_id, old_event_id, NOW - timedelta(days=91)), (new_id, new_event_id, NOW - timedelta(days=89)), ): db.add( ScoringRun( id=run_id, tenant_id=tenant_id, request_id=run_id, fingerprint="0" * 64, request={"private": "snapshot"}, profile_version="test", status="failed", created_at=created_at, completed_at=created_at, ) ) await db.flush() db.add( ScoringNotification( id=event_id, run_id=run_id, tenant_id=tenant_id, status="failed", attempts=5 if run_id == old_id else 0, next_attempt_at=created_at, created_at=created_at, dead=run_id == old_id, ) ) await db.commit() async with factory() as db: cases = NotificationUseCases( SqlAlchemyNotificationRepository(db), lambda _event: asyncio.sleep(0, result=True), db.commit, db.rollback, lambda: NOW, ) await cases.delete_expired() async with factory() as db: assert await db.get(ScoringRun, old_id) is None assert await db.get(ScoringNotification, old_event_id) is None assert await db.get(ScoringRun, new_id) is not None assert await db.get(ScoringNotification, new_event_id) is not None sent: list[Notification] = [] async def send(event: Notification) -> bool: sent.append(event) return True async with factory() as db: cases = NotificationUseCases( SqlAlchemyNotificationRepository(db), send, db.commit, db.rollback, lambda: NOW ) assert await cases.dispatch_one() assert [event.id for event in sent] == [new_event_id] async with factory() as db: delivered = await db.get(ScoringNotification, new_event_id) assert delivered is not None and delivered.delivered_at == NOW assert delivered.attempts == 1 finally: async with factory() as db: await db.execute(delete(ScoringRun).where(ScoringRun.tenant_id == tenant_id)) await db.execute(delete(TenantKey).where(TenantKey.tenant_id == tenant_id)) await db.commit() await factory.kw["bind"].dispose() await run()