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.
110 lines
5.0 KiB
Python
110 lines
5.0 KiB
Python
import asyncio
|
|
from urllib.parse import quote, urlsplit
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
from pwdlib import PasswordHash
|
|
from sqlalchemy import delete, select
|
|
|
|
from ai_platform.bootstrap.app import create_app
|
|
from ai_platform.bootstrap.wiring import build_app_credential_use_cases
|
|
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 (
|
|
AdminSession,
|
|
AdminUser,
|
|
AppCredential,
|
|
AppCredentialAuditEvent,
|
|
)
|
|
from ai_platform.platform.security import digest
|
|
|
|
|
|
def test_operator_lifecycle_and_admin_session_revoke():
|
|
settings = Settings.load()
|
|
if urlsplit(settings.database_url).path.rstrip("/").rsplit("/", 1)[-1] != "ai_platform_test":
|
|
raise RuntimeError("App API tests require /ai_platform_test")
|
|
sessions = make_session_factory(settings.database_url)
|
|
app_id = f"omni/{uuid4().hex}"
|
|
email, password = f"app-admin-{uuid4().hex}@example.invalid", "test-only-password"
|
|
path = f"/v1/admin/apps/{quote(app_id, safe='')}"
|
|
|
|
async def setup():
|
|
async with sessions() as db:
|
|
db.add(AdminUser(email=email, password_hash=PasswordHash.recommended().hash(password)))
|
|
await db.commit()
|
|
key = await build_app_credential_use_cases(db).create(app_id, "cli")
|
|
await sessions.kw["bind"].dispose()
|
|
return key
|
|
|
|
async def rotate():
|
|
async with sessions() as db:
|
|
key = await build_app_credential_use_cases(db).rotate(app_id, "cli")
|
|
await sessions.kw["bind"].dispose()
|
|
return key
|
|
|
|
async def cleanup():
|
|
async with sessions() as db:
|
|
await db.execute(
|
|
delete(AppCredentialAuditEvent).where(AppCredentialAuditEvent.app_id == app_id)
|
|
)
|
|
await db.execute(delete(AppCredential).where(AppCredential.app_id == app_id))
|
|
await db.execute(delete(AdminSession).where(AdminSession.admin_email == email))
|
|
await db.execute(delete(AdminUser).where(AdminUser.email == email))
|
|
await db.commit()
|
|
await sessions.kw["bind"].dispose()
|
|
|
|
initial_key = asyncio.run(setup())
|
|
try:
|
|
with TestClient(create_app(settings)) as client:
|
|
assert client.get("/v1/admin/apps").status_code == 401
|
|
assert client.delete(path).status_code == 401
|
|
assert client.post("/v1/platform/tenant-keys", json={}).status_code == 404
|
|
assert client.post("/v1/admin/apps", json={"app_id": "new"}).status_code == 405
|
|
login = client.post("/v1/admin/login", json={"email": email, "password": password})
|
|
assert login.status_code == 200
|
|
csrf = {"x-csrf-token": login.json()["csrf_token"]}
|
|
listed = client.get("/v1/admin/apps")
|
|
assert listed.status_code == 200
|
|
item = next(x for x in listed.json()["apps"] if x["app_id"] == app_id)
|
|
assert set(item) == {"app_id", "active", "lead_scoring", "created_at", "updated_at"}
|
|
assert item["active"] and item["lead_scoring"]
|
|
assert initial_key not in listed.text and "verifier" not in listed.text
|
|
assert client.delete(path).status_code == 403
|
|
run_path = f"/v1/lead-scoring/runs/{uuid4()}"
|
|
auth = lambda key: {"Authorization": f"Bearer {key}"}
|
|
assert client.get(run_path, headers=auth(initial_key)).status_code == 404
|
|
rotated_key = asyncio.run(rotate())
|
|
assert rotated_key != initial_key
|
|
assert client.get(run_path, headers=auth(initial_key)).status_code == 401
|
|
assert client.get(run_path, headers=auth(rotated_key)).status_code == 404
|
|
revoked = client.delete(path, headers=csrf)
|
|
assert revoked.status_code == 200 and revoked.json()["active"] is False
|
|
assert client.get(run_path, headers=auth(rotated_key)).status_code == 401
|
|
active_key = asyncio.run(rotate())
|
|
assert client.get(run_path, headers=auth(active_key)).status_code == 404
|
|
assert active_key not in client.get("/v1/admin/apps").text
|
|
|
|
async def verify():
|
|
async with sessions() as db:
|
|
row = await db.get(AppCredential, app_id)
|
|
assert row is not None and row.active and row.verifier == digest(active_key)
|
|
events = (
|
|
await db.scalars(
|
|
select(AppCredentialAuditEvent)
|
|
.where(AppCredentialAuditEvent.app_id == app_id)
|
|
.order_by(AppCredentialAuditEvent.id)
|
|
)
|
|
).all()
|
|
assert [(e.actor, e.action) for e in events] == [
|
|
("cli", "create"),
|
|
("cli", "rotate"),
|
|
(email, "revoke"),
|
|
("cli", "rotate"),
|
|
]
|
|
assert all(e.occurred_at for e in events)
|
|
await sessions.kw["bind"].dispose()
|
|
|
|
asyncio.run(verify())
|
|
finally:
|
|
asyncio.run(cleanup())
|