Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851a7b17d2 |
@@ -1,5 +1,29 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.80.0 (2026-10-22) — Émission d'événements vers le journal du service d'agents (Lot 2)
|
||||
|
||||
- **Nouveau module `app/services/agent_evenements.py`** : Flowdeck
|
||||
émet ses mutations significatives vers le journal d'activité du
|
||||
service d'agents (`POST /api/v1/evenements`, contrat ACC-0.2) :
|
||||
enveloppe {id, type, occurred_at, actor, domain, source_app,
|
||||
object{ref, label, url}} — une référence et un libellé, jamais le
|
||||
contenu — signée **HMAC-SHA256** (en-tête `X-Draco-Signature`)
|
||||
avec la clé propre à Flowdeck. Envoi en fil d'arrière-plan, délai
|
||||
maximal 2 s, erreurs avalées : **inactif tant que
|
||||
`AGENT_EVENEMENTS_CLE` n'est pas posée** dans l'environnement, et
|
||||
une panne du service ne casse ni ne ralentit jamais le métier.
|
||||
- **Événements émis** : `flowdeck.page.created` /
|
||||
`flowdeck.page.updated` / `flowdeck.page.deleted` et
|
||||
`flowdeck.collection.created`, branchés sur les surfaces réelles
|
||||
de mutation : API de session (pages du board, pages et
|
||||
collections des bases), API publique v2 (acteur « user ») et
|
||||
outils MCP `create_page` / `update_page` / `create_collection`
|
||||
(acteur « agent:mcp », dans le périmètre du jeton). Références
|
||||
`page:<id>` / `collection:<id>`, liens profonds vers la page
|
||||
(`/board/pages/<id>`) ou la vue de sa collection.
|
||||
- **Aucun événement meeting** : la décision A/B sur le bloc
|
||||
meeting reste ouverte, le sujet est explicitement exclu.
|
||||
|
||||
## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1)
|
||||
|
||||
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
|
||||
|
||||
@@ -102,6 +102,9 @@ def create_collection_v2(request: Request, authorization: str | None = Header(de
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
audit_log(user, "collection.create", "collection", cid, name, request)
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.collection.created", f"collection:{cid}", name,
|
||||
url=url_collection(cid), actor="user")
|
||||
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
@@ -349,6 +352,9 @@ def create_collection_page_v2(collection_id: int, request: Request, authorizatio
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
audit_log(user, "page.create", "collection_page", pid, title, request)
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.created", f"page:{pid}", title,
|
||||
url=url_collection(collection_id), actor="user")
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
|
||||
except Exception:
|
||||
@@ -426,6 +432,9 @@ def patch_page_v2(page_id: int, request: Request, authorization: str | None = He
|
||||
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.updated", f"page:{page_id}", title,
|
||||
url=url_collection(row["collection_id"]), actor="user")
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
|
||||
except Exception:
|
||||
@@ -444,6 +453,9 @@ def delete_page_v2(page_id: int, request: Request, authorization: str | None = H
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.delete", "collection_page", page_id, "", request)
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"],
|
||||
url=url_collection(row["collection_id"]), actor="user")
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
|
||||
except Exception:
|
||||
|
||||
@@ -86,6 +86,9 @@ def delete_page(request: Request, page_id: int):
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
from app.services.agent_evenements import emettre, url_page
|
||||
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "",
|
||||
url=url_page(page_id), actor="user")
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ def create_page(request: Request, title: str = Query(default=""),
|
||||
page_id = cur.lastrowid
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
from app.services.agent_evenements import emettre, url_page
|
||||
emettre("flowdeck.page.created", f"page:{page_id}", page_title,
|
||||
url=url_page(page_id), actor="user")
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -109,9 +112,14 @@ def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
trow = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
page_label = (trow["title"] if trow else title) or ""
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")}))
|
||||
from app.services.agent_evenements import emettre, url_page
|
||||
emettre("flowdeck.page.updated", f"page:{page_id}", page_label,
|
||||
url=url_page(page_id), actor="user")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
conn.commit()
|
||||
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.collection.created", f"collection:{collection_id}", name,
|
||||
url=url_collection(collection_id), actor="user")
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
|
||||
@@ -119,4 +119,7 @@ def create_page_api(request: Request, collection_id: int, body: dict = Body(defa
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
}))
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.created", f"page:{page_id}", title,
|
||||
url=url_collection(collection_id), actor="user")
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
@@ -149,6 +149,9 @@ def update_page_api(request: Request, page_id: int, body: dict = Body(default={}
|
||||
user = _current_user(request)
|
||||
notify_assignment(existing["collection_id"], page_id, title,
|
||||
stored, props, user.get("id"))
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.updated", f"page:{page_id}", title,
|
||||
url=url_collection(existing["collection_id"]), actor="user")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@@ -178,6 +181,9 @@ def delete_page_api(request: Request, page_id: int):
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
}))
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.deleted", f"page:{page_id}", existing["title"],
|
||||
url=url_collection(existing["collection_id"]), actor="user")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
|
||||
@@ -237,6 +237,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
||||
conn.commit()
|
||||
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||
(cur.lastrowid,)).fetchone()
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.created", f"page:{page['id']}", title,
|
||||
url=url_collection(collection_id), actor="agent:mcp")
|
||||
return _page_resume(page)
|
||||
if nom == "update_page":
|
||||
try:
|
||||
@@ -265,6 +268,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
||||
conn.commit()
|
||||
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||
(page_id,)).fetchone()
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.page.updated", f"page:{page_id}", title,
|
||||
url=url_collection(page["collection_id"]), actor="agent:mcp")
|
||||
return _page_resume(updated)
|
||||
if nom == "create_collection":
|
||||
name = str(args.get("name") or "").strip()
|
||||
@@ -287,6 +293,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
|
||||
conn.commit()
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||
(new_id,)).fetchone()
|
||||
from app.services.agent_evenements import emettre, url_collection
|
||||
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
|
||||
url=url_collection(new_id), actor="agent:mcp")
|
||||
return {"id": coll["id"], "name": coll["name"],
|
||||
"description": coll["description"], "icon": coll["icon"]}
|
||||
return {"erreur": f"outil inconnu : {nom}"}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Émission d'événements vers le journal du service d'agents
|
||||
(Fusion, Lot 2 — contrat ACC-0.2).
|
||||
|
||||
Flowdeck émet ses mutations significatives (pages et collections)
|
||||
vers `POST /api/v1/evenements` du service d'agents, qui les expose
|
||||
dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne
|
||||
contient qu'une référence, un libellé et un lien — jamais le
|
||||
contenu de l'objet : {id, type, occurred_at, actor, domain,
|
||||
source_app, object{ref, label, url}}.
|
||||
|
||||
Authentification : signature HMAC-SHA256 du corps brut, en-tête
|
||||
`X-Draco-Signature`, avec la clé propre à Flowdeck
|
||||
(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est
|
||||
INACTIF : aucun appel, aucun bruit.
|
||||
|
||||
Discipline « au mieux » (même esprit que le journal du service) :
|
||||
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
|
||||
et toute erreur est avalée avec une ligne de journal — une panne
|
||||
du récepteur ne doit jamais casser ni ralentir le métier.
|
||||
|
||||
Aucun événement « meeting » n'est émis ici : la décision A/B de
|
||||
Bruno sur le bloc meeting de Flowdeck est toujours ouverte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger("flowdeck.evenements")
|
||||
|
||||
SOURCE_APP = "flowdeck"
|
||||
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||
DELAI_S = 2.0
|
||||
|
||||
|
||||
def _cle() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
"""L'émission n'existe que si la clé de Flowdeck est configurée."""
|
||||
return bool(_cle())
|
||||
|
||||
|
||||
def url_page(page_id: int) -> str:
|
||||
"""Lien profond d'une page d'espace de travail (vue HTML du board)."""
|
||||
return f"/board/pages/{page_id}"
|
||||
|
||||
|
||||
def url_collection(collection_id: int) -> str:
|
||||
"""Lien profond d'une collection (sa vue tableau). Une page de
|
||||
collection n'a pas d'URL autonome : elle s'ouvre dans cette vue."""
|
||||
return f"/db/{collection_id}/view/table"
|
||||
|
||||
|
||||
def construire_enveloppe(type_: str, objet_ref: str, label: str,
|
||||
url: str = "/", domain: str = "pro",
|
||||
actor: str | None = None) -> tuple[dict, bytes]:
|
||||
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
|
||||
signé puis envoyé — la signature porte sur ces octets-là)."""
|
||||
env = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": type_,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"actor": actor or "user",
|
||||
"domain": domain or "pro",
|
||||
"source_app": SOURCE_APP,
|
||||
"object": {"ref": objet_ref, "label": label or "", "url": url or "/"},
|
||||
}
|
||||
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
||||
return env, corps
|
||||
|
||||
|
||||
def _signer(corps: bytes) -> str:
|
||||
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _envoyer(corps: bytes) -> None:
|
||||
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
|
||||
journal distant est un confort, jamais une dépendance."""
|
||||
try:
|
||||
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
|
||||
client.post(
|
||||
_url(), content=corps,
|
||||
headers={"Content-Type": "application/json",
|
||||
"X-Draco-Signature": _signer(corps)},
|
||||
)
|
||||
except Exception as exc: # panne, délai, DNS… : on log et on passe
|
||||
log.warning("événement non livré au journal du service (%s)", exc)
|
||||
|
||||
|
||||
def emettre(type_: str, objet_ref: str, label: str, url: str = "/",
|
||||
domain: str = "pro", actor: str | None = None) -> bool:
|
||||
"""Émet un événement en tâche de fond. Retourne True si l'envoi
|
||||
a été déclenché, False si le module est inactif (pas de clé) ou
|
||||
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
|
||||
if not actif():
|
||||
return False
|
||||
try:
|
||||
_env, corps = construire_enveloppe(type_, objet_ref, label, url,
|
||||
domain, actor)
|
||||
except Exception:
|
||||
log.warning("événement %s non construit", type_, exc_info=True)
|
||||
return False
|
||||
threading.Thread(target=_envoyer, args=(corps,), daemon=True,
|
||||
name="flowdeck-evenements").start()
|
||||
return True
|
||||
@@ -0,0 +1,282 @@
|
||||
"""É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
|
||||
Reference in New Issue
Block a user