Add app credentials, account-scoped idempotency and per-app terminal notifications. Consolidate the unreleased migration baseline, preserve rule descriptions as AI context, and regenerate provider contracts with regression coverage.
223 lines
8.6 KiB
Python
223 lines
8.6 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 AppWebhook, Settings
|
|
from ai_platform.infrastructure.persistence.database import make_session_factory
|
|
from ai_platform.infrastructure.persistence.models import (
|
|
AppCredential,
|
|
ScoringNotification,
|
|
ScoringRun,
|
|
)
|
|
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):
|
|
AppWebhook.model_validate({"url": "https://omni.example.invalid/events"})
|
|
for url in (
|
|
"http://omni.example.invalid/events",
|
|
"https://omni.example.invalid/events?token=secret",
|
|
):
|
|
with pytest.raises(ValidationError):
|
|
AppWebhook(url=url, signing_secret=SecretStr(SECRET))
|
|
target = AppWebhook(url="https://omni.example.invalid/events", signing_secret=SecretStr(SECRET))
|
|
assert Settings(app_webhooks={"omni-local": target}).app_webhooks["omni-local"] == target
|
|
local = AppWebhook(url="http://localhost:8080/events", signing_secret=SecretStr(SECRET))
|
|
with pytest.raises(ValidationError, match="HTTPS outside local development"):
|
|
Settings(environment="production", app_webhooks={"omni-local": local})
|
|
|
|
|
|
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["app_id"],
|
|
body["account_id"],
|
|
body["request_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", "app-1", "3", "request-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", "app-1", "3", "request-1", "completed", NOW, attempts
|
|
)
|
|
self.next_at = next_at
|
|
self.dead = dead
|
|
|
|
async def redeliver(self, event_id, now):
|
|
self.dead = False
|
|
self.next_at = now
|
|
return True
|
|
|
|
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
|
|
app_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(AppCredential(app_id=app_id, verifier=digest(f"test-key-{suffix}")))
|
|
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,
|
|
app_id=app_id,
|
|
account_id="3",
|
|
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,
|
|
app_id=app_id,
|
|
account_id="3",
|
|
request_id=run_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.app_id == app_id))
|
|
await db.execute(delete(AppCredential).where(AppCredential.app_id == app_id))
|
|
await db.commit()
|
|
await factory.kw["bind"].dispose()
|
|
|
|
await run()
|