import asyncio import hashlib import hmac import json import time from dataclasses import replace from datetime import UTC, datetime, timedelta from uuid import uuid4 import httpx from fastapi.testclient import TestClient from pydantic import SecretStr from sqlalchemy import delete, select from ai_platform.bootstrap.app import create_app from ai_platform.infrastructure.config.settings import AppWebhook 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 def test_terminal_webhook_routes_by_app_and_redelivery_keeps_event( aihub_test_environment, valid_run_payload, wait_for_terminal ): env = aihub_test_environment settings, factory = env["settings"], env["factory"] other_id, other_key = f"webhook-{uuid4().hex}", f"key-{uuid4().hex}" settings.app_webhooks = { env["app_id"]: AppWebhook( url="https://omni-a.example.invalid/notifications", signing_secret=SecretStr("test-secret-app-a-more-than-32-characters"), ), other_id: AppWebhook( url="https://omni-b.example.invalid/notifications", signing_secret=SecretStr("test-secret-app-b-more-than-32-characters"), ), } sent = [] first_a = True def receive(request): nonlocal first_a sent.append(request) if request.url.host == "omni-a.example.invalid" and first_a: first_a = False return httpx.Response(503) return httpx.Response(202) async def setup(): async with factory() as db: db.add(AppCredential(app_id=other_id, verifier=digest(other_key))) await db.commit() await factory.kw["bind"].dispose() asyncio.run(setup()) def wait_sent(count): for _ in range(150): if len(sent) >= count: return time.sleep(0.02) raise AssertionError("webhook not dispatched") try: with TestClient( create_app(settings, webhook_transport=httpx.MockTransport(receive)) ) as client: for app_id, key in ((env["app_id"], env["app_key"]), (other_id, other_key)): headers = { "Authorization": f"Bearer {key}", "Idempotency-Key": valid_run_payload["request_id"], } response = client.post( "/v1/lead-scoring/runs", headers=headers, json=valid_run_payload ) assert response.status_code == 202 detail = wait_for_terminal(client, response.json()["run_id"], headers) assert detail["status"] == "completed" and detail["app_id"] == app_id wait_sent(2) a = next(r for r in sent if r.url.host == "omni-a.example.invalid") event_id = json.loads(a.content)["event_id"] async def redeliver(): async with factory() as db: row = await db.get(ScoringNotification, event_id) assert row is not None and row.attempts == 1 and row.delivered_at is None cases = NotificationUseCases( SqlAlchemyNotificationRepository(db), lambda _: asyncio.sleep(0, result=False), db.commit, db.rollback, lambda: datetime.now(UTC), ) assert await cases.redeliver(event_id) await factory.kw["bind"].dispose() asyncio.run(redeliver()) wait_sent(3) again = sent[-1] assert again.content == a.content assert again.headers["X-Webhook-Timestamp"] != a.headers["X-Webhook-Timestamp"] for request in sent: body = json.loads(request.content) assert set(body) == { "event_id", "app_id", "account_id", "request_id", "run_id", "status", "occurred_at", } target = settings.app_webhooks[body["app_id"]] assert str(request.url) == target.url signature = hmac.new( target.signing_secret.get_secret_value().encode(), request.headers["X-Webhook-Timestamp"].encode() + b"." + request.content, hashlib.sha256, ).hexdigest() assert request.headers["X-Webhook-Signature"] == f"sha256={signature}" assert ( body["account_id"] == "3" and body["request_id"] == valid_run_payload["request_id"] ) detail = client.get( f"/v1/lead-scoring/runs/{body['run_id']}", headers={ "Authorization": f"Bearer {env['app_key'] if body['app_id'] == env['app_id'] else other_key}" }, ).json() assert all( detail[name] == body[name] for name in ("app_id", "account_id", "request_id", "run_id", "status") ) assert ( detail["snapshot"]["lead_profile"]["ems_lead_id"] == valid_run_payload["subject_ref"] ) async def verify(): async with factory() as db: rows = ( await db.scalars( select(ScoringRun).where(ScoringRun.app_id.in_([env["app_id"], other_id])) ) ).all() assert len(rows) == 2 row = await db.get(ScoringNotification, event_id) assert row is not None and row.delivered_at is not None and row.attempts == 1 await factory.kw["bind"].dispose() asyncio.run(verify()) finally: async def cleanup(): async with factory() as db: await db.execute(delete(ScoringRun).where(ScoringRun.app_id == other_id)) await db.execute(delete(AppCredential).where(AppCredential.app_id == other_id)) await db.commit() await factory.kw["bind"].dispose() asyncio.run(cleanup()) async def test_retry_bytes_stay_identical_and_redirect_is_not_followed(): sent = [] now = datetime(2026, 9, 30, tzinfo=UTC) event = Notification( str(uuid4()), str(uuid4()), "omni-local", "3", str(uuid4()), "failed", now, 0 ) def redirect(request): sent.append(request) return httpx.Response( 307, headers={"Location": "https://unexpected.example.invalid/events"} ) async with httpx.AsyncClient( transport=httpx.MockTransport(redirect), follow_redirects=False ) as client: sender = OmniWebhook( client, "https://omni.example.invalid/events", "test-signing-secret-more-than-32-characters", lambda: now, ) assert not await sender.send(event) now += timedelta(seconds=60) assert not await sender.send(replace(event, attempts=1)) assert len(sent) == 2 and sent[0].content == sent[1].content assert sent[0].headers["X-Webhook-Timestamp"] != sent[1].headers["X-Webhook-Timestamp"]