dotb_ai-platform/tests/test_app_webhook_delivery.py
hungtran24 2268b6a77a feat: replace tenant auth with app-scoped scoring access
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.
2026-10-02 10:56:58 +07:00

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"]