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())