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.
198 lines
7.6 KiB
Python
198 lines
7.6 KiB
Python
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"]
|