# tests/test_draco_session.py # Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la # connexion, vérification (signature, expiration, révocation, rotation), # renouvellement glissant, return_to en liste blanche, révocations. import time import pytest from jose import jwt as jose_jwt from backend import draco_session USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"} @pytest.fixture(autouse=True) def _draco_env(monkeypatch, tmp_path): monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test") monkeypatch.setattr(draco_session, "REVOKED_FILE", tmp_path / "draco_revoked.json") draco_session._revoked = None yield draco_session._revoked = None # ── Jeton : émission / vérification ───────────────────────────────────── def test_roundtrip_claims_minimaux(): token, jti, exp = draco_session.issue_token(USER) claims = draco_session.verify_token(token) assert claims is not None assert claims["sub"] == "admin-1" assert claims["name"] == "Admin" assert claims["jti"] == jti assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2 assert "role" not in claims # aucun rôle dans le jeton def test_jeton_falsifie_rejete(): token, _j, _e = draco_session.issue_token(USER) assert draco_session.verify_token(token + "x") is None autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret", algorithm="HS256") assert draco_session.verify_token(autre) is None def test_jeton_expire_rejete(): now = int(time.time()) token = jose_jwt.encode( {"sub": "admin-1", "name": "Admin", "iat": now - 7200, "exp": now - 3600, "jti": "ancien"}, "secret-de-test", algorithm="HS256") assert draco_session.verify_token(token) is None def test_rotation_secret_precedent_accepte(monkeypatch): token = jose_jwt.encode( {"sub": "admin-1", "name": "Admin", "iat": int(time.time()), "exp": int(time.time()) + 3600, "jti": "rotation"}, "ancien-secret", algorithm="HS256") assert draco_session.verify_token(token) is None # pas encore configuré monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret") assert draco_session.verify_token(token)["jti"] == "rotation" def test_revocation(): token, jti, exp = draco_session.issue_token(USER) assert draco_session.verify_token(token) is not None draco_session.revoke(jti, exp) assert draco_session.verify_token(token) is None assert {"jti": jti, "exp": exp} in draco_session.revoked_list() def test_inerte_sans_secret(monkeypatch): monkeypatch.delenv("DRACO_SESSION_SECRET") assert draco_session.enabled() is False assert draco_session.verify_token("peu-importe") is None def test_fenetre_de_renouvellement(): assert draco_session.needs_renewal({"exp": int(time.time()) + 1800}) assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600}) # ── return_to : liste blanche ──────────────────────────────────────────── @pytest.mark.parametrize("url,ok", [ ("https://tiktak.dracodev.net/?task=3", True), ("https://og.dracodev.net/", True), ("https://dracodev.net/x", True), ("http://tiktak.dracodev.net/", False), # https seulement ("https://evil.com/", False), ("https://tiktak.dracodev.net.evil.com/", False), ("https://notdracodev.net/", False), ("", False), ]) def test_validate_return_to(url, ok): assert (draco_session.validate_return_to(url) is not None) == ok # ── Intégration : connexion / déconnexion / renouvellement ────────────── def _login(client): r = client.post("/api/auth/login", json={"username": "admin", "password": "chab30"}) assert r.status_code == 200, r.text return r def _draco_cookie(response): for raw in response.headers.get_list("set-cookie"): if raw.startswith("draco_session="): return raw return None def test_login_pose_le_cookie(admin_client): r = _login(admin_client) cookie = _draco_cookie(r) assert cookie is not None assert "Domain=.dracodev.net" in cookie assert "HttpOnly" in cookie and "SameSite=lax" in cookie token = cookie.split(";")[0].split("=", 1)[1] assert draco_session.verify_token(token)["sub"] == "admin-1" def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch): monkeypatch.delenv("DRACO_SESSION_SECRET") r = _login(admin_client) assert _draco_cookie(r) is None def _token_depuis_login(client): # Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte # de test différent) : on lit le jeton dans l'en-tête set-cookie. cookie = _draco_cookie(_login(client)) assert cookie is not None return cookie.split(";")[0].split("=", 1)[1] def test_logout_revoque_le_jti(admin_client): token = _token_depuis_login(admin_client) assert draco_session.verify_token(token) r = admin_client.post("/api/auth/logout", headers={"Cookie": f"draco_session={token}"}) assert r.status_code == 200, r.text assert draco_session.verify_token(token) is None # révoqué def test_renouvellement_glissant(admin_client): _login(admin_client) # Cookie draco presque expiré (dans la fenêtre des 2 h) : now = int(time.time()) vieux = jose_jwt.encode( {"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600, "exp": now + 600, "jti": "vieux-jti"}, "secret-de-test", algorithm="HS256") admin_client.cookies.set("draco_session", vieux) r = admin_client.get("/api/auth/me") assert r.status_code == 200, r.text cookie = _draco_cookie(r) assert cookie is not None # réémis nouveau = cookie.split(";")[0].split("=", 1)[1] assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti" def test_pas_de_renouvellement_si_frais(admin_client): token = _token_depuis_login(admin_client) admin_client.cookies.set("draco_session", token) # cookie frais porté r = admin_client.get("/api/auth/me") assert r.status_code == 200 assert _draco_cookie(r) is None # cookie frais : rien def test_sso_authorize_liste_blanche(client): r = client.get("/sso/authorize", params={"return_to": "https://tiktak.dracodev.net/?task=3"}, follow_redirects=False) assert r.status_code == 302 assert "return_to=" in r.headers["location"] r = client.get("/sso/authorize", params={"return_to": "https://evil.com/phish"}, follow_redirects=False) assert r.status_code == 302 assert r.headers["location"] == "/" # refusé, pas de relais # ── Validation locale (ACC-3.2, #9) ────────────────────────────────────── def test_me_avec_cookie_draco_seul(admin_client): # Aucune session native : le cookie partagé suffit (sub = id). token, _jti, _exp = draco_session.issue_token(USER) admin_client.cookies.set("draco_session", token) r = admin_client.get("/api/auth/me") assert r.status_code == 200, r.text assert r.json()["username"] == "admin" def test_me_draco_revoque_refuse(admin_client): token, jti, exp = draco_session.issue_token(USER) draco_session.revoke(jti, exp) admin_client.cookies.set("draco_session", token) r = admin_client.get("/api/auth/me") assert r.status_code == 401 def test_me_draco_sub_inconnu_refuse(admin_client): token, _j, _e = draco_session.issue_token( {"id": "personne-99", "username": "x", "display_name": "X"}) admin_client.cookies.set("draco_session", token) r = admin_client.get("/api/auth/me") assert r.status_code == 401 def test_me_draco_inerte_sans_secret(admin_client, monkeypatch): token, _j, _e = draco_session.issue_token(USER) monkeypatch.delenv("DRACO_SESSION_SECRET") admin_client.cookies.set("draco_session", token) r = admin_client.get("/api/auth/me") assert r.status_code == 401 def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch): token, jti, exp = draco_session.issue_token(USER) draco_session.revoke(jti, exp) r = admin_client.get("/api/sso/revocations") assert r.status_code == 404 # clé non configurée monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine") r = admin_client.get("/api/sso/revocations") assert r.status_code == 401 # en-tête absent r = admin_client.get("/api/sso/revocations", headers={"X-Draco-Machine-Key": "cle-machine"}) assert r.status_code == 200 assert {"jti": jti, "exp": exp} in r.json()["revoked"]