"""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()