Compare commits

...
Author SHA1 Message Date
Spark cf2e39e15f Flowdeck 7.85.6 — porte centrale unique (phase 2) : écran réduit à l'entrée ObsiGate SSO + porte de secours administrateur par variables d'environnement, désactivée par défaut
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-11-07 04:52:51 +00:00
7 changed files with 472 additions and 25 deletions
+10
View File
@@ -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`.
+1 -1
View File
@@ -1 +1 @@
7.85.5
7.85.6
+12
View File
@@ -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
+14 -6
View File
@@ -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()
+99 -6
View File
@@ -94,15 +94,64 @@ body{{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont
</html>"""
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 """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — Connexion</title>
<style>
body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
.box{background:#222;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:40px;width:100%;max-width:400px;}
.box h1{font-size:24px;margin:0 0 8px;}
.box p{color:rgba(255,255,255,.55);font-size:14px;line-height:1.6;margin:0 0 20px;}
.box a{color:#2383E2;}
.sso{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:14px;border-radius:8px;font-size:15px;font-weight:500;cursor:pointer;border:1px solid rgba(35,131,226,.6);background:#2383E2;color:#fff;text-decoration:none;box-sizing:border-box;}
.sso:hover{background:#2C8CEB;}
</style>
</head>
<body>
<div class="box">
<h1>FlowDeck</h1>
<p>Cette instance n'accepte que la connexion centrale de la suite
Dracodev. Les comptes se créent dans le
<a href="__HUB_URL__">Hub Dracodev</a>.</p>
<a class="sso" href="__SSO_LOGIN_URL__">🧭 Login with ObsiGate (SSO)</a>
</div>
</body>
</html>""".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:
+114 -12
View File
@@ -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).
+222
View File
@@ -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": "[email protected]", "password": "secret123",
"name": "Historique"})
assert r.status_code == 200
_activer()
r = anon_csrf(client).post("/auth/local-login", json={
"email": "[email protected]", "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": "[email protected]", "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()