"""FlowDeck — v6.6.0 : Agent phase 5 (API publique agent + skill marketplace). Covers the Bearer+scopes wrappers under ``/api/v2/agents`` and ``/api/v2/skills``, the portable skill export/import + gallery install, the internal (session) marketplace routes, ownership checks, and the agent run lifecycle webhooks (``agent.run.started`` / ``finished`` / ``failed``). """ from __future__ import annotations import os import tempfile import pytest from conftest import login_test_client from app.services import skill_gallery from app.services.webhook_outbound import EVENTS # ── Fixtures ──────────────────────────────────────────────────────────────── @pytest.fixture def client(): """Fresh SQLite DB + offline (mock) LLM — no network, no shared state.""" db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) db_path = db_file.name db_file.close() os.environ["DATABASE_URL"] = f"sqlite:///{db_path}" os.environ["APP_SECRET_KEY"] = "test-secret-for-tests" os.environ["RATE_LIMIT_ENABLED"] = "false" os.environ["LLM_PROVIDER"] = "offline" # deterministic, no network from app.config import settings from app.db import get_conn, init_db from app.main import app from app.password_utils import hash_password settings.database_url = f"sqlite:///{db_path}" settings.llm_provider = "offline" settings.agent_max_iterations = 12 settings.agent_max_tokens_budget = 500_000 settings.agent_run_timeout_seconds = 30 init_db() with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) " "VALUES ('admin', 'Admin', '', ?, 1)", (hash_password("test"),), ) conn.commit() from fastapi.testclient import TestClient yield login_test_client(TestClient(app)) try: os.unlink(db_path) except FileNotFoundError: pass def _token(client, login: str, scopes: str = "read,write") -> dict: """Register an account (opens a session) → legacy token → scoped v2 token.""" r = client.post("/auth/register", json={ "email": f"{login}@test.dev", "password": "secret123", "name": login, }) assert r.status_code == 200, r.text legacy = client.post("/api/v1/token").json()["token"] r = client.post("/api/v2/tokens", json={"name": "phase5", "scopes": scopes}, headers={"Authorization": f"Bearer {legacy}"}) assert r.status_code == 200, r.text return {"Authorization": f"Bearer {r.json()['token']}"} # ── Auth & scopes ─────────────────────────────────────────────────────────── def test_v2_agents_requires_bearer(client): r = client.get("/api/v2/agents") assert r.status_code == 401 assert r.headers["content-type"].startswith("application/problem+json") assert r.json()["status"] == 401 def test_v2_skills_rejects_bad_token(client): r = client.get("/api/v2/skills", headers={"Authorization": "Bearer nope"}) assert r.status_code == 401 def test_v2_agent_write_requires_write_scope(client): headers = _token(client, "readonly", scopes="read") assert client.get("/api/v2/agents", headers=headers).status_code == 200 r = client.post("/api/v2/agents", json={"name": "Nope"}, headers=headers) assert r.status_code == 403 assert "write" in r.json()["detail"] # ── Agents CRUD ───────────────────────────────────────────────────────────── def test_v2_agents_crud(client): headers = _token(client, "agentcrud") r = client.post("/api/v2/agents", json={ "name": "Analyste", "description": "Synthèses", "model": "gpt-4o", "system_instructions": "Sois concis.", "scope": {"tools": ["search_workspace"]}, }, headers=headers) assert r.status_code == 201, r.text agent_id = r.json()["id"] scope = r.json()["agent"]["scope_json"] assert scope == {"tools": ["search_workspace"]} or scope == '{"tools": ["search_workspace"]}' listed = client.get("/api/v2/agents", headers=headers).json() assert any(a["id"] == agent_id for a in listed["agents"]) assert listed["total"] >= 1 got = client.get(f"/api/v2/agents/{agent_id}", headers=headers) assert got.status_code == 200 assert got.json()["name"] == "Analyste" assert "password_hash" not in got.text upd = client.put(f"/api/v2/agents/{agent_id}", json={"description": "V2", "approval_mode": "confirm"}, headers=headers) assert upd.status_code == 200 assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).json()["description"] == "V2" assert client.get("/api/v2/agents/999999", headers=headers).status_code == 404 assert client.delete(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 200 assert client.get(f"/api/v2/agents/{agent_id}", headers=headers).status_code == 404 def test_v2_agent_idempotency(client): headers = _token(client, "agentidem") idem = {"Idempotency-Key": "agent-create-1"} r1 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem}) r2 = client.post("/api/v2/agents", json={"name": "Idem"}, headers={**headers, **idem}) assert r1.status_code == 201 assert r2.status_code == r1.status_code assert r1.json()["id"] == r2.json()["id"] with_id = [a for a in client.get("/api/v2/agents", headers=headers).json()["agents"] if a["name"] == "Idem"] assert len(with_id) == 1, "idempotent replay must not create a second agent" # ── Conversations, run & audit ────────────────────────────────────────────── def test_v2_conversation_and_sync_run(client): headers = _token(client, "runner") r = client.post("/api/v2/agents/conversations", json={"title": "Run test"}, headers=headers) assert r.status_code == 201, r.text conv_id = r.json()["id"] bad = client.post(f"/api/v2/agents/conversations/{conv_id}/run", json={}, headers=headers) assert bad.status_code == 400 r = client.post(f"/api/v2/agents/conversations/{conv_id}/run", json={"message": "Crée une collection CRM"}, headers=headers) assert r.status_code == 200, r.text run = r.json() assert run["conversation_id"] == conv_id assert run["status"] == "completed" assert run["final"], "offline mock must yield a final answer" assert isinstance(run["events"], list) and run["events"] detail = client.get(f"/api/v2/agents/conversations/{conv_id}", headers=headers).json() roles = [m["role"] for m in detail["messages"]] assert "user" in roles and "assistant" in roles # Audit journal + rollback surface exposed to integrations actions = client.get(f"/api/v2/agents/conversations/{conv_id}/actions", headers=headers) assert actions.status_code == 200 assert isinstance(actions.json()["actions"], list) from app.db import get_conn with get_conn() as conn: rows = conn.execute( "SELECT action FROM api_audit_log WHERE resource_id=? ORDER BY id", (str(conv_id),), ).fetchall() assert "agent.run" in [r_[0] for r_ in rows] def test_v2_conversation_ownership(client): alice = _token(client, "alice") bob = _token(client, "bob") conv_id = client.post("/api/v2/agents/conversations", json={"title": "Privé"}, headers=alice).json()["id"] # Bob sees nothing of Alice's conversation (and gets 404, not 403 leakage). assert client.get(f"/api/v2/agents/conversations/{conv_id}", headers=bob).status_code == 404 assert client.post(f"/api/v2/agents/conversations/{conv_id}/run", json={"message": "hack"}, headers=bob).status_code == 404 assert client.get(f"/api/v2/agents/conversations/{conv_id}/actions", headers=bob).status_code == 404 assert client.delete(f"/api/v2/agents/conversations/{conv_id}", headers=bob).status_code == 404 assert client.get(f"/api/v2/agents/conversations/{conv_id}", headers=alice).status_code == 200 def test_v2_trigger_agent(client): headers = _token(client, "trigger") agent_id = client.post("/api/v2/agents", json={ "name": "Déclenché", "system_instructions": "Crée un document de statut.", }, headers=headers).json()["id"] r = client.post(f"/api/v2/agents/{agent_id}/trigger", json={}, headers=headers) assert r.status_code == 200, r.text payload = r.json() assert payload["agent_id"] == agent_id assert payload["conversation_id"] > 0 assert payload["status"] == "completed" assert payload["final"] def test_v2_conversation_delete(client): headers = _token(client, "deleter") conv_id = client.post("/api/v2/agents/conversations", json={"title": "Bye"}, headers=headers).json()["id"] assert client.delete(f"/api/v2/agents/conversations/{conv_id}", headers=headers).status_code == 200 assert client.get(f"/api/v2/agents/conversations/{conv_id}", headers=headers).status_code == 404 # ── Skill marketplace ─────────────────────────────────────────────────────── def test_v2_skills_crud_and_scopes(client): headers = _token(client, "skillcrud") r = client.post("/api/v2/skills", json={ "name": "Veille", "description": "Surveille un sujet", "prompt_template": "Cherche les infos sur le sujet et résume.", "allowed_tools": ["search_workspace"], }, headers=headers) assert r.status_code == 201, r.text skill_id = r.json()["id"] listed = client.get("/api/v2/skills", headers=headers).json() assert any(s["id"] == skill_id for s in listed["skills"]) got = client.get(f"/api/v2/skills/{skill_id}", headers=headers) assert got.status_code == 200 tools = got.json()["allowed_tools_json"] assert tools == ["search_workspace"] or tools == '["search_workspace"]' dup = client.post("/api/v2/skills", json={ "name": "Veille", "prompt_template": "autre", }, headers=headers) assert dup.status_code == 409 assert client.delete(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 200 assert client.get(f"/api/v2/skills/{skill_id}", headers=headers).status_code == 404 def test_v2_skill_export_import_roundtrip(client): headers = _token(client, "porter") created = client.post("/api/v2/skills", json={ "name": "Rapport CRM", "description": "d", "prompt_template": "fais le rapport", "allowed_tools": ["read_document", "create_document"], }, headers=headers).json() export = client.get(f"/api/v2/skills/{created['id']}/export", headers=headers) assert export.status_code == 200 doc = export.json() assert doc["format"] == skill_gallery.EXPORT_FORMAT assert doc["version"] == skill_gallery.EXPORT_VERSION assert doc["skill"]["name"] == "Rapport CRM" assert doc["skill"]["allowed_tools"] == ["read_document", "create_document"] # Portable = no instance internals leak assert "id" not in doc["skill"] and "workspace_id" not in doc["skill"] assert "created_by" not in doc["skill"] # Same instance, different name → import as-is would clash → 409 first clash = client.post("/api/v2/skills/import", json=doc, headers=headers) assert clash.status_code == 409 # Renamed import succeeds, overwrite updates the existing one doc["skill"]["name"] = "Rapport CRM (copie)" imp = client.post("/api/v2/skills/import", json=doc, headers=headers) assert imp.status_code == 201, imp.text assert imp.json()["skill"]["prompt_template"] == "fais le rapport" doc["skill"]["prompt_template"] = "version 2" upd = client.post("/api/v2/skills/import", json={**doc, "overwrite": True}, headers=headers) assert upd.status_code in (200, 201), upd.text reimported = client.get(f"/api/v2/skills/{imp.json()['id']}", headers=headers).json() assert reimported["prompt_template"] == "version 2" def test_v2_skill_import_validation(client): headers = _token(client, "validator") assert client.post("/api/v2/skills/import", json={"nope": True}, headers=headers).status_code == 400 assert client.post("/api/v2/skills/import", json={"format": "not-flowdeck", "skill": {"name": "x", "prompt_template": "y"}}, headers=headers).status_code == 400 assert client.post("/api/v2/skills/import", json={"format": skill_gallery.EXPORT_FORMAT, "version": 99, "skill": {"name": "x", "prompt_template": "y"}}, headers=headers).status_code == 400 assert client.post("/api/v2/skills/import", json={"name": "sans outils", "prompt_template": "", "allowed_tools": "not-a-list"}, headers=headers).status_code == 400 def test_v2_gallery_install(client): headers = _token(client, "galery") gallery = client.get("/api/v2/skills/gallery", headers=headers) assert gallery.status_code == 200 presets = gallery.json()["gallery"] assert len(presets) >= 6 slugs = [p["slug"] for p in presets] assert "rapport-hebdo" in slugs and "base-crm" in slugs for p in presets: assert p["prompt_template"], f"preset {p['slug']} has no prompt" for tool in p["allowed_tools"]: assert tool in _known_tools(), f"preset {p['slug']} uses unknown tool {tool}" r = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={}, headers=headers) assert r.status_code == 201, r.text assert r.json()["status"] == "installed" skill_id = r.json()["id"] # Installed preset shows up in the list, and can be applied names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]] assert "Rapport hebdo" in names applied = client.post(f"/api/v2/skills/{skill_id}/apply", json={}, headers=headers) assert applied.status_code == 201 conv = client.get(f"/api/v2/agents/conversations/{applied.json()['conversation_id']}", headers=headers) assert conv.status_code == 200 # Re-install updates instead of duplicating again = client.post("/api/v2/skills/gallery/rapport-hebdo/install", json={}, headers=headers) assert again.status_code in (200, 201) names = [s["name"] for s in client.get("/api/v2/skills", headers=headers).json()["skills"]] assert names.count("Rapport hebdo") == 1 assert client.post("/api/v2/skills/gallery/does-not-exist/install", json={}, headers=headers).status_code == 404 def _known_tools() -> set[str]: from app.services.tool_registry import ToolRegistry return set(ToolRegistry().tools.keys()) # ── Internal (session) marketplace routes ─────────────────────────────────── def test_internal_gallery_and_skill_lifecycle(client): gallery = client.get("/api/agent/skills/gallery") assert gallery.status_code == 200 assert gallery.json()["total"] >= 6 installed = client.post("/api/agent/skills/gallery/base-crm/install", json={}) assert installed.status_code == 200, installed.text skill_id = installed.json()["id"] export = client.get(f"/api/agent/skills/{skill_id}/export") assert export.status_code == 200 assert export.json()["format"] == skill_gallery.EXPORT_FORMAT doc = export.json() doc["skill"]["name"] = "Base CRM (import)" imp = client.post("/api/agent/skills/import", json=doc) assert imp.status_code == 200, imp.text assert imp.json()["name"] == "Base CRM (import)" assert client.post("/api/agent/skills/gallery/nope/install", json={}).status_code == 404 assert client.delete(f"/api/agent/skills/{skill_id}").status_code == 200 assert client.get(f"/api/agent/skills/{skill_id}/export").status_code == 404 # ── Agent run lifecycle webhooks ──────────────────────────────────────────── def test_agent_run_lifecycle_webhooks(client, monkeypatch): """started → finished on success, started → failed on LLM error.""" from app.db import get_conn from app.services import webhook_outbound fired: list[tuple[str, dict]] = [] async def record(event, payload): fired.append((event, dict(payload))) monkeypatch.setattr(webhook_outbound, "fire_event", record) headers = _token(client, "hooks") conv_id = client.post("/api/v2/agents/conversations", json={"title": "Hooks"}, headers=headers).json()["id"] r = client.post(f"/api/v2/agents/conversations/{conv_id}/run", json={"message": "Crée une page de notes"}, headers=headers) assert r.status_code == 200, r.text events = [e for e, _ in fired] assert "agent.run.started" in events assert "agent.run.finished" in events assert events.index("agent.run.started") < events.index("agent.run.finished") for event in events: assert event in EVENTS, f"{event} must be in the webhook catalogue" # Failure path: the LLM blows up → started + failed, never finished. fired.clear() async def boom(self, *args, **kwargs): raise RuntimeError("llm down") from app.services.llm_client import LLMClient monkeypatch.setattr(LLMClient, "complete", boom) conv2 = client.post("/api/v2/agents/conversations", json={"title": "KO"}, headers=headers).json()["id"] r = client.post(f"/api/v2/agents/conversations/{conv2}/run", json={"message": "ça va planter"}, headers=headers) assert r.status_code == 500 assert r.json()["status"] == "failed" events = [e for e, _ in fired] assert "agent.run.started" in events assert "agent.run.failed" in events assert "agent.run.finished" not in events failed_payload = next(p for e, p in fired if e == "agent.run.failed") assert failed_payload["conversation_id"] == conv2 assert "llm down" in failed_payload["error"] # Conversation status must be released (finally block) for both paths. with get_conn() as conn: rows = conn.execute( "SELECT status FROM agent_conversations WHERE id IN (?, ?)", (conv_id, conv2) ).fetchall() assert {r_["status"] for r_ in rows} == {"idle"}