"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer. Le service d'agents se fait refuser par Kanbante depuis la 0.24.0 (auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent une identité machine propre, révocable, limitée à /mcp et /api/. """ import pytest from fastapi.testclient import TestClient import app.main as main from app import accounts, models @pytest.fixture() def utilisateur(): # La base de test est partagée par toute la session : nom unique, # et nettoyage complet au teardown — le pont SSO (test_draco_sso) # a une sémantique « un seul compte en base » dont hérite la suite. import uuid uid = models.create_user( f"machine-{uuid.uuid4().hex[:8]}", accounts.hash_password("motdepasse"), role="member") user = models.get_user(uid) yield user with models.get_db() as conn: conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,)) conn.execute("DELETE FROM users WHERE id = ?", (uid,)) def test_jeton_cycle_de_vie(utilisateur): row, clair = models.create_api_token(utilisateur["id"], "service-agents") assert clair.startswith("kbt_") # La base ne connaît que l'empreinte, jamais le texte clair. assert row["token_hash"] != clair and len(row["token_hash"]) == 64 trouve = models.user_by_api_token(clair) assert trouve is not None and trouve["username"] == utilisateur["username"] assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None # Révocation : effet immédiat. assert models.revoke_api_token(utilisateur["id"], row["id"]) is True assert models.user_by_api_token(clair) is None # On ne révoque pas le jeton d'autrui. row2, _clair2 = models.create_api_token(utilisateur["id"], "autre") assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False def test_jeton_invalide_ou_compte_desactive(utilisateur): _row, clair = models.create_api_token(utilisateur["id"], "x") assert models.user_by_api_token("kbt_nimportequoi") is None assert models.user_by_api_token("") is None assert models.user_by_api_token("tck_autreformat") is None models.update_user(utilisateur["id"], active=0) assert models.user_by_api_token(clair) is None def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur): monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") _row, clair = models.create_api_token(utilisateur["id"], "service-agents") corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}} with TestClient(main.app) as client: # Sans jeton : 401 (le refus vu en production le 2026-10-21). assert client.post("/mcp", json=corps).status_code == 401 # Avec le jeton Bearer : le handshake passe. r = client.post("/mcp", json=corps, headers={"Authorization": f"Bearer {clair}"}) assert r.status_code == 200 assert r.json()["result"]["serverInfo"]["name"] == "kanbante" # Mais un jeton n'ouvre pas l'interface web. assert client.get("/", headers={"Authorization": f"Bearer {clair}", "Accept": "application/json"}).status_code == 401