From e4531488d0356ca644e61d0530231968f203a943 Mon Sep 17 00:00:00 2001 From: Spark Date: Sat, 7 Nov 2026 04:19:08 +0000 Subject: [PATCH] =?UTF-8?q?Kanbante=200.46.6=20=E2=80=94=20porte=20central?= =?UTF-8?q?e=20unique=20:=20r=C3=A9glage=20administrateur,=20=C3=A9cran=20?= =?UTF-8?q?r=C3=A9duit=20=C3=A0=20l'entr=C3=A9e=20SSO=20et=20porte=20de=20?= =?UTF-8?q?secours=20discr=C3=A8te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++ CHANGELOG.md | 10 ++ README.md | 2 + VERSION | 2 +- app/config.py | 29 +++++ app/main.py | 148 +++++++++++++++++++++--- app/templates/login.html | 24 ++++ docs/porte-centrale.md | 81 +++++++++++++ tests/test_porte_centrale.py | 216 +++++++++++++++++++++++++++++++++++ 9 files changed, 504 insertions(+), 16 deletions(-) create mode 100644 docs/porte-centrale.md create mode 100644 tests/test_porte_centrale.py diff --git a/.env.example b/.env.example index ba139b4..f05c026 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,14 @@ GITEA_TOKEN= KANBANTE_AUTH_USER= KANBANTE_AUTH_PASSWORD= KANBANTE_REQUIRE_AUTH=0 +# Porte centrale unique (désactivée par défaut ; activable aussi au +# panneau d'administration, réglage « porte_centrale ») +KANBANTE_PORTE_CENTRALE=0 +# Porte de secours administrateur (chemin discret /login/secours, +# jamais proposé sur l'écran principal) — compte administrateur +# local désigné, identifiants d'environnement uniquement : +# KANBANTE_PORTE_SECOURS_USER= +# KANBANTE_PORTE_SECOURS_PASSWORD= # Noms publics dracodev — défauts = noms OFFICIELS (prod). # Environnement dev : déclarer ici les noms en -dev, sans toucher au code. KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net diff --git a/CHANGELOG.md b/CHANGELOG.md index cf00d2d..073102c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.46.6 — 2026-11-06 — Porte centrale unique (programme « compte partout », phase 2 — basée sur la 0.46.5) + +Version **fonctionnelle** dans la série 0.46.x (base : branche 0.46.5, fusion #145/#144 ; la 0.47.0 de la PR #87 reste indépendante et non touchée). **Désactivée par défaut à la livraison** : sans activation explicite d'un administrateur, le comportement est exactement celui de la 0.46.5. + +- **Nouveau réglage `porte_centrale`** (booléen, `app/config.py`) : amorçable par l'environnement (`KANBANTE_PORTE_CENTRALE=1`) et **surchargeable au panneau d'administration** (Administration → Paramètres, patron « l'environnement amorce, le panneau gouverne »). Un compte créé au Hub n'a pas de mot de passe local (il vit chez ObsiGate) : la porte centrale fait de la voie SSO le parcours normal au lieu d'un formulaire voué à échouer pour ces comptes. +- **Mode actif** : l'écran de connexion ne présente **que l'entrée centrale** — le lien « Se connecter avec ObsiGate (SSO) », bien visible, avec l'explication qu'il n'y a pas de mot de passe local ; aucun champ local n'est rendu, et `POST /login` refuse toute tentative (403), identifiants de base comme repli d'environnement. **Sessions déjà établies : aucun impact** (contrôle de session inchangé). Non touchés : Basic d'environnement (voie scripts), jetons `kbt_`, pont `/auth/draco`, `/api/search`. +- **Porte de secours administrateur** : un seul compte désigné, par l'environnement uniquement — `KANBANTE_PORTE_SECOURS_USER` / `KANBANTE_PORTE_SECOURS_PASSWORD` (secrets, jamais en base ni dans le panneau ni en dur) — joignable par le **chemin discret `/login/secours`**, jamais proposé ni lié sur l'écran principal ; sans les deux variables, le chemin répond 404. Gardes : le compte désigné doit être un administrateur local **actif** (ou l'opérateur d'environnement) — un membre désigné par erreur est refusé ; le geste est journalisé (`login.porte_secours`). Fiche complète : `docs/porte-centrale.md` (dont la fiche d'activation opérateur). +- **Correctif au passage (panneau des paramètres)** : la conversion des réglages booléens à l'enregistrement est désormais explicite (`true`/`false` lus en toutes lettres) — auparavant `bool("false")` valait `True`, donc un booléen du panneau (dont `porte_centrale`, mais aussi `sync_*`, `briefing_include_standup`) ne pouvait pas se désactiver en saisissant `false`. +- **Tests** : nouveau `tests/test_porte_centrale.py` (7) — mode inactif par défaut = comportement actuel ; activation puis désactivation par le panneau ; mode actif : écran central seul, secours jamais proposé, formulaire refusé pour un membre comme pour l'amorçage, session préexistante intacte ; porte de secours : chemin discret fonctionnel en plein mode central, mauvais mot de passe et membre refusés, membre désigné refusé, 404 sans configuration ; amorçage par variable d'environnement et surcharge du panneau par-dessus. + ## 0.46.5 — 2026-11-06 — Pont draco : adoption de l'amorçage conditionnée (fusion #145) et échec de connexion qui suggère le SSO (fusion #144, part Kanbante) Version **corrective de sécurité** dans la série 0.46.x (base 0.46.4 ; la 0.47.0 de la PR #87, métadonnées des jetons, reste indépendante et non touchée). diff --git a/README.md b/README.md index 0d51fe6..d3ed5ca 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ sudo systemctl enable --now docker | `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) | | `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts | | `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée | +| `KANBANTE_PORTE_CENTRALE` | `0` | `1` = porte centrale unique : l'écran de connexion ne présente que l'entrée ObsiGate (SSO) et le formulaire local est refusé ; surchargeable au panneau d'administration (réglage `porte_centrale`) — voir `docs/porte-centrale.md` | +| `KANBANTE_PORTE_SECOURS_USER` / `KANBANTE_PORTE_SECOURS_PASSWORD` | *(vide)* | Porte de secours : compte administrateur local désigné, joignable par le chemin discret `/login/secours` (jamais proposé sur l'écran principal) ; vide = porte désactivée | | `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) | | `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé | | `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » | diff --git a/VERSION b/VERSION index 992b0b0..6b9ee5c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.46.5 +0.46.6 diff --git a/app/config.py b/app/config.py index 675557b..8d8fd1f 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,24 @@ Environment variables: KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth). KANBANTE_AUTH_PASSWORD Login password. KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. + KANBANTE_PORTE_CENTRALE "1" = active la « porte centrale unique » + (0.46.6) : l'écran de connexion ne présente + plus que l'entrée centrale (SSO ObsiGate) + et le formulaire local est refusé. Défaut + "0" (désactivé) ; surchargeable depuis le + panneau d'administration (réglage + ``porte_centrale``, l'environnement amorce, + le panneau gouverne). + KANBANTE_PORTE_SECOURS_USER / KANBANTE_PORTE_SECOURS_PASSWORD + Identifiants de la porte de secours + administrateur (0.46.6) : LE compte + administrateur local désigné, joignable + par le chemin discret /login/secours + (jamais proposé sur l'écran principal), + y compris quand la porte centrale est + active. Secrets d'environnement uniquement, + jamais en base ni dans le panneau. Variables + absentes = porte de secours désactivée. KANBANTE_SESSION_SECRET HMAC key for the login session cookie (default: derived from the credentials — rotating the password invalidates sessions). @@ -370,6 +388,9 @@ class Settings: auth_user: str = "" auth_password: str = "" require_auth: bool = False + porte_centrale: bool = False + porte_secours_user: str = "" + porte_secours_password: str = "" active_window_days: int = 365 cache_ttl: float = 30.0 max_note_chars: int = 20_000 @@ -456,6 +477,9 @@ def _env_settings() -> Settings: auth_user=os.getenv("KANBANTE_AUTH_USER", ""), auth_password=os.getenv("KANBANTE_AUTH_PASSWORD", ""), require_auth=_env_bool("KANBANTE_REQUIRE_AUTH", False), + porte_centrale=_env_bool("KANBANTE_PORTE_CENTRALE", False), + porte_secours_user=os.getenv("KANBANTE_PORTE_SECOURS_USER", ""), + porte_secours_password=os.getenv("KANBANTE_PORTE_SECOURS_PASSWORD", ""), active_window_days=_env_int("KANBANTE_ACTIVE_WINDOW_DAYS", 365), cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0), max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000), @@ -551,6 +575,11 @@ OVERRIDABLE_SETTINGS: dict[str, type] = { "capture_repo": str, "sync_labels": bool, "sync_gitea": bool, + # Porte centrale unique (0.46.6) : booléen du panneau — + # l'administrateur l'active/la désactive sans redémarrage. + # Les identifiants de la porte de secours, eux, restent en + # environnement (secrets, jamais surchargeables). + "porte_centrale": bool, } diff --git a/app/main.py b/app/main.py index 9cbd929..a7f957d 100644 --- a/app/main.py +++ b/app/main.py @@ -249,7 +249,7 @@ registry = connections_service.ForgeRegistry() SESSION_COOKIE = "kanbante_session" SESSION_DAYS = 7 _PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login", - "/auth/draco") + "/login/secours", "/auth/draco") def _session_secret() -> bytes: @@ -536,11 +536,30 @@ def _safe_next(raw: str) -> str: return "/" +def _ouvrir_session(username: str, next: str) -> RedirectResponse: + """Réponse de connexion native : session signée posée, redirection.""" + response = RedirectResponse(_safe_next(next), status_code=303) + response.set_cookie( + SESSION_COOKIE, + _make_session(username), + max_age=SESSION_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + @app.get("/login") async def login_page(request: Request, next: str = "/"): if not get_settings().auth_enabled or _session_user(request) is not None: return RedirectResponse(_safe_next(next), status_code=303) - return render(request, "login.html", next=_safe_next(next), error="") + # Porte centrale (0.46.6) : en mode actif, le gabarit ne + # présente que l'entrée centrale (lien SSO) — aucun champ + # local (voir app/templates/login.html et docs/porte-centrale.md). + return render( + request, "login.html", next=_safe_next(next), error="", + porte_centrale=bool(get_settings().porte_centrale), + ) @app.post("/login") @@ -559,6 +578,25 @@ async def login_submit( request, "login.html", status_code=403, next=_safe_next(next), error="Session expirée — réessaie.", ) + if settings.porte_centrale: + # Porte centrale unique (0.46.6) : en mode actif, les + # identifiants locaux ordinaires ne sont plus acceptés + # sur ce formulaire — ni mot de passe de base, ni repli + # d'environnement. Seules restent : l'entrée centrale + # (SSO ObsiGate, ci-dessous sur l'écran) et, pour + # l'administrateur désigné, la porte de secours discrète + # (/login/secours, identifiants d'environnement — jamais + # proposée ici). Les sessions déjà établies ne passent + # pas par ce formulaire et ne sont pas touchées. + return render( + request, "login.html", status_code=403, + next=_safe_next(next), porte_centrale=True, + error=( + "La porte centrale est active sur cette instance : " + "la connexion locale est fermée. Utilise « Se " + "connecter avec ObsiGate (SSO) » ci-dessous." + ), + ) from app import accounts as accounts_service user = models.get_user_by_username(username) @@ -622,15 +660,88 @@ async def login_submit( sub = claims.get("sub") if sub and models.get_user_by_draco(sub) is None: models.update_user(user["id"], draco_sub=sub) - response = RedirectResponse(_safe_next(next), status_code=303) - response.set_cookie( - SESSION_COOKIE, - _make_session(username), - max_age=SESSION_DAYS * 86400, - httponly=True, - samesite="lax", + return _ouvrir_session(username, next) + + +# ── Porte de secours administrateur (0.46.6) ────────────────── +# Chemin discret /login/secours : jamais lié ni proposé sur +# l'écran principal (ni en mode porte centrale, ni sinon). +# Un seul compte, désigné par l'environnement : +# KANBANTE_PORTE_SECOURS_USER / KANBANTE_PORTE_SECOURS_PASSWORD +# (voir app/config.py). Le compte désigné doit être un +# administrateur local actif (ou l'opérateur d'environnement) : +# un membre désigné par erreur ne passe pas. Sans les deux +# variables, la porte n'existe pas (404 sec, sans explication). + +def _porte_secours_configuree(settings) -> bool: + return bool(settings.porte_secours_user and settings.porte_secours_password) + + +@app.get("/login/secours") +async def login_secours_page(request: Request, next: str = "/"): + settings = get_settings() + if not settings.auth_enabled or _session_user(request) is not None: + return RedirectResponse(_safe_next(next), status_code=303) + if not _porte_secours_configuree(settings): + return render( + request, "error.html", status_code=404, + message="Page introuvable.", + ) + return render( + request, "login.html", next=_safe_next(next), error="", + mode_secours=True, ) - return response + + +@app.post("/login/secours") +async def login_secours_submit( + request: Request, + username: str = Form(""), + password: str = Form(""), + next: str = Form("/"), + csrf_token: str = Form(""), +): + settings = get_settings() + if not settings.auth_enabled: + return RedirectResponse("/", status_code=303) + if not _porte_secours_configuree(settings): + return render( + request, "error.html", status_code=404, + message="Page introuvable.", + ) + if not _csrf_valid(request, csrf_token): + return render( + request, "login.html", status_code=403, mode_secours=True, + next=_safe_next(next), error="Session expirée — réessaie.", + ) + identifiants_ok = secrets.compare_digest( + username, settings.porte_secours_user + ) and secrets.compare_digest(password, settings.porte_secours_password) + compte = ( + models.get_user_by_username(settings.porte_secours_user) + if identifiants_ok + else None + ) + if compte is not None: + admin_ok = bool(compte["active"]) and compte["role"] == "admin" + else: + # Pas de ligne en base : seul l'opérateur d'environnement + # (administrateur synthétique) peut être le compte désigné. + admin_ok = settings.porte_secours_user == settings.auth_user + if not identifiants_ok or not admin_ok: + return render( + request, "login.html", status_code=401, mode_secours=True, + next=_safe_next(next), error="Identifiants incorrects.", + ) + if compte is not None: + models.update_user( + compte["id"], + last_login_at=time.strftime( + "%Y-%m-%d %H:%M:%S", time.gmtime() + ), + ) + models.log_admin(compte["id"], "login.porte_secours", username) + return _ouvrir_session(settings.porte_secours_user, next) @app.get("/logout") @@ -2090,11 +2201,18 @@ async def admin_settings_save(request: Request, csrf_token: str = Form("")): if raw is None or str(raw).strip() == "": continue # champ vide = retour à la valeur d'environnement try: - overrides[key] = ( - str(raw).strip() - if converter is str - else converter(str(raw).strip()) - ) + if converter is bool: + # Booléen lu EXPLICITEMENT (0.46.6) : bool("false") + # vaudrait True — un réglage booléen du panneau + # (porte_centrale, sync_*, briefing_*) doit pouvoir + # se désactiver en saisissant « false ». + overrides[key] = str(raw).strip().lower() in ( + "1", "true", "on", "oui", + ) + elif converter is str: + overrides[key] = str(raw).strip() + else: + overrides[key] = converter(str(raw).strip()) except (TypeError, ValueError): continue import json as _json diff --git a/app/templates/login.html b/app/templates/login.html index 09122b4..ba86b1d 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -19,6 +19,29 @@ {% if error %} {% endif %} + {% if mode_secours|default(false) %} + {# Porte de secours administrateur (0.46.6) : ce gabarit n'est + rendu que par le chemin discret /login/secours, jamais + proposé depuis l'écran principal. #} +

Porte de secours — administrateur désigné uniquement.

+
+ + + + + +
+ {% elif porte_centrale|default(false) %} + {# Porte centrale unique (0.46.6) : aucune entrée locale — + seule l'entrée centrale est présentée. #} +

Cette instance se connecte par la porte centrale de la suite Dracodev : ton compte du Hub ouvre Kanbante via ObsiGate, sans mot de passe local.

+ Se connecter avec ObsiGate (SSO) +

Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.

+ {% else %}
@@ -32,6 +55,7 @@
Se connecter avec ObsiGate (SSO)

Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.

+ {% endif %} diff --git a/docs/porte-centrale.md b/docs/porte-centrale.md new file mode 100644 index 0000000..75f4237 --- /dev/null +++ b/docs/porte-centrale.md @@ -0,0 +1,81 @@ +# Porte centrale unique (0.46.6) + +Programme « compte partout / porte centrale unique » : un compte +créé au Hub n'a pas de mot de passe local (il vit chez ObsiGate) ; +les formulaires locaux des applications ne pourront donc jamais +l'accepter. La porte centrale fait de cette réalité le parcours +normal : une seule entrée présentée, celle de la suite. + +## Le réglage + +- **Nom** : `porte_centrale` (booléen). +- **Défaut à la livraison : désactivé.** Tant qu'il est inactif, + Kanbante se comporte exactement comme avant (formulaire local + + bouton « Se connecter avec ObsiGate (SSO) »). +- **Activation** : un administrateur Kanbante le règle dans + **Administration → Paramètres** (ligne `porte_centrale` : + saisir `true` pour activer, `false` pour désactiver, vide pour + revenir à l'environnement). L'environnement peut aussi amorcer + la valeur : `KANBANTE_PORTE_CENTRALE=1` — l'environnement + amorce, le panneau gouverne, comme les autres réglages + surchargeables (`app.config.OVERRIDABLE_SETTINGS`). + +## Mode actif + +- L'écran de connexion (`GET /login`) ne présente **que** + l'entrée centrale : le lien « Se connecter avec ObsiGate + (SSO) », bien visible, et un texte qui explique qu'il n'y a + pas de mot de passe local. Aucun champ identifiant/mot de + passe n'est rendu. +- Le formulaire local (`POST /login`) **refuse** toute tentative + (403), quels que soient les identifiants présentés — mot de + passe de base comme repli d'environnement : les identifiants + locaux ordinaires ne sont plus acceptés sur cette voie. +- **Sessions déjà établies : aucun impact.** Le contrôle de + session (cookie signé) ne change pas ; une session ouverte + avant l'activation reste valide jusqu'à son expiration + normale, et le pont draco continue d'en ouvrir par le SSO. +- Non touchés : l'authentification Basic d'environnement (voie + scripts historique), les jetons personnels `kbt_`, le pont + `/auth/draco` et `/api/search`. + +## Porte de secours administrateur + +Pour ne jamais se verrouiller dehors (panne de l'émettrice, +liaison à réparer…), **un** compte administrateur local désigné +peut toujours se connecter — par un **chemin discret**, jamais +proposé ni lié sur l'écran principal : + +- **Chemin** : `GET /login/secours` (formulaire), `POST + /login/secours` (connexion). Sans configuration, le chemin + répond 404, sans explication. +- **Désignation, par l'environnement uniquement** (secrets — + jamais en base, jamais dans le panneau, jamais en dur) : + - `KANBANTE_PORTE_SECOURS_USER` : nom du compte administrateur + local désigné ; + - `KANBANTE_PORTE_SECOURS_PASSWORD` : son mot de passe de + secours (comparé en temps constant ; il peut différer du mot + de passe de base du compte — c'est un identifiant dédié). +- **Gardes** : les deux variables doivent être présentes ; le + compte désigné doit exister en base, être **actif** et de rôle + **admin** (ou être l'opérateur d'environnement, administrateur + synthétique). Un membre désigné par erreur ne passe pas. + L'ouverture de session par cette voie est journalisée + (`login.porte_secours`). +- La session ouverte est une session native ordinaire + (7 jours) : l'administrateur peut ensuite désactiver la porte + centrale dans le panneau, lier un `sub` draco, réparer une + correspondance, etc. + +## Fiche d'activation (résumé opérateur) + +1. Poser `KANBANTE_PORTE_SECOURS_USER` et + `KANBANTE_PORTE_SECOURS_PASSWORD` dans l'environnement du + conteneur Kanbante, recréer le conteneur. +2. Vérifier la porte de secours : ouvrir `/login/secours`, + se connecter avec le compte désigné. +3. Activer : Administration → Paramètres → + `porte_centrale` = `true` → Enregistrer. +4. Vérifier : déconnecté, `/login` ne montre que l'entrée + ObsiGate (SSO) ; un essai de formulaire local est refusé ; + les sessions déjà ouvertes restent valides. diff --git a/tests/test_porte_centrale.py b/tests/test_porte_centrale.py new file mode 100644 index 0000000..77187d0 --- /dev/null +++ b/tests/test_porte_centrale.py @@ -0,0 +1,216 @@ +"""Porte centrale unique (0.46.6) — réglage, écran, refus, secours. + +Branche de la phase 2 du programme « compte partout » (basée sur +la 0.46.5, fusion #145/#144). Couvert ici, sur instance fraîche +par test (base temporaire : seul le compte d'amorçage « proprio », +admin, créé depuis l'environnement — même patron que +tests/test_amorcage_pont.py) : + +- mode INACTIF (défaut à la livraison) : comportement actuel — + formulaire local visible et fonctionnel ; +- activation par le PANNEAU d'administration (le réglage + ``porte_centrale`` se pose et se retire : ``true`` puis + ``false`` — la conversion booléenne explicite de + l'enregistrement est exercée au passage) ; +- mode ACTIF : l'écran ne présente que l'entrée centrale (lien + SSO, aucun champ local) ; le formulaire local est refusé même + avec des identifiants locaux valides ; une session déjà + établie reste valide (aucun impact) ; +- porte de secours : chemin discret ``/login/secours``, jamais + proposé sur l'écran principal ; identifiants posés par les + variables d'environnement ``KANBANTE_PORTE_SECOURS_USER`` / + ``KANBANTE_PORTE_SECOURS_PASSWORD`` ; seul le compte + administrateur désigné passe (un membre désigné est refusé) ; + sans les variables, le chemin répond 404. +""" + +import json + +import pytest +from fastapi.testclient import TestClient + +import app.main as main +from app import accounts, models +from app.config import get_settings + +MDP_AMORCE = "mdp-amorce" +MDP_MEMBRE = "mdp-membre" +MDP_SECOURS = "mdp-secours-test" + + +@pytest.fixture() +def instance(monkeypatch, tmp_path, fake_gitea): + monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "porte.db")) + monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", MDP_AMORCE) + with TestClient(main.app) as client: + users = models.list_users() + assert len(users) == 1 and users[0]["username"] == "proprio" + membre_id = models.create_user( + "membre1", accounts.hash_password(MDP_MEMBRE), + role="member", display_name="Membre Un", + ) + yield client, users[0], membre_id + + +def _csrf(client, chemin="/login"): + client.get(chemin) + return client.cookies.get("kanbante_csrf") or "" + + +def _connecter(client, username, password, chemin="/login"): + token = _csrf(client, chemin) + return client.post( + chemin, + data={"username": username, "password": password, + "next": "/", "csrf_token": token}, + follow_redirects=False, + ) + + +def _activer_porte_par_le_panneau(client): + """Connexion admin native, puis POST /admin/settings (true).""" + r = _connecter(client, "proprio", MDP_AMORCE) + assert r.status_code == 303 + r = client.get("/admin/settings") + assert r.status_code == 200 + assert "porte_centrale" in r.text # le réglage est au panneau + token = client.cookies.get("kanbante_csrf") or "" + r = client.post( + "/admin/settings", + data={"setting_porte_centrale": "true", "csrf_token": token}, + follow_redirects=False, + ) + assert r.status_code == 303 + assert get_settings().porte_centrale is True + + +# ── Mode inactif : comportement actuel ─────────────────────── + +def test_mode_inactif_par_defaut_comportement_actuel(instance): + client, _amorce, _membre = instance + assert get_settings().porte_centrale is False + r = client.get("/login") + assert r.status_code == 200 + assert 'name="password"' in r.text # formulaire local présent + assert "Se connecter avec ObsiGate (SSO)" in r.text + # Le formulaire local fonctionne pour un compte ordinaire. + r = _connecter(client, "membre1", MDP_MEMBRE) + assert r.status_code == 303 + assert "kanbante_session" in r.headers.get("set-cookie", "") + assert client.get("/").status_code == 200 + + +def test_panneau_active_puis_desactive(instance): + client, _amorce, _membre = instance + _activer_porte_par_le_panneau(client) + # Désactivation : saisir « false » doit RÉELLEMENT désactiver + # (conversion booléenne explicite, pas bool("false") == True). + token = client.cookies.get("kanbante_csrf") or "" + r = client.post( + "/admin/settings", + data={"setting_porte_centrale": "false", "csrf_token": token}, + follow_redirects=False, + ) + assert r.status_code == 303 + assert get_settings().porte_centrale is False + # Et le formulaire refonctionne (session admin refermée). + client.cookies.delete("kanbante_session") + r = _connecter(client, "membre1", MDP_MEMBRE) + assert r.status_code == 303 + + +# ── Mode actif : écran central, formulaire refusé ──────────── + +def test_mode_actif_ecran_et_formulaire_refuse(instance): + client, _amorce, _membre = instance + _activer_porte_par_le_panneau(client) + # La session DÉJÀ ÉTABLIE (celle de l'admin qui vient + # d'activer) n'est pas touchée par l'activation. + assert client.get("/").status_code == 200 + assert client.get("/admin/settings").status_code == 200 + # Écran de connexion, visiteur déconnecté : entrée centrale seule. + client.cookies.delete("kanbante_session") + r = client.get("/login") + assert r.status_code == 200 + assert "Se connecter avec ObsiGate (SSO)" in r.text + assert 'name="password"' not in r.text + assert 'action="/login"' not in r.text + # Le chemin de secours n'est JAMAIS proposé sur l'écran principal. + assert "/login/secours" not in r.text + # Formulaire local refusé, même avec les identifiants valides + # d'un compte ordinaire… + r = _connecter(client, "membre1", MDP_MEMBRE) + assert r.status_code == 403 + assert "porte centrale est active" in r.text + assert "kanbante_session" not in r.headers.get("set-cookie", "") + # …et même avec ceux de l'administrateur d'amorçage. + r = _connecter(client, "proprio", MDP_AMORCE) + assert r.status_code == 403 + assert "kanbante_session" not in r.headers.get("set-cookie", "") + + +# ── Porte de secours ───────────────────────────────────────── + +def test_porte_secours_chemin_discret(instance, monkeypatch): + client, _amorce, _membre = instance + monkeypatch.setenv("KANBANTE_PORTE_SECOURS_USER", "proprio") + monkeypatch.setenv("KANBANTE_PORTE_SECOURS_PASSWORD", MDP_SECOURS) + _activer_porte_par_le_panneau(client) + client.cookies.delete("kanbante_session") + # L'écran principal ne propose toujours pas le chemin. + assert "/login/secours" not in client.get("/login").text + # Le chemin discret, lui, présente son formulaire… + r = client.get("/login/secours") + assert r.status_code == 200 + assert 'action="/login/secours"' in r.text + # …refuse un mauvais mot de passe… + r = _connecter(client, "proprio", "mauvais", chemin="/login/secours") + assert r.status_code == 401 + assert "kanbante_session" not in r.headers.get("set-cookie", "") + # …refuse le compte membre (pas le compte désigné)… + r = _connecter(client, "membre1", MDP_SECOURS, chemin="/login/secours") + assert r.status_code == 401 + # …et ouvre la session de l'administrateur désigné, en plein + # mode porte centrale. + r = _connecter(client, "proprio", MDP_SECOURS, chemin="/login/secours") + assert r.status_code == 303 + assert "kanbante_session" in r.headers.get("set-cookie", "") + assert client.get("/admin").status_code == 200 + + +def test_porte_secours_membre_designe_refuse(instance, monkeypatch): + """Le compte désigné doit être administrateur : un membre ne passe pas.""" + client, _amorce, _membre = instance + monkeypatch.setenv("KANBANTE_PORTE_SECOURS_USER", "membre1") + monkeypatch.setenv("KANBANTE_PORTE_SECOURS_PASSWORD", MDP_SECOURS) + r = _connecter(client, "membre1", MDP_SECOURS, chemin="/login/secours") + assert r.status_code == 401 + assert "kanbante_session" not in r.headers.get("set-cookie", "") + + +def test_porte_secours_non_configuree_404(instance): + client, _amorce, _membre = instance + # Aucune variable de secours dans l'environnement de test. + assert client.get("/login/secours").status_code == 404 + r = _connecter(client, "proprio", MDP_AMORCE, chemin="/login/secours") + assert r.status_code == 404 + assert "kanbante_session" not in r.headers.get("set-cookie", "") + + +def test_activation_par_variable_environnement(monkeypatch, tmp_path, fake_gitea): + """L'environnement peut amorcer le réglage (défaut officiel : inactif).""" + monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "porte-env.db")) + monkeypatch.setenv("KANBANTE_AUTH_USER", "proprio") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", MDP_AMORCE) + monkeypatch.setenv("KANBANTE_PORTE_CENTRALE", "1") + with TestClient(main.app) as client: + assert get_settings().porte_centrale is True + r = client.get("/login") + assert 'name="password"' not in r.text + # La surcharge du panneau reste possible par-dessus : + # (kv settings_overrides, même écriture que le panneau). + models.set_kv( + "settings_overrides", json.dumps({"porte_centrale": False}) + ) + assert get_settings().porte_centrale is False -- 2.54.0