Files
flowdeck/tests/test_evenements_journal.py
T
Spark 851a7b17d2
FlowDeck CI / lint (push) Failing after 1m29s
FlowDeck CI / test (push) Failing after 29m9s
FlowDeck CI / docker (push) Skipped
7.80.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
2026-10-22 20:25:36 +00:00

283 lines
11 KiB
Python

"""É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": "[email protected]", "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