Files
flowdeck/tests/test_v66_agent_api.py
bruno d125eb399e
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s
fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
2026-09-30 22:04:13 -04:00

447 lines
19 KiB
Python

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