Kanbante 0.46.5 — pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion suggérant le SSO (fusion #144)
CI / test (push) Failing after 2m26s
CI / test (pull_request) Failing after 2m35s

This commit is contained in:
Spark
2026-11-07 04:16:19 +00:00
parent d89b35061b
commit f8f8af63e1
8 changed files with 449 additions and 38 deletions
+293
View File
@@ -0,0 +1,293 @@
"""Fusion #145 (0.46.5) — adoption CONDITIONNÉE du compte d'amorçage.
Avant ce correctif, sur une instance fraîche, le premier visiteur
du pont draco adoptait le compte administrateur d'amorçage
(« liaison au premier passage ») — un simple utilisateur de la
suite (« dan », audit « compte partout » du 2026-11-06) l'a fait
en pile de contrôle. Désormais, l'adoption n'a lieu que si :
(a) le ``sub`` entrant est « administrateur » pour kanbante au
registre des rôles du Hub (repli propre : Hub muet = rôle
inconnu = PAS d'adoption) ;
(b) ou l'adoption est initiée depuis une session native déjà
authentifiée (liaison explicite par l'utilisateur connecté).
Couvert ici, sur instance RÉELLEMENT fraîche (base SQLite
temporaire propre à chaque test : au démarrage, seul le compte
d'amorçage créé depuis l'environnement existe, sans liaison) :
(i) sub simple utilisateur → compte membre JIT propre,
amorçage intact et toujours sans liaison ;
(ii) sub administrateur au registre → adoption de l'amorçage ;
(ii-bis) Hub muet (rôle inconnu) → pas d'adoption, JIT membre ;
(iii) liaison existante (cas de production) → résolution
inchangée, quel que soit le rôle du registre ;
(iv) session native authentifiée → liaison explicite de
l'amorçage possible (pont et résolution directe) ;
plus la part Kanbante de la fusion #144 : l'échec du formulaire
local suggère la voie « Se connecter avec ObsiGate (SSO) ».
Le faux Hub suit le patron de tests/test_hub_roles.py : l'appel
``httpx.get`` du client du registre est simulé, les rôles sont
lus dans le cookie relayé.
"""
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 accounts, draco, hub_roles, models
SECRET = "secret-amorcage-145"
SUB_SIMPLE = "fus145-simple"
SUB_ADMIN = "fus145-admin"
SUB_INCONNU = "fus145-inconnu"
SUB_LIE = "fus145-deja-lie"
SUB_NATIF = "fus145-natif"
SUB_NATIF_DIRECT = "fus145-natif-direct"
def _jeton(sub, nom="Visiteur 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):
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``, lus dans le cookie relayé."""
etat = {"roles": {}, "mode": "ok"}
def faux_get(url, headers=None, timeout=None):
if etat["mode"] == "panne":
raise httpx.ConnectError("Hub injoignable (simulation)")
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
@pytest.fixture()
def instance_fraiche(monkeypatch, tmp_path, fake_gitea, faux_hub):
"""Instance neuve : base temporaire, amorçage seul et non lié.
Au démarrage (TestClient), ``ensure_bootstrap_admin`` crée
l'unique compte « proprio » (admin) depuis l'environnement —
c'est exactement l'état de l'audit : un seul compte, aucune
liaison draco.
"""
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "fraiche.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "mdp-amorce")
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
draco._revoked_cache.clear()
with TestClient(main.app) as client:
users = models.list_users()
assert len(users) == 1, users
assert users[0]["username"] == "proprio"
assert users[0]["role"] == "admin"
assert not users[0].get("draco_sub")
yield client, faux_hub, users[0]
draco._revoked_cache.clear()
def _pont(client, sub, nom="Visiteur Exemple"):
client.cookies.set("draco_session", _jeton(sub, nom))
return client.get("/auth/draco", follow_redirects=False)
def _profil_username(client) -> str:
r = client.get("/profile")
assert r.status_code == 200
return r.text.split("page-sub\">", 1)[1].split(" ·", 1)[0].strip()
def _csrf_via_login(client) -> str:
client.get("/login")
return client.cookies.get("kanbante_csrf") or ""
# ── (i) Simple utilisateur : JIT membre, amorçage intact ─────
def test_simple_utilisateur_jit_membre_amorcage_intact(instance_fraiche):
client, hub, amorce = instance_fraiche
hub["roles"][SUB_SIMPLE] = "utilisateur"
r = _pont(client, SUB_SIMPLE, "Dan Simple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert "kanbante_session" in r.headers.get("set-cookie", "")
# L'amorçage est intact : toujours là, admin, TOUJOURS sans liaison.
apres = models.get_user(amorce["id"])
assert apres["role"] == "admin"
assert not apres.get("draco_sub")
# Le visiteur a reçu SON compte : membre, distinct, lié à son sub.
cree = models.get_user_by_draco(SUB_SIMPLE)
assert cree is not None and cree["id"] != amorce["id"]
assert cree["role"] == "member"
assert models.count_users() == 2
assert _profil_username(client) == cree["username"]
# ── (ii) Administrateur au registre : adoption ───────────────
def test_administrateur_registre_adopte_amorcage(instance_fraiche):
client, hub, amorce = instance_fraiche
hub["roles"][SUB_ADMIN] = "administrateur"
r = _pont(client, SUB_ADMIN, "Chef Suite")
assert r.status_code == 303 and r.headers["location"] == "/"
# Aucun compte créé : le sub s'est lié au compte d'amorçage.
assert models.count_users() == 1
lie = models.get_user_by_draco(SUB_ADMIN)
assert lie is not None and lie["id"] == amorce["id"]
assert lie["role"] == "admin"
assert _profil_username(client) == "proprio"
def test_hub_muet_pas_d_adoption(instance_fraiche):
"""Repli propre : rôle inconnu (Hub injoignable) = pas d'adoption."""
client, hub, amorce = instance_fraiche
hub["mode"] = "panne"
r = _pont(client, SUB_INCONNU, "Inconnu Exemple")
assert r.status_code == 303 and r.headers["location"] == "/"
assert not models.get_user(amorce["id"]).get("draco_sub")
cree = models.get_user_by_draco(SUB_INCONNU)
assert cree is not None and cree["role"] == "member"
assert cree["id"] != amorce["id"]
# ── (iii) Liaison existante : résolution inchangée ───────────
def test_liaison_existante_resolution_inchangee(instance_fraiche):
client, hub, amorce = instance_fraiche
# Cas de production : l'amorçage est DÉJÀ lié (à Bruno).
models.update_user(amorce["id"], draco_sub=SUB_LIE)
# Même avec un rôle moindre au registre, la correspondance
# existante désigne son compte, comme avant le correctif.
hub["roles"][SUB_LIE] = "utilisateur"
claims = {"sub": SUB_LIE, "name": "Bruno"}
resolu = accounts.resolve_draco_user(claims)
assert resolu is not None and resolu["id"] == amorce["id"]
r = _pont(client, SUB_LIE, "Bruno")
assert r.status_code == 303 and r.headers["location"] == "/"
assert models.count_users() == 1
assert _profil_username(client) == "proprio"
# ── (iv) Session native authentifiée : liaison explicite ─────
def test_session_native_lie_explicitement_via_le_pont(instance_fraiche):
client, hub, amorce = instance_fraiche
hub["roles"][SUB_NATIF] = "utilisateur"
# Connexion native de l'amorçage (mot de passe du compte).
token = _csrf_via_login(client)
r = client.post(
"/login",
data={"username": "proprio", "password": "mdp-amorce",
"next": "/", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert "kanbante_session" in r.headers.get("set-cookie", "")
# Le passage du pont, session native ouverte, lie le sub libre
# au compte de la session — sans rôle administrateur au registre.
r = _pont(client, SUB_NATIF, "Proprio Lui-Même")
assert r.status_code == 303
lie = models.get_user_by_draco(SUB_NATIF)
assert lie is not None and lie["id"] == amorce["id"]
assert models.count_users() == 1
def test_resolution_directe_avec_session_native(instance_fraiche):
_client, _hub, amorce = instance_fraiche
claims = {"sub": SUB_NATIF_DIRECT, "name": "Proprio"}
# Sans drapeau : aucune adoption (résolution « à froid »).
assert accounts.resolve_draco_user(claims) is None
assert not models.get_user(amorce["id"]).get("draco_sub")
# Avec le drapeau « session native authentifiée » : adoption.
resolu = accounts.resolve_draco_user(
claims, liaison_session_native=True
)
assert resolu is not None and resolu["id"] == amorce["id"]
assert models.get_user_by_draco(SUB_NATIF_DIRECT)["id"] == amorce["id"]
# ── Fusion #144 : l'échec du formulaire suggère le SSO ───────
def test_echec_login_identifiant_sans_compte_local_suggere_sso(instance_fraiche):
client, _hub, _amorce = instance_fraiche
token = _csrf_via_login(client)
r = client.post(
"/login",
data={"username": "compte-du-hub", "password": "peu-importe",
"next": "/", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 401
assert "aucun compte local" in r.text
assert "Se connecter avec ObsiGate (SSO)" in r.text
assert "kanbante_session" not in r.headers.get("set-cookie", "")
def test_echec_login_mauvais_mot_de_passe_suggere_sso(instance_fraiche):
client, _hub, _amorce = instance_fraiche
token = _csrf_via_login(client)
r = client.post(
"/login",
data={"username": "proprio", "password": "mauvais",
"next": "/", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 401
assert "Identifiants incorrects" in r.text
assert "Se connecter avec ObsiGate (SSO)" in r.text
+16 -3
View File
@@ -4,8 +4,11 @@ Le pont SSO ne désigne plus « l'opérateur » : chaque identité
draco (claim ``sub``) désigne SON compte Kanbante, via la colonne
users.draco_sub. Couvert ici :
- amorçage : l'unique compte admin sans liaison se lie au
premier passage (le compte existant garde son accès) ;
- amorçage : l'unique compte admin sans liaison ne se lie plus
au premier passage — depuis la 0.46.5 (fusion #145), il faut
un rôle « administrateur » au registre du Hub (voir
tests/test_amorcage_pont.py pour la matrice complète) ; le
cas historique est couvert ici avec ce rôle simulé ;
- JIT : deux identités distinctes → deux comptes distincts,
rôle membre, mot de passe local non divulgué/inutilisable ;
- le JIT n'adopte jamais un compte préexistant non lié, même
@@ -129,7 +132,17 @@ def _unique_admin_sans_liaison():
# ── Amorçage ──────────────────────────────────────────────────
def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16):
def test_amorcage_unique_admin_se_lie_au_premier_passage(client_hub16, monkeypatch):
# Depuis la 0.46.5 (fusion #145), le premier passage ne lie
# l'amorçage que pour un « administrateur » du registre du
# Hub : c'est ce rôle qui est simulé ici. Sans lui, le même
# passage crée un compte membre JIT (test_amorcage_pont.py).
from app import hub_roles
monkeypatch.setattr(
hub_roles, "role_pour",
lambda cookie, sub, hote=None: "administrateur",
)
admin, pur = _unique_admin_sans_liaison()
if not pur:
pytest.skip("base partagée : le cas d'amorçage pur n'est pas en place")
+7 -2
View File
@@ -5,8 +5,13 @@ Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
GET /auth/draco. Depuis la 0.46.0 (HUB-16), la correspondance est
par utilisateur (users.draco_sub, création JIT) — voir
tests/test_draco_correspondance.py. Les cas couverts ici restent
valides : dans ce module, le seul compte est l'admin d'amorçage,
auquel le premier jeton valide se lie (cas d'amorçage documenté).
valides : dans ce module, le seul compte préexistant est l'admin
d'amorçage ; depuis la 0.46.5 (fusion #145), le premier jeton
valide ne s'y lie PLUS automatiquement (adoption conditionnée au
rôle « administrateur » du registre du Hub ou à une session
native) — le pont ouvre alors la session d'un compte JIT propre
à l'identité, ce que ces cas vérifient (session ouverte, accès
à l'app) sans présumer du compte résolu.
Sans DRACO_SESSION_SECRET, le pont renvoie simplement au login.
"""