FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 13s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / lint (pull_request) Failing after 12s
Généralisation de maybe_link : à la première connexion draco, si aucun compte local ne correspond au sub (ni lien draco_links, ni repli mono-utilisateur), le pendant local est créé depuis les claims (sub, name) et lié dans la même transaction. Rôle par défaut sûr : utilisateur standard (is_admin=0), auth_method 'draco', password_hash NULL (aucune connexion locale par mot de passe pour ce compte ; inchangée pour les autres). Garde pierre tombale : une ligne draco_links existante interdit toute création. Connexion locale et OAuth Gitea/GitHub intacts. Tests : test_fusion_draco.py étendu (création JIT, pierre tombale) ; 9/9 sur le fichier, balayage auth 226 réussis (3 échecs préexistants sur main vierge, réseau).
178 lines
7.2 KiB
Python
178 lines
7.2 KiB
Python
"""Fusion ACC-3.2 — validation locale draco_session et pont de session.
|
|
|
|
Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
|
|
(secret partagé, HS256), résolu vers le compte Flowdeck
|
|
(draco_links, migration 51 ; repli mono-utilisateur), et converti en
|
|
session native par GET /auth/draco. Sans DRACO_SESSION_SECRET, le
|
|
pont renvoie simplement à la connexion locale. HUB-15 ajoute le
|
|
provisionnement JIT : aucun compte correspondant → le pendant local
|
|
est créé depuis les claims (rôle standard, jamais admin, sans mot
|
|
de passe local), sauf pierre tombale de dissociation.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from app.db import get_conn
|
|
from app.services import draco
|
|
|
|
SECRET = "secret-partage-de-test"
|
|
|
|
|
|
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test", 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)}"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _env(monkeypatch):
|
|
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
|
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
|
draco._revoked_cache.clear()
|
|
yield
|
|
draco._revoked_cache.clear()
|
|
|
|
|
|
def test_verify_token():
|
|
assert draco.verify_token(_jeton())["sub"] == "admin-1"
|
|
assert draco.verify_token(_jeton(secret="mauvais")) is None
|
|
assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None
|
|
assert draco.verify_token("pas.un.jeton") is None
|
|
|
|
|
|
def test_pont_sans_cookie_vers_login(client):
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.status_code == 302
|
|
assert r.headers["location"] == "/auth/login?provider=local"
|
|
|
|
|
|
def test_pont_cookie_valide_cree_session(client):
|
|
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"
|
|
assert "flowdeck_session" in r.headers.get("set-cookie", "")
|
|
me = client.get("/auth/user")
|
|
assert me.json()["authenticated"] is True
|
|
|
|
|
|
def test_pont_cookie_falsifie_vers_login(client):
|
|
client.cookies.set("draco_session", _jeton(secret="mauvais"))
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/auth/login?provider=local"
|
|
|
|
|
|
def test_pont_inerte_sans_secret(client, monkeypatch):
|
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
|
client.cookies.set("draco_session", _jeton())
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/auth/login?provider=local"
|
|
|
|
|
|
def test_lien_automatique_et_gestion(client):
|
|
client.cookies.set("draco_session", _jeton(sub="og-bruno-1"))
|
|
client.get("/auth/user") # visite doublement connectée
|
|
r = client.get("/auth/draco-link")
|
|
assert r.json()["sub"] == "og-bruno-1"
|
|
r = client.delete("/auth/draco-link")
|
|
assert r.json()["ok"] is True
|
|
assert client.get("/auth/draco-link").json()["sub"] is None
|
|
client.get("/auth/user") # ne recrée pas le lien
|
|
assert client.get("/auth/draco-link").json()["sub"] is None
|
|
r = client.post("/auth/draco-link") # ré-association explicite
|
|
assert r.json()["sub"] == "og-bruno-1"
|
|
|
|
|
|
def test_lien_requis_en_multi_utilisateurs(client):
|
|
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()
|
|
# Le lien explicite (créé pendant une visite connectée) ouvre la porte.
|
|
client.cookies.set("draco_session", _jeton(sub="og-bruno-1"))
|
|
assert client.post("/auth/draco-link").json()["sub"] == "og-bruno-1"
|
|
client.cookies.delete("flowdeck_session")
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/workspaces"
|
|
|
|
|
|
# ── HUB-15 : provisionnement JIT ─────────────────────────────────────
|
|
|
|
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 _compte_utilisateurs():
|
|
with get_conn() as conn:
|
|
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
|
|
|
|
|
def test_jit_cree_le_pendant_local_en_multi_utilisateurs(client):
|
|
_ajoute_deuxieme_utilisateur()
|
|
avant = _compte_utilisateurs()
|
|
# Sub inconnu, sans lien, en multi-utilisateurs : avant HUB-15 le
|
|
# pont refusait ; désormais le pendant local est créé en JIT.
|
|
client.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/workspaces"
|
|
assert _compte_utilisateurs() == avant + 1
|
|
with get_conn() as conn:
|
|
user = conn.execute(
|
|
"SELECT * FROM users WHERE login = 'draco-og-nouveau'").fetchone()
|
|
assert user is not None
|
|
assert user["full_name"] == "Nouvelle"
|
|
assert user["is_admin"] == 0 # jamais admin
|
|
assert user["is_active"] == 1
|
|
assert user["password_hash"] is None # aucun mot de passe local
|
|
assert user["auth_method"] == "draco"
|
|
lien = conn.execute(
|
|
"SELECT * FROM draco_links WHERE sub = 'og-nouveau'").fetchone()
|
|
assert lien is not None and lien["user_id"] == user["id"]
|
|
assert lien["unlinked_at"] is None
|
|
# Deuxième arrivée : même compte réutilisé, aucune création de plus.
|
|
client.cookies.delete("flowdeck_session")
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/workspaces"
|
|
assert _compte_utilisateurs() == avant + 1
|
|
# Le compte JIT ne peut pas se connecter par mot de passe local.
|
|
r = client.post("/auth/local-login",
|
|
json={"email": "draco-og-nouveau", "password": "nimporte"})
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_jit_pierre_tombale_sans_recreation(client):
|
|
_ajoute_deuxieme_utilisateur()
|
|
client.cookies.set("draco_session", _jeton(sub="og-ephemere"))
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/workspaces" # compte JIT créé
|
|
# La session courante est celle du compte JIT : dissociation.
|
|
r = client.delete("/auth/draco-link")
|
|
assert r.json()["ok"] is True
|
|
apres_creation = _compte_utilisateurs()
|
|
# Nouvelle arrivée du même sub : la pierre tombale interdit toute
|
|
# re-création automatique — retour à la connexion locale.
|
|
client.cookies.delete("flowdeck_session")
|
|
r = client.get("/auth/draco", follow_redirects=False)
|
|
assert r.headers["location"] == "/auth/login?provider=local"
|
|
assert _compte_utilisateurs() == apres_creation
|