"""HUB-23 — métadonnées des jetons `kbt_` pour le registre du Hub. Route `GET /api/jetons/metadonnees` : le Hub l'interroge côté serveur en relayant le cookie `draco_session` de l'administrateur connecté (patron /api/search, correspondance HUB-16, aucun JIT). Couvert ici : - sans cookie draco, ou cookie sans correspondance : 401 ; - un membre ne reçoit que ses propres jetons ; - un administrateur Kanbante reçoit tous les jetons (portée « tous »), propriétaires joints ; - la réponse ne contient que des métadonnées : ni valeur de jeton, ni empreinte SHA-256 (`token_hash`), sous aucune clé ; - l'état distingue actif / révoqué ; le préfixe est `kbt_`. La base de test est partagée par toute la session (voir conftest) : les comptes créés ici portent des identifiants uniques « hub23-… », liés par `draco_sub` posé directement, et sont supprimés au teardown de chaque test. """ import base64 import hashlib import hmac import json import time import pytest from fastapi.testclient import TestClient import app.main as main from app import accounts, draco, models CLE_TEST = "hub23-" + "cle-de-test" def _jeton(sub, nom="Alice Exemple", secret=CLE_TEST): def b64(d: bytes) -> str: return base64.urlsafe_b64encode(d).rstrip(b"=").decode() header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) payload = b64(json.dumps({ "sub": sub, "name": nom, "iat": int(time.time()), "exp": int(time.time()) + 3600, "jti": f"jti-{sub}", }).encode()) sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest() return f"{header}.{payload}.{b64(sig)}" def _creer_lie(username: str, sub: str, role: str = "member") -> dict: uid = models.create_user( username, accounts.hash_password("motdepasse-local"), role=role, display_name=username, ) models.update_user(uid, draco_sub=sub) return models.get_user(uid) def _nettoyer(): with models.get_db() as conn: ids = [ r[0] for r in conn.execute( "SELECT id FROM users WHERE username LIKE 'hub23-%'" ).fetchall() ] for uid in ids: conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,)) conn.execute( "DELETE FROM workspace_members WHERE user_id = ?", (uid,) ) conn.execute("DELETE FROM users WHERE id = ?", (uid,)) @pytest.fixture() def client_hub23(monkeypatch): monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") monkeypatch.setenv("DRACO_SESSION_SECRET", CLE_TEST) monkeypatch.setattr(draco, "_refresh_revocations", lambda: None) draco._revoked_cache.clear() _nettoyer() with TestClient(main.app) as client: yield client _nettoyer() draco._revoked_cache.clear() def test_sans_cookie_ou_sans_correspondance_401(client_hub23): # Un compte lié existe d'abord : l'amorçage de l'unique compte # admin (patron /api/search) ne peut pas lier le sub inconnu. _creer_lie("hub23-temoin", "hub23-temoin") assert client_hub23.get("/api/jetons/metadonnees").status_code == 401 client_hub23.cookies.set( "draco_session", _jeton("hub23-inconnu", "Sans Lien") ) r = client_hub23.get("/api/jetons/metadonnees") assert r.status_code == 401 def test_membre_ne_voit_que_ses_jetons(client_hub23): membre = _creer_lie("hub23-membre", "hub23-membre") autre = _creer_lie("hub23-autre", "hub23-autre") row, _clair = models.create_api_token(membre["id"], "service-agents") models.create_api_token(autre["id"], "jeton-d-autrui") models.revoke_api_token(membre["id"], row["id"]) _row2, _clair2 = models.create_api_token(membre["id"], "mcp-local") client_hub23.cookies.set( "draco_session", _jeton("hub23-membre", "Membre Exemple") ) r = client_hub23.get("/api/jetons/metadonnees") assert r.status_code == 200 corps = r.json() assert corps["portee"] == "propres" noms = {j["nom"]: j for j in corps["jetons"]} assert set(noms) == {"service-agents", "mcp-local"} assert noms["service-agents"]["etat"] == "revoque" assert noms["mcp-local"]["etat"] == "actif" assert noms["mcp-local"]["proprietaire"] == "hub23-membre" assert noms["mcp-local"]["prefixe"] == "kbt_" assert noms["mcp-local"]["cree_le"] # Aucune valeur, aucune empreinte, sous aucune forme. texte = json.dumps(corps) assert "token_hash" not in texte assert _clair not in texte and _clair2 not in texte def test_admin_voit_tous_les_jetons(client_hub23): admin = _creer_lie("hub23-admin", "hub23-admin", role="admin") membre = _creer_lie("hub23-zoe", "hub23-zoe") models.create_api_token(admin["id"], "jeton-admin") models.create_api_token(membre["id"], "jeton-zoe") client_hub23.cookies.set( "draco_session", _jeton("hub23-admin", "Admin Exemple") ) r = client_hub23.get("/api/jetons/metadonnees") assert r.status_code == 200 corps = r.json() assert corps["portee"] == "tous" par_nom = {j["nom"]: j for j in corps["jetons"]} assert par_nom["jeton-admin"]["proprietaire"] == "hub23-admin" assert par_nom["jeton-zoe"]["proprietaire"] == "hub23-zoe" assert "token_hash" not in json.dumps(corps)