- 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
447 lines
19 KiB
Python
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"}
|