207 lines
8.1 KiB
Python
207 lines
8.1 KiB
Python
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()
|