dotb_ai-platform/tests/test_app_admin_api.py

113 lines
5.1 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()}"
def auth(key: str) -> dict[str, str]:
return {"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())