"""Émission d'événements vers le journal du service d'agents (Fusion, Lot 2 — contrat ACC-0.2). Un faux récepteur local capture les POST signés HMAC et les vérifie comme le fait le service (journal.verifier_signature) ; on prouve aussi l'inactivité sans clé et la non-régression métier quand le récepteur est en panne. Surfaces couvertes : API de session (board et collections), API publique v2 et MCP (acteur « agent:mcp »). """ import hashlib import hmac import json import threading import time from http.server import BaseHTTPRequestHandler, HTTPServer import pytest from app.db import get_conn CLE = "cle-flowdeck-de-test" class _Recepteur(BaseHTTPRequestHandler): recus = [] def do_POST(self): n = int(self.headers.get("Content-Length") or 0) corps = self.rfile.read(n) type(self).recus.append( {"headers": {k.lower(): v for k, v in self.headers.items()}, "corps": corps}) self.send_response(201) self.end_headers() def log_message(self, *a): pass def _demarrer(monkeypatch): _Recepteur.recus = [] serveur = HTTPServer(("127.0.0.1", 0), _Recepteur) port = serveur.server_address[1] threading.Thread(target=serveur.serve_forever, daemon=True).start() monkeypatch.setenv("AGENT_EVENEMENTS_URL", f"http://127.0.0.1:{port}/api/v1/evenements") monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) return serveur def _attendre(nombre, delai=6.0): """Attend `nombre` événements ; vérifie la signature HMAC de chacun (comme le service) et retourne la liste des enveloppes.""" fin = time.time() + delai while time.time() < fin and len(_Recepteur.recus) < nombre: time.sleep(0.05) assert len(_Recepteur.recus) >= nombre, \ f"{len(_Recepteur.recus)} événement(s) reçu(s), {nombre} attendus" envs = [] for recu in _Recepteur.recus: sig = recu["headers"].get("x-draco-signature", "") attendu = hmac.new(CLE.encode(), recu["corps"], hashlib.sha256).hexdigest() assert hmac.compare_digest(sig.lower(), attendu), \ "signature HMAC invalide" env = json.loads(recu["corps"].decode("utf-8")) assert env["source_app"] == "flowdeck" assert env["id"] and env["occurred_at"] envs.append(env) return envs def _par_type(envs, type_): return [e for e in envs if e["type"] == type_] def test_enveloppe_et_signature(): from app.services import agent_evenements env, corps = agent_evenements.construire_enveloppe( "flowdeck.page.created", "page:42", "Page de test", url="/board/pages/42", actor="user") assert env["source_app"] == "flowdeck" assert env["domain"] == "pro" assert env["actor"] == "user" assert env["object"] == {"ref": "page:42", "label": "Page de test", "url": "/board/pages/42"} assert json.loads(corps.decode("utf-8")) == env sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest() assert len(sig) == 64 def test_cycle_de_vie_api_session(client, monkeypatch): serveur = _demarrer(monkeypatch) try: # Page d'espace de travail (board) : création, màj, suppression. r = client.post("/board/api/pages", params={"title": "Page journal"}) assert r.status_code == 200, r.text page_id = r.json()["id"] r = client.put(f"/board/api/pages/{page_id}", params={"title": "Page journal (éditée)"}) assert r.status_code == 200, r.text r = client.delete(f"/board/api/pages/{page_id}") assert r.status_code == 200, r.text # Collection puis sa page : création, màj, suppression. r = client.post("/db/api", json={"name": "Collection journal"}) assert r.status_code == 200, r.text cid = r.json()["id"] r = client.post(f"/db/{cid}/pages/api", json={"title": "Ligne journal"}) assert r.status_code == 200, r.text ligne_id = r.json()["id"] r = client.put(f"/db/pages/{ligne_id}/api", json={"title": "Ligne journal (éditée)"}) assert r.status_code == 200, r.text r = client.delete(f"/db/pages/{ligne_id}/api") assert r.status_code == 200, r.text envs = _attendre(7) crees = _par_type(envs, "flowdeck.page.created") assert len(crees) == 2 urls = {e["object"]["url"] for e in crees} assert f"/board/pages/{page_id}" in urls assert f"/db/{cid}/view/table" in urls board = next(e for e in crees if e["object"]["url"] == f"/board/pages/{page_id}") assert board["object"]["ref"] == f"page:{page_id}" assert board["object"]["label"] == "Page journal" assert board["actor"] == "user" maj = _par_type(envs, "flowdeck.page.updated") assert len(maj) == 2 labels = {e["object"]["label"] for e in maj} assert labels == {"Page journal (éditée)", "Ligne journal (éditée)"} suppr = _par_type(envs, "flowdeck.page.deleted") assert len(suppr) == 2 refs = {e["object"]["ref"] for e in suppr} assert refs == {f"page:{page_id}", f"page:{ligne_id}"} coll = _par_type(envs, "flowdeck.collection.created") assert len(coll) == 1 assert coll[0]["object"] == { "ref": f"collection:{cid}", "label": "Collection journal", "url": f"/db/{cid}/view/table"} assert coll[0]["actor"] == "user" finally: serveur.shutdown() def test_api_publique_v2(client, monkeypatch): serveur = _demarrer(monkeypatch) try: r = client.post("/auth/register", json={ "email": "journal@test.dev", "password": "secret123", "name": "journal"}) assert r.status_code == 200, r.text tok = client.post("/api/v1/token").json()["token"] h1 = {"Authorization": f"Bearer {tok}"} r = client.post("/api/v2/tokens", json={"name": "ci", "scopes": "read,write"}, headers=h1) assert r.status_code == 200, r.text h = {"Authorization": f"Bearer {r.json()['token']}"} r = client.post("/api/v2/collections", json={"name": "Collection v2"}, headers=h) assert r.status_code == 201, r.text cid = r.json()["id"] r = client.post(f"/api/v2/collections/{cid}/pages", json={"title": "Page v2"}, headers=h) assert r.status_code == 201, r.text pid = r.json()["id"] r = client.patch(f"/api/v2/pages/{pid}", json={"title": "Page v2 (éditée)"}, headers=h) assert r.status_code == 200, r.text r = client.delete(f"/api/v2/pages/{pid}", headers=h) assert r.status_code == 200, r.text envs = _attendre(4) types = [e["type"] for e in envs] assert types.count("flowdeck.collection.created") == 1 assert types.count("flowdeck.page.created") == 1 assert types.count("flowdeck.page.updated") == 1 assert types.count("flowdeck.page.deleted") == 1 assert all(e["actor"] == "user" for e in envs) coll = _par_type(envs, "flowdeck.collection.created")[0] assert coll["object"]["ref"] == f"collection:{cid}" page = _par_type(envs, "flowdeck.page.created")[0] assert page["object"]["ref"] == f"page:{pid}" assert page["object"]["url"] == f"/db/{cid}/view/table" finally: serveur.shutdown() @pytest.fixture() def jeton_mcp(client): brut = "fd_test_token_journal" with get_conn() as conn: conn.execute( "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) " "VALUES (1, 'service-agents', ?, ?, 0)", (hashlib.sha256(brut.encode()).hexdigest(), brut[:12])) conn.commit() return brut def _appel_mcp(client, jeton, rid, nom, args=None): r = client.post( "/mcp", json={"jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": nom, "arguments": args or {}}}, headers={"Authorization": f"Bearer {jeton}"}) assert r.status_code == 200, r.text return json.loads(r.json()["result"]["content"][0]["text"]) def test_mcp_acteur_agent(client, monkeypatch, jeton_mcp): serveur = _demarrer(monkeypatch) try: coll = _appel_mcp(client, jeton_mcp, 1, "create_collection", {"name": "Collection MCP"}) cid = coll["id"] page = _appel_mcp(client, jeton_mcp, 2, "create_page", {"collection_id": cid, "title": "Page MCP"}) pid = page["id"] _appel_mcp(client, jeton_mcp, 3, "update_page", {"id": pid, "title": "Page MCP (éditée)"}) envs = _attendre(3) assert all(e["actor"] == "agent:mcp" for e in envs) coll_env = _par_type(envs, "flowdeck.collection.created") assert len(coll_env) == 1 assert coll_env[0]["object"] == { "ref": f"collection:{cid}", "label": "Collection MCP", "url": f"/db/{cid}/view/table"} creee = _par_type(envs, "flowdeck.page.created") assert len(creee) == 1 assert creee[0]["object"]["ref"] == f"page:{pid}" assert creee[0]["object"]["label"] == "Page MCP" maj = _par_type(envs, "flowdeck.page.updated") assert len(maj) == 1 assert maj[0]["object"]["label"] == "Page MCP (éditée)" finally: serveur.shutdown() def test_inactif_sans_cle(client, monkeypatch): serveur = _demarrer(monkeypatch) monkeypatch.delenv("AGENT_EVENEMENTS_CLE") try: from app.services import agent_evenements assert agent_evenements.actif() is False assert agent_evenements.emettre( "flowdeck.page.created", "page:1", "x") is False r = client.post("/board/api/pages", params={"title": "Sans journal"}) assert r.status_code == 200, r.text r = client.post("/db/api", json={"name": "Collection sans journal"}) assert r.status_code == 200, r.text time.sleep(0.6) assert _Recepteur.recus == [] finally: serveur.shutdown() def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch): # Port fermé : connexion refusée dans le fil d'arrière-plan. monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:59998/api/v1/evenements") monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE) r = client.post("/board/api/pages", params={"title": "Malgré la panne"}) assert r.status_code == 200, r.text page_id = r.json()["id"] r = client.get(f"/board/api/pages/{page_id}") assert r.status_code == 200, r.text assert r.json()["title"] == "Malgré la panne" r = client.post("/db/api", json={"name": "Collection malgré la panne"}) assert r.status_code == 200, r.text