FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 12s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 14s
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""HUB-19 — propagation des rôles du Hub à Flowdeck (suite du pilote
|
|
ShaarIt 0.7.0) : client ``app.services.hub_roles`` et ses effets.
|
|
|
|
Contrat D3(a) : à l'ouverture de session draco, consultation de
|
|
``GET {hub}/api/roles/flowdeck`` (cookie complet relayé, délai 2 s),
|
|
cache mémoire par ``sub`` TTL 60 s, horodatage posé avant l'appel.
|
|
Toute erreur = rôle inconnu → comportement actuel conservé.
|
|
|
|
Effets vérifiés ici :
|
|
- rôle connu ``aucun`` → 403 en français au pont ``GET /auth/draco``,
|
|
avant la création de la session locale et avant le provisionnement
|
|
JIT HUB-15 (aucun compte créé pour un ``sub`` refusé) ;
|
|
- session locale préexistante exemptée (accès de secours) ;
|
|
- surfaces d'administration d'instance (``/api/admin/*``) : session
|
|
draco + rôle connu → ``administrateur`` exigé ;
|
|
- rôle inconnu (Hub injoignable/erreur/rôle hors ensemble) → repli
|
|
intégral.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from conftest import anon
|
|
|
|
from app.db import get_conn
|
|
from app.services import draco, hub_roles
|
|
|
|
SECRET = "secret-partage-de-test"
|
|
|
|
|
|
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-hub19", name="Bruno"):
|
|
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": name, "iat": int(time.time()),
|
|
"exp": exp if exp is not None else int(time.time()) + 3600,
|
|
"jti": jti,
|
|
}).encode())
|
|
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
|
|
hashlib.sha256).digest()
|
|
return f"{header}.{payload}.{b64(sig)}"
|
|
|
|
|
|
def _requete(cookie_complet="", draco_token=None):
|
|
cookies = {}
|
|
headers = {}
|
|
if draco_token is not None:
|
|
cookies["draco_session"] = draco_token
|
|
complet = f"draco_session={draco_token}"
|
|
headers["cookie"] = complet if not cookie_complet else cookie_complet
|
|
elif cookie_complet:
|
|
headers["cookie"] = cookie_complet
|
|
return SimpleNamespace(cookies=cookies, headers=headers)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _caches_propres(monkeypatch):
|
|
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
|
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
|
draco._revoked_cache.clear()
|
|
hub_roles._cache.clear()
|
|
hub_roles._cache_fetched_at = 0.0
|
|
yield
|
|
draco._revoked_cache.clear()
|
|
hub_roles._cache.clear()
|
|
hub_roles._cache_fetched_at = 0.0
|
|
|
|
|
|
def _pose_role(monkeypatch, role, compteur=None):
|
|
"""Simule la réponse du Hub pour ``role_pour`` (appel réseau évité)."""
|
|
def faux_consulter(cookie):
|
|
if compteur is not None:
|
|
compteur.append(cookie)
|
|
return role
|
|
monkeypatch.setattr(hub_roles, "_consulter_hub", faux_consulter)
|
|
|
|
|
|
# ── Client : cache par sub, TTL, horodatage avant l'appel ───────────
|
|
|
|
def test_role_connu_mis_en_cache_par_sub(monkeypatch):
|
|
appels = []
|
|
_pose_role(monkeypatch, "utilisateur", appels)
|
|
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
|
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
|
assert len(appels) == 1 # 2e lecture : cache
|
|
assert hub_roles.role_pour("cookie-b", "sub-2") == "utilisateur"
|
|
assert len(appels) == 2 # autre sub : nouvel appel
|
|
|
|
|
|
def test_echec_ancien_cache_conserve_pas_de_rafale(monkeypatch):
|
|
appels = []
|
|
_pose_role(monkeypatch, "utilisateur", appels)
|
|
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
|
# Fenêtre expirée, Hub désormais en échec : l'ancien cache reste.
|
|
hub_roles._cache_fetched_at = time.monotonic() - 61
|
|
_pose_role(monkeypatch, None, appels)
|
|
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
|
assert len(appels) == 2
|
|
# Pas de rafale : dans la fenêtre qui suit l'échec, aucun rappel.
|
|
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
|
assert len(appels) == 2
|
|
|
|
|
|
def test_echec_sans_cache_role_inconnu(monkeypatch):
|
|
# Fidèle au pilote : sans entrée en cache pour ce sub, l'échec
|
|
# rend « inconnu » et la demande suivante retente (la protection
|
|
# anti-rafale de l'horodatage posé avant l'appel ne sert que les
|
|
# subs déjà en cache — test précédent).
|
|
appels = []
|
|
_pose_role(monkeypatch, None, appels)
|
|
assert hub_roles.role_pour("c", "sub-x") is None
|
|
assert hub_roles.role_pour("c", "sub-x") is None
|
|
assert len(appels) == 2
|
|
|
|
|
|
def test_role_sans_sub_inconnu(monkeypatch):
|
|
_pose_role(monkeypatch, "aucun")
|
|
assert hub_roles.role_pour("c", "") is None
|
|
|
|
|
|
# ── Client : validation de la réponse du Hub ────────────────────────
|
|
|
|
class _Reponse:
|
|
def __init__(self, status_code, payload=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
|
|
def json(self):
|
|
if isinstance(self._payload, Exception):
|
|
raise self._payload
|
|
return self._payload
|
|
|
|
|
|
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
|
import httpx
|
|
|
|
from app.services import draco_urls
|
|
monkeypatch.setattr(draco_urls, "hub_url", lambda: "https://hub.example")
|
|
|
|
def faux_get(url, headers=None, timeout=None):
|
|
if appels is not None:
|
|
appels.append((url, headers, timeout))
|
|
if isinstance(reponse_ou_exception, Exception):
|
|
raise reponse_ou_exception
|
|
return reponse_ou_exception
|
|
|
|
monkeypatch.setattr(httpx, "get", faux_get)
|
|
|
|
|
|
def test_consultation_contrat_exact(monkeypatch):
|
|
appels = []
|
|
_pose_http(monkeypatch, _Reponse(200, {
|
|
"app": "flowdeck", "sub": "sub-1", "role": "administrateur",
|
|
"explicite": True, "ttl_secondes": 60}), appels)
|
|
cookie = "draco_session=jeton; autre=1"
|
|
assert hub_roles._consulter_hub(cookie) == "administrateur"
|
|
url, headers, timeout = appels[0]
|
|
assert url == "https://hub.example/api/roles/flowdeck"
|
|
assert headers["Cookie"] == cookie # cookie complet relayé
|
|
assert headers["Accept"] == "application/json"
|
|
assert timeout == 2.0
|
|
|
|
|
|
@pytest.mark.parametrize("reponse", [
|
|
_Reponse(401, {}), _Reponse(404, {}), _Reponse(500, {}),
|
|
_Reponse(200, {"role": "superadmin"}), # hors ensemble
|
|
_Reponse(200, {"role": None}),
|
|
_Reponse(200, ValueError("json illisible")),
|
|
ConnectionError("hub injoignable"),
|
|
])
|
|
def test_consultation_erreurs_role_inconnu(monkeypatch, reponse):
|
|
_pose_http(monkeypatch, reponse)
|
|
assert hub_roles._consulter_hub("c") is None
|
|
|
|
|
|
# ── role_draco / admin_hub_ok ───────────────────────────────────────
|
|
|
|
def test_role_draco_sans_cookie_inconnu():
|
|
assert hub_roles.role_draco(_requete()) is None
|
|
|
|
|
|
def test_role_draco_avec_cookie(monkeypatch):
|
|
_pose_role(monkeypatch, "administrateur")
|
|
assert hub_roles.role_draco(_requete(draco_token=_jeton())) == "administrateur"
|
|
|
|
|
|
def test_admin_hub_ok_sans_draco_comportement_actuel():
|
|
assert hub_roles.admin_hub_ok(_requete()) is True
|
|
|
|
|
|
@pytest.mark.parametrize("role,attendu", [
|
|
("administrateur", True), ("utilisateur", False), ("aucun", False),
|
|
(None, True), # inconnu → repli
|
|
])
|
|
def test_admin_hub_ok_selon_role(monkeypatch, role, attendu):
|
|
_pose_role(monkeypatch, role)
|
|
assert hub_roles.admin_hub_ok(_requete(draco_token=_jeton())) is attendu
|
|
|
|
|
|
# ── Intégration : pont /auth/draco ──────────────────────────────────
|
|
|
|
def _compte_utilisateurs():
|
|
with get_conn() as conn:
|
|
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
|
|
|
|
def _ajoute_deuxieme_utilisateur():
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"INSERT INTO users (login, email, password_hash, full_name, is_active) "
|
|
"VALUES ('deuxieme', '[email protected]', 'x', 'Deuxième', 1)")
|
|
conn.commit()
|
|
|
|
|
|
def test_pont_refus_aucun_403_en_francais(client, monkeypatch):
|
|
c = anon(client)
|
|
_pose_role(monkeypatch, "aucun")
|
|
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = c.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 403
|
|
assert "registre des rôles" in r.text and "Hub" in r.text
|
|
assert "« aucun »" in r.text
|
|
assert "flowdeck_session" not in r.headers.get("set-cookie", "")
|
|
assert c.get("/auth/user").json()["authenticated"] is False
|
|
|
|
|
|
def test_pont_refus_aucun_avant_provisionnement_jit(client, monkeypatch):
|
|
c = anon(client)
|
|
_ajoute_deuxieme_utilisateur()
|
|
avant = _compte_utilisateurs()
|
|
_pose_role(monkeypatch, "aucun")
|
|
c.cookies.set("draco_session", _jeton(sub="og-refuse", name="Refusée"))
|
|
r = c.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 403
|
|
# Ordre vérifié : refus avant JIT — aucun compte, aucun lien créés.
|
|
assert _compte_utilisateurs() == avant
|
|
with get_conn() as conn:
|
|
assert conn.execute(
|
|
"SELECT COUNT(*) FROM draco_links WHERE sub = 'og-refuse'"
|
|
).fetchone()[0] == 0
|
|
|
|
|
|
def test_pont_role_utilisateur_jit_inchange(client, monkeypatch):
|
|
c = anon(client)
|
|
_ajoute_deuxieme_utilisateur()
|
|
avant = _compte_utilisateurs()
|
|
_pose_role(monkeypatch, "utilisateur")
|
|
c.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
|
r = c.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"] == "/workspaces"
|
|
assert _compte_utilisateurs() == avant + 1 # JIT HUB-15 intact
|
|
|
|
|
|
def test_pont_role_inconnu_repli_comportement_actuel(client, monkeypatch):
|
|
c = anon(client)
|
|
_pose_role(monkeypatch, None) # Hub injoignable
|
|
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = c.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"] == "/workspaces"
|
|
|
|
|
|
def test_pont_session_locale_preexistante_exemptee(client, monkeypatch):
|
|
# Le client de la fixture porte déjà une session locale (admin de
|
|
# test) : patron du pilote — l'accès de secours n'est pas refusé.
|
|
_pose_role(monkeypatch, "aucun")
|
|
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"] == "/workspaces"
|
|
|
|
|
|
# ── Intégration : surfaces d'administration (/api/admin/*) ──────────
|
|
|
|
def test_admin_instance_role_utilisateur_refuse(client, monkeypatch):
|
|
_pose_role(monkeypatch, "utilisateur")
|
|
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = client.get("/api/admin/users")
|
|
assert r.status_code == 403
|
|
assert "registre" in r.text and "Hub" in r.text
|
|
|
|
|
|
def test_admin_instance_role_administrateur_accepte(client, monkeypatch):
|
|
_pose_role(monkeypatch, "administrateur")
|
|
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = client.get("/api/admin/users")
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_admin_instance_role_inconnu_repli(client, monkeypatch):
|
|
_pose_role(monkeypatch, None)
|
|
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
|
r = client.get("/api/admin/users")
|
|
assert r.status_code == 200 # comportement actuel
|
|
|
|
|
|
def test_admin_instance_sans_draco_inchange(client, monkeypatch):
|
|
_pose_role(monkeypatch, "aucun") # jamais consulté sans draco
|
|
r = client.get("/api/admin/users")
|
|
assert r.status_code == 200
|