diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d03851..d23bb30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## v7.85.6 (2026-11-06) — Porte centrale unique (phase 2 du programme « compte partout »), désactivée par défaut + +> Branche basée sur la 7.85.5 (phase 1, #146/#144) — PR distincte, à fusionner après elle. + +- **Nouveau réglage « porte centrale »** (`porte_centrale_active` dans `app/services/reglages_acces.py`, même patron que le réglage d'inscription de la phase 1, **distinct** de lui, **désactivé par défaut à la livraison** ; activable/désactivable par un administrateur via `GET`/`PUT /api/admin/reglages-acces` ou la variable `PORTE_CENTRALE_ACTIVE`). +- **Mode actif** : l'écran de connexion ne présente que l'entrée centrale — le bouton « Login with ObsiGate (SSO) », bien visible, et le lien vers le Hub Dracodev où les comptes se créent (URL dérivée de l'hôte). `POST /auth/local-login` refuse les identifiants locaux ordinaires (403, message nommant la voie SSO), **y compris pour les comptes locaux historiques** ; l'inscription est également fermée dans ce mode. Les sessions déjà établies ne sont pas touchées, et la voie draco (SSO/JIT) reste la porte normale. +- **Porte de secours administrateur** : UN compte administrateur local désigné peut encore s'authentifier localement, uniquement par le chemin discret `GET /auth/login?provider=local&secours=1` (le formulaire y marque ses envois `secours` ; jamais proposé sur l'écran principal). Ses identifiants sont posés par les variables d'environnement `FLOWDECK_SECOURS_ADMIN_LOGIN` et `FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE` (comparaison à temps constant ; le compte local correspondant doit exister, être actif et administrateur ; aucun secret en dur, aucune valeur par défaut — sans variables, la porte est fermée). +- **Mode inactif** : comportement actuel intégralement préservé (formulaire local, inscription selon le réglage de la phase 1). +- **Tests** : `tests/test_porte_centrale.py` (nouveau, 15 tests) — défaut désactivé vérifié dans le code, réglage distinct de celui d'inscription, refus du compte ordinaire et du compte historique en mode actif, écran réduit à l'entrée centrale (aucun formulaire, aucun « secours »), inscription fermée en mode actif, session établie préservée, secours : écran discret, connexion admin réussie, mauvais mot de passe refusé, absence de drapeau refusée, compte non-admin désigné refusé, absence de variables = porte fermée ; mode inactif = comportement actuel ; aller-retour de l'API d'administration. + ## v7.85.5 (2026-11-06) — Inscription locale fermée par défaut + échec de connexion qui nomme la voie SSO (fusion #146, #144) - **#146 — l'inscription publique locale n'est plus ouverte par défaut** : nouveau réglage d'administration « inscription locale ouverte » (`app/services/reglages_acces.py`, patron `llm_config`/`sso_config` : ligne unique en base qui prime sur la variable `INSCRIPTION_LOCALE_OUVERTE`, défaut **fermée** — décision de Bruno : les comptes naissent dans le Hub Dracodev). Réversible à tout moment par un administrateur via `GET`/`PUT /api/admin/reglages-acces`. diff --git a/VERSION b/VERSION index 4876892..582007e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.85.5 +7.85.6 diff --git a/app/config.py b/app/config.py index 9662447..8f512fd 100644 --- a/app/config.py +++ b/app/config.py @@ -52,6 +52,18 @@ class Settings(BaseSettings): # Inscription publique locale : fermée par défaut (décision de # Bruno — les comptes naissent dans le Hub Dracodev). inscription_locale_ouverte: bool = False + # Porte centrale unique (phase 2 du programme « compte partout ») : + # désactivée par défaut à la livraison ; même patron de réglage + # (la ligne en base prime). Activée, seuls la voie ObsiGate (SSO) + # et la porte de secours administrateur ouvrent une session. + porte_centrale_active: bool = False + # Porte de secours administrateur (porte centrale) — repli des + # variables d'environnement FLOWDECK_SECOURS_ADMIN_LOGIN / + # FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE, lues en priorité par + # `app/services/reglages_acces.py`. Aucune valeur par défaut : + # sans variables posées, la porte de secours est fermée. + secours_admin_login: str = "" + secours_admin_mot_de_passe: str = "" # Rate limiting rate_limit_enabled: bool = True diff --git a/app/routers/admin.py b/app/routers/admin.py index ed88c44..bb8729e 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -158,15 +158,23 @@ def get_reglages_acces(_admin=Depends(admin_required)): def put_reglages_acces(_admin=Depends(admin_required), body: dict = Body(default={})): """Modifie les réglages d'accès — réversible à tout moment. - Corps accepté : ``{"inscription_locale_ouverte": true|false}``. - Fermée (défaut) : aucun compte local ne se crée sur l'instance, - les comptes naissent dans le Hub Dracodev. La connexion des - comptes existants n'est jamais affectée. + Corps accepté : ``{"inscription_locale_ouverte": true|false, + "porte_centrale_active": true|false}`` (chaque clé optionnelle). + Inscription fermée (défaut) : aucun compte local ne se crée sur + l'instance, les comptes naissent dans le Hub Dracodev. + Porte centrale (défaut : inactive) : activée, seuls la voie + ObsiGate (SSO) et la porte de secours administrateur ouvrent + une session. """ from app.services import reglages_acces + kwargs = {} if "inscription_locale_ouverte" in body: - return reglages_acces.set_reglages_acces( - inscription_locale_ouverte=bool(body["inscription_locale_ouverte"])) + kwargs["inscription_locale_ouverte"] = bool( + body["inscription_locale_ouverte"]) + if "porte_centrale_active" in body: + kwargs["porte_centrale_active"] = bool(body["porte_centrale_active"]) + if kwargs: + return reglages_acces.set_reglages_acces(**kwargs) return reglages_acces.get_reglages_acces() diff --git a/app/routers/auth.py b/app/routers/auth.py index 44df0a8..e1e4669 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -94,15 +94,64 @@ body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont """ -def _rendre_page_connexion(html: str) -> str: - """Rend la page de connexion en tenant compte du réglage #146. +def _page_porte_centrale() -> str: + """Écran de connexion en mode porte centrale (phase 2). - Inscription fermée : l'onglet Register disparaît et un avis nomme - le Hub Dracodev (lien dérivé de l'hôte, comme les autres liens de - la suite). Inscription ouverte : page intégralement inchangée. + Ne présente **que** l'entrée centrale : le bouton ObsiGate (SSO), + bien visible. Aucun formulaire local, aucun onglet Register — + la porte de secours administrateur passe par le chemin discret + ``?secours=1``, jamais proposé ici. + """ + from app.services import draco_urls + + hub = draco_urls.hub_url() + return """ + + + + +FlowDeck — Connexion + + + +
+

FlowDeck

+

Cette instance n'accepte que la connexion centrale de la suite + Dracodev. Les comptes se créent dans le + Hub Dracodev.

+ 🧭 Login with ObsiGate (SSO) +
+ +""".replace("__HUB_URL__", hub) + + +def _rendre_page_connexion(html: str, secours: bool = False) -> str: + """Rend la page de connexion en tenant compte des réglages d'accès. + + Porte centrale active (phase 2) : seul l'écran central est rendu + — sauf par le chemin discret de secours (``secours=True``), qui + rend le formulaire local en marquant ses envois ``secours``. + Inscription fermée (#146) : l'onglet Register disparaît et un + avis nomme le Hub Dracodev (lien dérivé de l'hôte, comme les + autres liens de la suite). Sinon : page intégralement inchangée. """ from app.services import draco_urls, reglages_acces + if reglages_acces.porte_centrale_active(): + if not secours: + return _with_nonce(_page_porte_centrale()) + html = html.replace( + "const body={email,password};", + "const body={email,password,secours:true};") + return _with_nonce(html) if reglages_acces.inscription_locale_ouverte(): return _with_nonce(html) html = html.replace( @@ -257,6 +306,9 @@ def register_page(request: Request): """ from app.services import reglages_acces + if reglages_acces.porte_centrale_active(): + return HTMLResponse(_with_nonce(_page_porte_centrale()), + status_code=403) if not reglages_acces.inscription_locale_ouverte(): return HTMLResponse(_page_inscription_fermee(), status_code=403) return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace( @@ -283,7 +335,13 @@ def login(request: Request, provider: str = Query("gitea")): # Local login page (POST handled by /auth/local-login) from fastapi.responses import HTMLResponse if provider == "local": - return HTMLResponse(_rendre_page_connexion(LOCAL_LOGIN_HTML), status_code=200) + # Chemin discret de la porte de secours (phase 2) : + # ?secours=1 rend le formulaire local même en mode porte + # centrale — jamais lié depuis l'écran principal. + secours = request.query_params.get("secours") == "1" + return HTMLResponse( + _rendre_page_connexion(LOCAL_LOGIN_HTML, secours=secours), + status_code=200) # OAuth flow — check if provider is configured from app.auth.providers import get_provider @@ -341,6 +399,13 @@ def register(request: Request, body: dict = Body(default={})): # naissent dans le Hub Dracodev — aucun compte local ne se crée # ici. La connexion des comptes existants n'est pas touchée. from app.services import draco_urls, reglages_acces + if reglages_acces.porte_centrale_active(): + from fastapi.responses import JSONResponse + return JSONResponse( + {"error": reglages_acces.message_porte_centrale(), + "hub_url": draco_urls.hub_url()}, + status_code=403, + ) if not reglages_acces.inscription_locale_ouverte(): from fastapi.responses import JSONResponse return JSONResponse( @@ -400,6 +465,34 @@ def local_login(request: Request, body: dict = Body(default={})): if not email or not password: return JSONResponse({"error": "Email and password required"}, status_code=400) + # Porte centrale unique (phase 2) : les identifiants locaux + # ordinaires ne sont plus acceptés — comptes historiques inclus. + # Seule exception : la porte de secours administrateur, par le + # drapeau explicite `secours` (posé par le chemin discret) et les + # identifiants des variables d'environnement. Les sessions déjà + # établies ne passent pas par ici et ne sont pas touchées. + from app.services import reglages_acces as _reglages_acces + if _reglages_acces.porte_centrale_active(): + if not body.get("secours"): + return JSONResponse( + {"error": _reglages_acces.message_porte_centrale()}, + status_code=403, + ) + compte_secours = _reglages_acces.authentifier_secours(email, password) + if compte_secours is None: + return JSONResponse( + {"error": "Porte de secours : identifiants invalides."}, + status_code=401, + ) + session = SessionManager.create_session(compte_secours, request) + _log_login(compte_secours["id"], request) + response = JSONResponse({"status": "ok", "user": { + "login": compte_secours["login"], + "name": compte_secours["full_name"]}}) + response.set_cookie("flowdeck_session", session, httponly=True, + max_age=86400 * 7, samesite="lax", path="/") + return response + with get_conn() as conn: user = conn.execute("SELECT * FROM users WHERE login=?", (email,)).fetchone() if not user: diff --git a/app/services/reglages_acces.py b/app/services/reglages_acces.py index 680efbb..e745cff 100644 --- a/app/services/reglages_acces.py +++ b/app/services/reglages_acces.py @@ -1,4 +1,5 @@ -"""Réglages d'accès de l'instance — inscription locale (fusion #146). +"""Réglages d'accès de l'instance — inscription locale (fusion #146) +et porte centrale unique (phase 2 du programme « compte partout »). Patron des réglages existants (``llm_config``, ``sso_config``) : une table à ligne unique (``id = 1``) créée paresseusement, qui prime sur @@ -16,12 +17,32 @@ Réglage « inscription locale ouverte » : ``INSCRIPTION_LOCALE_OUVERTE=true`` avant tout enregistrement en base. -Ce réglage ne touche **jamais** la connexion des comptes existants, -locaux comme draco : il ne garde que la création de comptes locaux. +Le réglage d'inscription ne touche **jamais** la connexion des +comptes existants, locaux comme draco : il ne garde que la création +de comptes locaux. + +Réglage « porte centrale » (phase 2, **distinct** du précédent) : + +- **désactivé par défaut à la livraison** ; activé par un + administrateur, l'écran de connexion ne présente que l'entrée + centrale (bouton ObsiGate SSO) et les identifiants locaux + ordinaires ne sont plus acceptés — y compris pour les comptes + locaux historiques. Les sessions déjà établies ne sont pas + touchées, et la voie draco (SSO/JIT) reste la porte normale. +- **Porte de secours administrateur** : UN compte administrateur + local désigné peut encore s'authentifier localement, uniquement + par le chemin discret ``/auth/login?provider=local&secours=1`` + (jamais proposé sur l'écran principal). Ses identifiants sont + posés par les variables d'environnement + ``FLOWDECK_SECOURS_ADMIN_LOGIN`` et + ``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE`` — aucun secret en dur ; + sans ces variables, la porte de secours est fermée. """ from __future__ import annotations import logging +import os +import secrets from app.config import settings @@ -32,18 +53,27 @@ __all__ = [ "set_reglages_acces", "inscription_locale_ouverte", "message_inscription_fermee", + "porte_centrale_active", + "message_porte_centrale", + "authentifier_secours", ] def _assurer_table(conn) -> None: - """Crée la table des réglages d'accès si elle n'existe pas encore.""" + """Crée la table des réglages d'accès si elle n'existe pas encore + et y ajoute les colonnes des phases ultérieures si besoin.""" conn.execute(""" CREATE TABLE IF NOT EXISTS reglages_acces ( id INTEGER PRIMARY KEY CHECK (id = 1), inscription_locale_ouverte INTEGER, + porte_centrale_active INTEGER, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + colonnes = {r[1] for r in conn.execute("PRAGMA table_info(reglages_acces)")} + if "porte_centrale_active" not in colonnes: + conn.execute( + "ALTER TABLE reglages_acces ADD COLUMN porte_centrale_active INTEGER") def get_reglages_acces() -> dict: @@ -52,6 +82,9 @@ def get_reglages_acces() -> dict: # Défaut du code et de la config : inscription fermée. "inscription_locale_ouverte": bool( getattr(settings, "inscription_locale_ouverte", False)), + # Défaut du code et de la config : porte centrale désactivée. + "porte_centrale_active": bool( + getattr(settings, "porte_centrale_active", False)), } try: from app.db import get_conn @@ -59,33 +92,43 @@ def get_reglages_acces() -> dict: with get_conn() as conn: _assurer_table(conn) row = conn.execute( - "SELECT inscription_locale_ouverte FROM reglages_acces" - " WHERE id=1" + "SELECT inscription_locale_ouverte, porte_centrale_active" + " FROM reglages_acces WHERE id=1" ).fetchone() except Exception: # noqa: BLE001 — base pas encore prête → config seule return reglages - if row is not None and row["inscription_locale_ouverte"] is not None: - reglages["inscription_locale_ouverte"] = bool( - row["inscription_locale_ouverte"]) + if row is not None: + if row["inscription_locale_ouverte"] is not None: + reglages["inscription_locale_ouverte"] = bool( + row["inscription_locale_ouverte"]) + if row["porte_centrale_active"] is not None: + reglages["porte_centrale_active"] = bool( + row["porte_centrale_active"]) return reglages -def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None) -> dict: +def set_reglages_acces(*, inscription_locale_ouverte: bool | None = None, + porte_centrale_active: bool | None = None) -> dict: """Enregistre les réglages d'accès (ligne unique) et les renvoie.""" from app.db import get_conn actuels = get_reglages_acces() if inscription_locale_ouverte is not None: actuels["inscription_locale_ouverte"] = bool(inscription_locale_ouverte) + if porte_centrale_active is not None: + actuels["porte_centrale_active"] = bool(porte_centrale_active) with get_conn() as conn: _assurer_table(conn) conn.execute( "INSERT INTO reglages_acces (id, inscription_locale_ouverte," - " updated_at) VALUES (1, ?, CURRENT_TIMESTAMP)" + " porte_centrale_active, updated_at) VALUES (1, ?, ?," + " CURRENT_TIMESTAMP)" " ON CONFLICT(id) DO UPDATE SET" " inscription_locale_ouverte=excluded.inscription_locale_ouverte," + " porte_centrale_active=excluded.porte_centrale_active," " updated_at=CURRENT_TIMESTAMP", - (1 if actuels["inscription_locale_ouverte"] else 0,), + (1 if actuels["inscription_locale_ouverte"] else 0, + 1 if actuels["porte_centrale_active"] else 0), ) conn.commit() return actuels @@ -96,6 +139,65 @@ def inscription_locale_ouverte() -> bool: return bool(get_reglages_acces()["inscription_locale_ouverte"]) +def porte_centrale_active() -> bool: + """Vrai si la porte centrale unique est active sur l'instance.""" + return bool(get_reglages_acces()["porte_centrale_active"]) + + +def message_porte_centrale() -> str: + """Message affiché quand la porte centrale refuse le local.""" + from app.services import draco_urls + + return ( + "La porte centrale est active sur cette instance : " + "connectez-vous avec le bouton « Login with ObsiGate (SSO) » " + "— les comptes se créent dans le Hub Dracodev " + f"({draco_urls.hub_url()})." + ) + + +def _secours_identifiants() -> tuple[str, str]: + """Identifiants de la porte de secours — variables d'environnement + en priorité, repli sur la configuration. Jamais de valeur par + défaut : vide = porte de secours fermée.""" + login = (os.environ.get("FLOWDECK_SECOURS_ADMIN_LOGIN", "") + or getattr(settings, "secours_admin_login", "") or "").strip() + mot_de_passe = (os.environ.get("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "") + or getattr(settings, "secours_admin_mot_de_passe", "") or "") + return login, mot_de_passe + + +def authentifier_secours(login: str, mot_de_passe: str) -> dict | None: + """Authentifie le compte administrateur de secours désigné. + + Les identifiants fournis doivent correspondre **exactement** aux + variables d'environnement de la porte de secours (comparaison à + temps constant), et le compte local correspondant doit exister, + être actif et être administrateur. Renvoie le compte (dict) ou + ``None`` — sans jamais dire quelle condition a échoué. + """ + attendu_login, attendu_mdp = _secours_identifiants() + if not attendu_login or not attendu_mdp: + return None + if not secrets.compare_digest((login or "").strip(), attendu_login): + return None + if not secrets.compare_digest(mot_de_passe or "", attendu_mdp): + return None + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM users WHERE login=? OR email=?", + (attendu_login, attendu_login), + ).fetchone() + if row is None: + return None + compte = dict(row) + if not compte.get("is_admin") or not compte.get("is_active"): + return None + return compte + + def message_inscription_fermee() -> str: """Message affiché quand l'inscription locale est fermée (#146). diff --git a/tests/test_porte_centrale.py b/tests/test_porte_centrale.py new file mode 100644 index 0000000..744d3df --- /dev/null +++ b/tests/test_porte_centrale.py @@ -0,0 +1,222 @@ +"""FlowDeck — porte centrale unique (phase 2, programme « compte +partout »), basée sur les réglages d'accès de la phase 1 (#146). + +Contrat : + +- réglage « porte centrale » **distinct** de celui d'inscription, + **désactivé par défaut** à la livraison ; +- mode actif : l'écran de connexion ne présente que l'entrée + centrale (bouton ObsiGate SSO) et les identifiants locaux + ordinaires sont refusés — comptes historiques inclus ; les + sessions déjà établies ne sont pas touchées ; +- **porte de secours** : UN compte administrateur local désigné, + identifiants posés par les variables d'environnement + ``FLOWDECK_SECOURS_ADMIN_LOGIN`` / + ``FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE``, authentification locale + uniquement par le chemin discret ``?secours=1`` (drapeau + ``secours`` à l'envoi) — jamais proposé sur l'écran principal ; +- mode inactif : comportement actuel intégralement préservé. +""" +from __future__ import annotations + +import pytest +from conftest import anon_csrf + +from app.services import reglages_acces + +LOGIN_SECOURS = "admin-secours" +MDP_SECOURS = "mot-de-passe-de-secours-designe" + + +def _activer(): + reglages_acces.set_reglages_acces(porte_centrale_active=True) + + +def _desactiver(): + reglages_acces.set_reglages_acces(porte_centrale_active=False) + + +def _creer_compte_local(login: str, mot_de_passe: str, admin: int = 0) -> None: + from app.db import get_conn + from app.password_utils import hash_password + + with get_conn() as conn: + conn.execute( + "INSERT OR REPLACE INTO users (login, full_name, email," + " password_hash, is_admin) VALUES (?, ?, ?, ?, ?)", + (login, login, f"{login}@test.dev", + hash_password(mot_de_passe), admin), + ) + conn.commit() + + +@pytest.fixture +def secours_env(monkeypatch): + """Pose les variables d'environnement de la porte de secours.""" + monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", LOGIN_SECOURS) + monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", MDP_SECOURS) + _creer_compte_local(LOGIN_SECOURS, "ancien-mot-de-passe-local", admin=1) + return monkeypatch + + +# ── Défaut et distinction des réglages ────────────────────────────── + +def test_porte_centrale_desactivee_par_defaut_dans_le_code(): + from app.config import settings + + assert settings.porte_centrale_active is False + + +def test_reglage_distinct_de_celui_d_inscription(client): + reglages_acces.set_reglages_acces( + inscription_locale_ouverte=True, porte_centrale_active=False) + reglages = reglages_acces.get_reglages_acces() + assert reglages["inscription_locale_ouverte"] is True + assert reglages["porte_centrale_active"] is False + + +# ── Mode actif : le local ordinaire est refusé ────────────────────── + +def test_mode_actif_refuse_compte_ordinaire(client): + _creer_compte_local("ordinaire", "secret123") + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": "ordinaire", "password": "secret123"}) + assert r.status_code == 403 + assert "ObsiGate (SSO)" in r.json()["error"] + + +def test_mode_actif_refuse_compte_local_historique(client): + """Compte créé par inscription publique (quand elle était + ouverte), puis porte activée : son mot de passe local ne passe + plus.""" + reglages_acces.set_reglages_acces( + inscription_locale_ouverte=True, porte_centrale_active=False) + r = client.post("/auth/register", json={ + "email": "historique@test.dev", "password": "secret123", + "name": "Historique"}) + assert r.status_code == 200 + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": "historique@test.dev", "password": "secret123"}) + assert r.status_code == 403 + + +def test_mode_actif_ecran_connexion_que_l_entree_centrale(client): + _activer() + r = anon_csrf(client).get("/auth/login?provider=local") + assert r.status_code == 200 + # L'entrée centrale, bien visible… + assert "Login with ObsiGate (SSO)" in r.text + # … et rien d'autre : ni formulaire local, ni onglet Register, + # ni lien vers le chemin discret de secours. + assert 'id="login-form"' not in r.text + assert 'id="password"' not in r.text + assert 'id="tab-register"' not in r.text + assert "secours" not in r.text + + +def test_mode_actif_inscription_aussi_fermee(client): + reglages_acces.set_reglages_acces(inscription_locale_ouverte=True) + _activer() + r = client.post("/auth/register", json={ + "email": "visiteur@test.dev", "password": "secret123"}) + assert r.status_code == 403 + + +def test_mode_actif_session_deja_etablie_non_touchee(client): + _creer_compte_local("deja-dedans", "secret123") + c = anon_csrf(client) + r = c.post("/auth/local-login", json={ + "email": "deja-dedans", "password": "secret123"}) + assert r.status_code == 200 + _activer() + r = c.get("/auth/user") + assert r.json()["authenticated"] is True + + +# ── Porte de secours administrateur ───────────────────────────────── + +def test_secours_connexion_admin_par_le_chemin_discret(client, secours_env): + _activer() + # L'écran discret rend le formulaire, marqué « secours ». + r = anon_csrf(client).get("/auth/login?provider=local&secours=1") + assert r.status_code == 200 + assert 'id="login-form"' in r.text + assert "secours:true" in r.text + # L'authentification elle-même : identifiants d'environnement. + r = anon_csrf(client).post("/auth/local-login", json={ + "email": LOGIN_SECOURS, "password": MDP_SECOURS, + "secours": True}) + assert r.status_code == 200 + assert r.json()["status"] == "ok" + assert "flowdeck_session" in r.headers.get("set-cookie", "") + + +def test_secours_mauvais_mot_de_passe_environnement_refuse(client, secours_env): + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": LOGIN_SECOURS, "password": "pas-le-bon", + "secours": True}) + assert r.status_code == 401 + + +def test_secours_sans_drapeau_refuse_meme_pour_l_admin(client, secours_env): + """Sans le drapeau du chemin discret, même les identifiants de + secours sont traités comme un envoi ordinaire : refusés.""" + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": LOGIN_SECOURS, "password": MDP_SECOURS}) + assert r.status_code == 403 + + +def test_secours_compte_non_admin_refuse(client, secours_env, monkeypatch): + """Le compte désigné doit être administrateur : un compte + ordinaire désigné par les variables ne passe pas.""" + _creer_compte_local("pas-admin", "secret123", admin=0) + monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_LOGIN", "pas-admin") + monkeypatch.setenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", "secret123") + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": "pas-admin", "password": "secret123", "secours": True}) + assert r.status_code == 401 + + +def test_secours_sans_variables_environnement_fermee(client, monkeypatch): + monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_LOGIN", raising=False) + monkeypatch.delenv("FLOWDECK_SECOURS_ADMIN_MOT_DE_PASSE", raising=False) + _creer_compte_local("admin-local", "secret123", admin=1) + _activer() + r = anon_csrf(client).post("/auth/local-login", json={ + "email": "admin-local", "password": "secret123", "secours": True}) + assert r.status_code == 401 + + +# ── Mode inactif : comportement actuel intégralement préservé ─────── + +def test_mode_inactif_connexion_locale_comme_avant(client): + _desactiver() + _creer_compte_local("normal", "secret123") + r = anon_csrf(client).post("/auth/local-login", json={ + "email": "normal", "password": "secret123"}) + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_mode_inactif_ecran_connexion_complet(client): + _desactiver() + r = anon_csrf(client).get("/auth/login?provider=local") + assert r.status_code == 200 + assert 'id="login-form"' in r.text + assert "Login with ObsiGate (SSO)" in r.text + + +def test_api_admin_aller_retour_porte_centrale(client): + _desactiver() + r = client.put("/api/admin/reglages-acces", + json={"porte_centrale_active": True}) + assert r.status_code == 200 + assert r.json()["porte_centrale_active"] is True + r = client.get("/api/admin/reglages-acces") + assert r.json()["porte_centrale_active"] is True + _desactiver()