Kanbante 0.46.2 : tests rôles Hub (client, cache/TTL, refus sans création, /admin gardé, session native non soumise) (tests/test_hub_roles.py)
CI / test (push) Failing after 0s
CI / test (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
"""HUB-19 (0.46.2) — rôles du Hub propagés à Kanbante (contrat D3(a)).
|
||||
|
||||
Portage du pilote ShaarIt 0.7.0. Couvert ici :
|
||||
|
||||
- client ``app.hub_roles`` : appel ``GET {hub}/api/roles/kanbante``
|
||||
(Cookie complet relayé, ``Accept: application/json``), URL du
|
||||
Hub résolue comme la barre (explicite > hôte de la requête >
|
||||
défaut officiel), cache 60 s par ``sub``, horodatage posé avant
|
||||
l'appel (échec = ancien cache conservé, pas de rafale), tout
|
||||
échec = rôle inconnu (``None``) ;
|
||||
- pont ``/auth/draco`` : la consultation précède la résolution et
|
||||
la création JIT — rôle connu ``aucun`` → refus explicite 403,
|
||||
**aucune session ouverte, aucun compte créé**, aucune liaison à
|
||||
une session native préexistante ;
|
||||
- ``/admin`` : session établie par draco + rôle connu ≠
|
||||
``administrateur`` → refusé malgré le rôle local admin ; rôle
|
||||
``administrateur`` → accepté ; rôle inconnu → repli local ;
|
||||
session **native** (même admin, même cookie draco présent) →
|
||||
règles locales inchangées.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import draco, hub_roles, models
|
||||
|
||||
SECRET = "secret-hub19-partage"
|
||||
|
||||
SUBS_TEST = (
|
||||
"hub19-refuse", "hub19-membre", "hub19-chef", "hub19-inconnu",
|
||||
"hub19-sans-liaison",
|
||||
)
|
||||
|
||||
|
||||
def _jeton(sub, nom="Alice Exemple", secret=SECRET):
|
||||
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 _sub_depuis_cookie(cookie: str) -> str:
|
||||
for morceau in (cookie or "").split(";"):
|
||||
nom, _, valeur = morceau.strip().partition("=")
|
||||
if nom == "draco_session" and valeur.count(".") == 2:
|
||||
segment = valeur.split(".")[1]
|
||||
segment += "=" * (-len(segment) % 4)
|
||||
try:
|
||||
return json.loads(base64.urlsafe_b64decode(segment))["sub"]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code=200, donnees=None):
|
||||
self.status_code = status_code
|
||||
self._donnees = donnees
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._donnees, Exception):
|
||||
raise self._donnees
|
||||
return self._donnees
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cache_propre():
|
||||
hub_roles._reset_cache()
|
||||
yield
|
||||
hub_roles._reset_cache()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def faux_hub(monkeypatch):
|
||||
"""Faux registre du Hub : rôles par ``sub``, lu dans le cookie relayé."""
|
||||
etat = {"roles": {}, "appels": [], "mode": "ok"}
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
etat["appels"].append(
|
||||
{"url": url, "headers": dict(headers or {}), "timeout": timeout}
|
||||
)
|
||||
if etat["mode"] == "panne":
|
||||
raise httpx.ConnectError("Hub injoignable (simulation)")
|
||||
if etat["mode"] == "erreur500":
|
||||
return _Reponse(500, {"detail": "erreur"})
|
||||
sub = _sub_depuis_cookie((headers or {}).get("Cookie", ""))
|
||||
role = etat["roles"].get(sub, "utilisateur")
|
||||
return _Reponse(200, {
|
||||
"app": "kanbante", "sub": sub, "role": role,
|
||||
"explicite": True, "ttl_secondes": 60,
|
||||
})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
return etat
|
||||
|
||||
|
||||
# ── Client : contrat d'appel et résolution d'URL ─────────────
|
||||
|
||||
def test_client_appel_contrat_et_cache(faux_hub):
|
||||
faux_hub["roles"]["sub-a"] = "administrateur"
|
||||
cookie = f"draco_session={_jeton('sub-a')}; autre=1"
|
||||
assert hub_roles.role_pour(cookie, "sub-a") == "administrateur"
|
||||
# Deuxième appel dans le TTL : servi par le cache, aucun appel.
|
||||
assert hub_roles.role_pour(cookie, "sub-a") == "administrateur"
|
||||
assert len(faux_hub["appels"]) == 1
|
||||
appel = faux_hub["appels"][0]
|
||||
assert appel["url"] == "https://hub.dracodev.net/api/roles/kanbante"
|
||||
assert appel["headers"]["Cookie"] == cookie
|
||||
assert appel["headers"]["Accept"] == "application/json"
|
||||
assert appel["timeout"] == 2.0
|
||||
|
||||
|
||||
def test_client_url_hub_par_hote_dev(faux_hub):
|
||||
hub_roles.role_pour(
|
||||
f"draco_session={_jeton('sub-b')}", "sub-b",
|
||||
hote="kanbante-dev.dracodev.net",
|
||||
)
|
||||
assert faux_hub["appels"][0]["url"] == (
|
||||
"https://hub-dev.dracodev.net/api/roles/kanbante"
|
||||
)
|
||||
|
||||
|
||||
def test_client_url_hub_explicite_prioritaire(faux_hub, monkeypatch):
|
||||
monkeypatch.setenv("DRACO_HUB_URL", "https://hub.exemple.test/")
|
||||
hub_roles.role_pour(
|
||||
f"draco_session={_jeton('sub-c')}", "sub-c",
|
||||
hote="kanbante-dev.dracodev.net",
|
||||
)
|
||||
assert faux_hub["appels"][0]["url"] == (
|
||||
"https://hub.exemple.test/api/roles/kanbante"
|
||||
)
|
||||
|
||||
|
||||
def test_client_ttl_expire_reconsulte(faux_hub):
|
||||
cookie = f"draco_session={_jeton('sub-d')}"
|
||||
faux_hub["roles"]["sub-d"] = "utilisateur"
|
||||
assert hub_roles.role_pour(cookie, "sub-d") == "utilisateur"
|
||||
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
|
||||
faux_hub["roles"]["sub-d"] = "administrateur"
|
||||
assert hub_roles.role_pour(cookie, "sub-d") == "administrateur"
|
||||
assert len(faux_hub["appels"]) == 2
|
||||
|
||||
|
||||
def test_client_echec_inconnu_et_ancien_cache_conserve(faux_hub):
|
||||
cookie = f"draco_session={_jeton('sub-e')}"
|
||||
faux_hub["roles"]["sub-e"] = "utilisateur"
|
||||
assert hub_roles.role_pour(cookie, "sub-e") == "utilisateur"
|
||||
# TTL expiré, Hub en panne : l'appel tenté rend « inconnu »…
|
||||
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
|
||||
faux_hub["mode"] = "panne"
|
||||
assert hub_roles.role_pour(cookie, "sub-e") is None
|
||||
appels = len(faux_hub["appels"])
|
||||
# …mais l'horodatage posé avant l'appel garde l'ancien cache :
|
||||
# l'appel suivant (dans le nouveau TTL) le ressert sans rafale.
|
||||
assert hub_roles.role_pour(cookie, "sub-e") == "utilisateur"
|
||||
assert len(faux_hub["appels"]) == appels
|
||||
|
||||
|
||||
def test_client_reponses_illisibles_inconnues(monkeypatch):
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
return _Reponse(200, {"role": "super-utilisateur"})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
assert hub_roles.role_pour("c=1", "sub-f") is None
|
||||
hub_roles._reset_cache()
|
||||
|
||||
def faux_get_403(url, headers=None, timeout=None):
|
||||
return _Reponse(403, {"role": "aucun"})
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get_403)
|
||||
assert hub_roles.role_pour("c=1", "sub-f") is None
|
||||
|
||||
|
||||
# ── Intégration : pont draco et garde /admin ─────────────────
|
||||
|
||||
def _nettoyer():
|
||||
with models.get_db() as conn:
|
||||
ids = [
|
||||
r[0] for r in conn.execute(
|
||||
"SELECT id FROM users WHERE draco_sub IN ({})".format(
|
||||
",".join("?" * len(SUBS_TEST))
|
||||
),
|
||||
SUBS_TEST,
|
||||
).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_hub19(monkeypatch, fake_gitea, faux_hub):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
_nettoyer()
|
||||
with TestClient(main.app) as client:
|
||||
admin = next(u for u in models.list_users() if u["role"] == "admin")
|
||||
sub_avant = admin.get("draco_sub")
|
||||
try:
|
||||
yield client, faux_hub, admin
|
||||
finally:
|
||||
models.update_user(admin["id"], draco_sub=sub_avant)
|
||||
_nettoyer()
|
||||
draco._revoked_cache.clear()
|
||||
|
||||
|
||||
def _neutraliser_amorcage(admin) -> bool:
|
||||
"""Vrai si l'amorçage pur a été neutralisé (témoin lié à l'admin).
|
||||
|
||||
Même patron que tests/test_draco_correspondance.py : les
|
||||
scénarios JIT exigent que l'unique admin sans liaison ne
|
||||
capture pas le premier sub de passage.
|
||||
"""
|
||||
users = models.list_users()
|
||||
if len(users) == 1 and users[0]["role"] == "admin" \
|
||||
and not users[0].get("draco_sub"):
|
||||
models.update_user(users[0]["id"], draco_sub="hub19-temoin-amorce")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pont(client, sub, nom="Alice Exemple"):
|
||||
client.cookies.set("draco_session", _jeton(sub, nom))
|
||||
return client.get("/auth/draco", follow_redirects=False)
|
||||
|
||||
|
||||
def test_pont_role_aucun_refuse_sans_creation(client_hub19):
|
||||
client, hub, _admin = client_hub19
|
||||
hub["roles"]["hub19-refuse"] = "aucun"
|
||||
avant = models.count_users()
|
||||
r = _pont(client, "hub19-refuse", "Refusée Exemple")
|
||||
assert r.status_code == 403
|
||||
assert "Accès refusé" in r.text
|
||||
assert "« aucun »" in r.text
|
||||
# Aucune session Kanbante ouverte par draco…
|
||||
assert "kanbante_session" not in r.headers.get("set-cookie", "")
|
||||
# …et aucun compte JIT créé pour ce sub.
|
||||
assert models.count_users() == avant
|
||||
assert models.get_user_by_draco("hub19-refuse") is None
|
||||
|
||||
|
||||
def test_pont_role_aucun_ne_lie_pas_une_session_native(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
hub["roles"]["hub19-sans-liaison"] = "aucun"
|
||||
# Session native préexistante (format historique, sans marqueur).
|
||||
client.cookies.set(
|
||||
"kanbante_session", main._make_session(admin["username"])
|
||||
)
|
||||
r = _pont(client, "hub19-sans-liaison")
|
||||
assert r.status_code == 403
|
||||
# La consultation a précédé la liaison : rien n'a été lié.
|
||||
assert models.get_user_by_draco("hub19-sans-liaison") is None
|
||||
# La session native elle-même n'est pas touchée.
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_pont_role_utilisateur_ouvre_session_jit(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
hub["roles"]["hub19-membre"] = "utilisateur"
|
||||
pur = _neutraliser_amorcage(admin)
|
||||
try:
|
||||
r = _pont(client, "hub19-membre", "Membre Exemple")
|
||||
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||
assert "kanbante_session" in r.headers.get("set-cookie", "")
|
||||
cree = models.get_user_by_draco("hub19-membre")
|
||||
assert cree is not None and cree["role"] == "member"
|
||||
assert client.get("/").status_code == 200
|
||||
finally:
|
||||
if pur:
|
||||
models.update_user(admin["id"], draco_sub=None)
|
||||
|
||||
|
||||
def test_pont_role_inconnu_comportement_actuel(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
hub["mode"] = "panne"
|
||||
pur = _neutraliser_amorcage(admin)
|
||||
try:
|
||||
r = _pont(client, "hub19-inconnu", "Inconnu Exemple")
|
||||
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||
assert models.get_user_by_draco("hub19-inconnu") is not None
|
||||
finally:
|
||||
if pur:
|
||||
models.update_user(admin["id"], draco_sub=None)
|
||||
|
||||
|
||||
def _admin_lie_au_pont(client, hub, admin, role_hub):
|
||||
"""Lie le compte admin local au sub chef, puis ouvre sa session par le pont."""
|
||||
models.update_user(admin["id"], draco_sub="hub19-chef")
|
||||
hub["roles"]["hub19-chef"] = role_hub
|
||||
hub_roles._reset_cache()
|
||||
r = _pont(client, "hub19-chef", "Chef Exemple")
|
||||
assert r.status_code == 303 and r.headers["location"] == "/"
|
||||
return r
|
||||
|
||||
|
||||
def test_admin_session_draco_exige_administrateur_du_registre(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
_admin_lie_au_pont(client, hub, admin, "utilisateur")
|
||||
# Rôle local admin, mais registre connu « utilisateur » : refusé.
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 403
|
||||
# Registre passé à « administrateur » : accepté.
|
||||
hub["roles"]["hub19-chef"] = "administrateur"
|
||||
hub_roles._reset_cache()
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_session_draco_role_inconnu_repli_local(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
_admin_lie_au_pont(client, hub, admin, "administrateur")
|
||||
assert client.get("/admin").status_code == 200
|
||||
# Hub en panne et cache expiré : rôle inconnu → règles locales.
|
||||
hub["mode"] = "panne"
|
||||
hub_roles._cache_fetched_at -= hub_roles.CACHE_TTL + 1
|
||||
# L'appel de garde rend « inconnu » (l'ancien cache ne ressert
|
||||
# qu'aux appels suivants, patron du pilote) : le repli local joue.
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_session_native_non_soumise_au_registre(client_hub19):
|
||||
client, hub, admin = client_hub19
|
||||
models.update_user(admin["id"], draco_sub="hub19-chef")
|
||||
hub["roles"]["hub19-chef"] = "utilisateur"
|
||||
# Session NATIVE de l'admin (mot de passe / format historique) :
|
||||
# même avec un cookie draco présent et un rôle registre connu
|
||||
# « utilisateur », /admin reste régi par les règles locales.
|
||||
client.cookies.set(
|
||||
"kanbante_session", main._make_session(admin["username"])
|
||||
)
|
||||
client.cookies.set("draco_session", _jeton("hub19-chef", "Chef Exemple"))
|
||||
r = client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_marqueur_session_draco_signe():
|
||||
"""Le marqueur « établie par draco » est couvert par la signature."""
|
||||
from starlette.requests import Request
|
||||
|
||||
def requete(valeur):
|
||||
scope = {
|
||||
"type": "http", "method": "GET", "path": "/",
|
||||
"headers": [(b"cookie", f"kanbante_session={valeur}".encode())],
|
||||
}
|
||||
return Request(scope)
|
||||
|
||||
natif = main._make_session("bruno")
|
||||
assert main._session_info(requete(natif)) == ("bruno", False)
|
||||
via = main._make_session("bruno", via_draco=True)
|
||||
assert main._session_info(requete(via)) == ("bruno", True)
|
||||
# Marqueur ajouté à la main sur un jeton natif : signature fausse.
|
||||
pirate = natif.rsplit(".", 1)[0] + ".draco." + natif.rsplit(".", 1)[1]
|
||||
assert main._session_info(requete(pirate)) is None
|
||||
Reference in New Issue
Block a user