Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463dd7b752 | ||
|
|
e6f5cff870 | ||
|
|
bd5357f589 | ||
|
|
5203b6288e | ||
|
|
50d7f337da | ||
|
|
0eec63a5ca | ||
|
|
be3e4e5f69 |
@@ -1,3 +1,29 @@
|
||||
## v7.85.3 (2026-11-06) — HUB-21 : consommation des réglages généraux du Hub
|
||||
|
||||
- **Client des réglages généraux** (`app/services/hub_reglages.py`, nouveau) : propagation du pilote TikTak 0.61.22. Consultation côté serveur de `GET {hub}/api/reglages/effectifs` — endpoint public, aucun cookie, `Accept: application/json`, délai 2,0 s. URL du Hub résolue comme le reste de la suite (`draco_urls.hub_url()` : hôte de la requête, puis dérivation par l'hôte de l'agent — `xxx-dev` → `hub-dev` — jamais d'URL dev figée ni de variable explicite qui figerait la dérivation). Cache mémoire 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Forme validée strictement (liste fermée D6(a)) ; toute erreur (réseau, statut, JSON, forme) = **repli intégral** sur les défauts actuels (`recherche_partout: true`, `banniere: ""`, `domaine_defaut: null`), jamais d'exception.
|
||||
- **Bannière d'annonce** : global Jinja `banniere_hub` (fonction, jamais un instantané) consommé par `app/templates/base.html` — bandeau sous la barre commune, texte rendu par l'autoescape Jinja (jamais de HTML injecté), bouton « fermer » qui mémorise le texte fermé en `sessionStorage` (clé `flowdeck_banniere_masquee`) : une nouvelle annonce réapparaît. Bannière vide = aucun fragment rendu, zéro changement visuel. Styles `.banniere-hub*` dans `static/css/app.css` (fond `var(--accent)`). Hors mode embarqué (`embed_mode`), jamais sur la page publique.
|
||||
- **Drapeau `recherche_partout`** : la route `/recherche` pose `data-recherche-partout` sur la coquille ; le JS vendangé `static/js/recherche.js` (même modification dans les 4 hôtes applicatifs) n'interroge plus les sœurs ni la console quand la valeur est `false` — leurs sections affichent « Recherche fédérée désactivée… », la recherche locale Flowdeck continue ; attribut absent ou illisible = fédération active (repli).
|
||||
- **`domaine_defaut` non consommé** : Flowdeck n'a pas de notion de domaine (Pro/Famille/Perso) propre — le sélecteur de la barre commune pilote le panneau Spark, pas les vues Flowdeck. La valeur est lue et validée par le client, sans effet local ; seul l'hôte console (agent) l'applique.
|
||||
- Tests : `tests/test_hub_reglages.py` (nouveau, 13 tests — validation de la forme, contrat exact de l'appel, cache 60 s, ancien cache après échec, repli Hub muet, bannière présente et échappée dans la page /recherche, absente si vide ou Hub muet, attribut du drapeau rendu selon la valeur) ; `tests/recherche.test.js` étendu (fonction pure `federationAutorisee`, 92 assertions).
|
||||
|
||||
## v7.85.2 (2026-11-06) — HUB-19 : propagation des rôles du Hub (registre D3(a))
|
||||
|
||||
- **Client des rôles du Hub** (`app/services/hub_roles.py`, nouveau) : portage Python du pilote ShaarIt 0.7.0 (`hub_roles.rs`). À l'ouverture de session draco, consultation de `GET {hub}/api/roles/flowdeck` — en-tête Cookie complet de la requête relayé tel quel, `Accept: application/json`, délai 2 s. URL du Hub résolue comme la barre commune (`draco_urls.hub_url()` : explicite `HUB_URL` > hôte de la requête > dérivation par l'hôte de l'agent > défaut officiel), jamais d'URL dev figée. Cache mémoire par `sub`, TTL 60 s, horodatage posé **avant** l'appel : un échec conserve l'ancien cache, pas de rafale. Toute erreur (401/404/réseau/JSON illisible/rôle hors ensemble {`aucun`, `utilisateur`, `administrateur`}) = rôle **inconnu** → comportement actuel conservé, intégralement.
|
||||
- **Refus `aucun` avant la session** : le pont `GET /auth/draco` (seul point où un cookie draco aboutit à une session locale) appelle `user_for_draco(..., refuser_aucun=True)` ; un rôle connu `aucun` lève `draco.RoleAucunRefuse` **avant** la résolution de compte et donc avant le provisionnement JIT HUB-15 — aucun compte ni lien n'est créé pour un `sub` refusé. Réponse 403 en français nommant le registre des rôles du Hub. Exemptions (patron du pilote) : session locale préexistante (accès de secours par mot de passe, le pont ne refuse pas), page de connexion, santé et statiques (jamais passées par le pont), contrats API propres (OAuth externe Gitea/GitHub, jetons Bearer/SCIM de Flowdeck — non draco, intacts) et recherche fédérée (`/api/search`, repli draco sans création de session : défaut `refuser_aucun=False`, aucun refus nouveau).
|
||||
- **Surfaces d'administration d'instance gardées** (`hub_roles.admin_hub_ok` : session draco + rôle connu → `administrateur` exigé ; sinon comportement actuel) : `/api/admin/*` (`admin_required`), configuration des fournisseurs de l'agent (`agent._current_admin`), journal d'audit unifié (branche session de `audit._admin_user`), administration SCIM/domaines (branche session de `scim._admin_session`), configuration SSO (branche session de `sso._require_admin`). Les voies Bearer de ces mêmes surfaces (contrats d'API propres) et l'administration d'espace de travail (rôles de membre) ne sont pas touchées.
|
||||
- **Provisionnement JIT inchangé** (HUB-15) : le refus intervient au même point que l'établissement de session, avant toute création.
|
||||
- Tests : `tests/test_hub_roles.py` (nouveau, 28 tests — client : contrat exact de l'appel, cache par `sub`, ancien cache après échec, erreurs → inconnu ; intégration : 403 français au pont, refus avant JIT — compte et lien absents —, rôle `utilisateur` avec JIT intact, repli rôle inconnu, session préexistante exemptée, garde `/api/admin/*` selon le rôle). Balayage draco/SSO/recherche existant reconfirmé sans régression (voir PR).
|
||||
|
||||
## v7.85.1 (2026-11-06) — Barre commune v0.5.0 : thème commun (HUB-18, fusion #132)
|
||||
|
||||
> **Hypothèse d'ordre** : cette version suppose la fusion préalable de la PR HUB-15 #51 (Flowdeck 7.85.0) ; branchée sur `main` (7.84.15), elle vise **7.85.1**.
|
||||
|
||||
- **Barre d'apps v0.5.0 vendangée (dracodev-commons, branche `hub18-theme-commun`, PR commons #12)** : seul `static/js/vendor/draco-barre.js` change, remplacé verbatim par la v0.5.0 ; le CSS et le fragment `app/templates/_draco_bar.html` sont inchangés.
|
||||
- **Thème commun** : la barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître.
|
||||
- **Initialisation propre à Flowdeck** (`static/js/app.js`) : écoute de l'événement `draco:theme` de la barre — le mode effectif est appliqué sur `data-theme` et mémorisé dans la clé locale `theme` (convergence) ; le sélecteur de thème des Réglages reste fonctionnel pour la session.
|
||||
- **Cache** : le bump 7.85.0 → 7.85.1 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
- Tests : `tests/test_draco_bar_texte.py` étendu (marqueurs v0.5.0 du JS vendangé + écouteur `draco:theme` dans app.js).
|
||||
|
||||
## v7.85.0 (2026-11-06) — HUB-15 : provisionnement JIT des comptes draco (fusion #129)
|
||||
|
||||
- **Généralisation de `maybe_link` en création JIT** (`app/services/draco.py`) : à la première connexion avec un cookie `draco_session` valide, si aucun compte local ne correspond au `sub` (ni lien `draco_links`, ni repli mono-utilisateur), le pendant local est **créé automatiquement** depuis les claims (`sub`, `name`) et lié au `sub` dans la même transaction — aucune création manuelle dans l'app. Un compte de suite créé dans le Hub fonctionne donc dans Flowdeck dès sa première visite.
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
||||
|
||||
|
||||
# Littéral de version synchronisé avec le fichier VERSION :
|
||||
# version="7.85.0"
|
||||
# version="7.85.2"
|
||||
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
|
||||
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||
|
||||
@@ -11,6 +11,16 @@ async def admin_required(request: Request):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 (registre des rôles du Hub) : session draco + rôle connu
|
||||
# pour Flowdeck → exiger « administrateur » dans le registre,
|
||||
# en plus du drapeau local. Pas de session draco ou rôle inconnu
|
||||
# (Hub injoignable) : comportement actuel.
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
if not user.get("is_admin"):
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -127,6 +127,14 @@ def _current_admin(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user.get("id"),)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Accès administrateur requis")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (voir app.services.hub_roles.admin_hub_ok).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ def _admin_user(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?",
|
||||
(sess.get("id"),)).fetchone()
|
||||
if row and row["is_admin"]:
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger
|
||||
# « administrateur » dans le registre du Hub (la voie
|
||||
# Bearer ci-dessous, contrat d'API propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le "
|
||||
"registre des rôles du Hub pour FlowDeck")
|
||||
return sess
|
||||
raise HTTPException(403, "Admin required")
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
|
||||
+30
-1
@@ -483,10 +483,39 @@ def draco_bridge(request: Request, next: str = "/workspaces"):
|
||||
Point de retour de ``GET /sso/authorize`` de l'émettrice : si le
|
||||
cookie est valide et résolu vers un compte local, une session
|
||||
Flowdeck native est créée ; sinon, retour à la connexion locale.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : rôle connu
|
||||
``aucun`` pour Flowdeck → refus 403 en français nommant le
|
||||
registre du Hub, **avant** la création de la session locale et
|
||||
avant tout provisionnement JIT (aucun compte créé pour un
|
||||
``sub`` refusé). Exemption, patron du pilote ShaarIt : une
|
||||
session locale préexistante (accès de secours) n'est pas
|
||||
refusée — le pont la laisse simplement se poursuivre. Rôle
|
||||
inconnu (Hub injoignable/erreur) : comportement actuel.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.services import draco
|
||||
|
||||
user = draco.user_for_draco(request)
|
||||
_cookie_session = request.cookies.get("flowdeck_session", "")
|
||||
session_existante = (SessionManager.decode_session(_cookie_session)
|
||||
if _cookie_session else None)
|
||||
try:
|
||||
user = draco.user_for_draco(
|
||||
request, refuser_aucun=not session_existante)
|
||||
except draco.RoleAucunRefuse:
|
||||
return HTMLResponse(
|
||||
"""<!DOCTYPE html><html lang="fr"><head><meta charset="utf-8">
|
||||
<title>Accès refusé — FlowDeck</title></head><body><main>
|
||||
<h1>Accès refusé</h1>
|
||||
<p>Le registre des rôles de la suite (Hub Dracodev) attribue le niveau
|
||||
« aucun » à votre compte pour FlowDeck : l'accès par la session draco
|
||||
est refusé. L'administrateur de suite peut modifier ce niveau dans la
|
||||
matrice des rôles du Hub. L'accès de secours par le mot de passe local
|
||||
de l'instance reste possible par la page de connexion.</p>
|
||||
</main></body></html>""",
|
||||
status_code=403,
|
||||
)
|
||||
if user is None:
|
||||
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
|
||||
session = SessionManager.create_session(user, request)
|
||||
|
||||
@@ -46,6 +46,14 @@ def _admin_session(request: Request) -> dict:
|
||||
row = conn.execute("SELECT is_admin FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(403, "Admin required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (les jetons SCIM Bearer, contrat propre,
|
||||
# passent par _scim_guard et sont exemptés).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
403, "Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -56,8 +56,15 @@ def recherche_page(request: Request, q: str = Query(default="")):
|
||||
static/js/recherche.js (API de recherche de chaque app, en
|
||||
parallèle, états par application). La palette Ctrl+K native et
|
||||
``GET /api/search`` restent inchangés par ailleurs.
|
||||
|
||||
HUB-21 : le drapeau ``recherche_partout`` des réglages
|
||||
généraux du Hub est rendu dans l'attribut
|
||||
``data-recherche-partout`` de la coquille (cache serveur de
|
||||
60 s de ``app.services.hub_reglages`` ; Hub muet → « true »,
|
||||
comportement actuel) — quand il est faux, recherche.js
|
||||
n'interroge que la section locale Flowdeck.
|
||||
"""
|
||||
from app.services import draco_urls
|
||||
from app.services import draco_urls, hub_reglages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -69,6 +76,7 @@ def recherche_page(request: Request, q: str = Query(default="")):
|
||||
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
||||
return HTMLResponse(template.render(
|
||||
request=request, q=q.strip(), user=user,
|
||||
recherche_partout=hub_reglages.recherche_partout(),
|
||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
||||
"kanbante", "shaarit", "agent")},
|
||||
))
|
||||
|
||||
@@ -527,6 +527,15 @@ def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
).fetchone()
|
||||
if not row or not row["is_admin"]:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# HUB-19 : session draco + rôle Hub connu → exiger « administrateur »
|
||||
# dans le registre du Hub (la voie Bearer ci-dessus, contrat d'API
|
||||
# propre, est exemptée).
|
||||
from app.services import hub_roles
|
||||
if not hub_roles.admin_hub_ok(request):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Niveau « administrateur » requis dans le registre "
|
||||
"des rôles du Hub pour FlowDeck")
|
||||
if write and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
cookie = request.cookies.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
|
||||
+33
-1
@@ -26,6 +26,12 @@ admin), aucun mot de passe local (``password_hash`` NULL,
|
||||
connexion locale par mot de passe reste impossible pour ce compte
|
||||
et inchangée pour les autres). Une pierre tombale (dissociation
|
||||
explicite) interdit toute re-création automatique pour ce ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub) : le pont d'ouverture de session
|
||||
peut demander le refus d'un ``sub`` au rôle connu ``aucun``
|
||||
(``user_for_draco(..., refuser_aucun=True)`` →
|
||||
``RoleAucunRefuse``), levé avant la résolution de compte et le
|
||||
provisionnement JIT — voir ``app.services.hub_roles``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -45,6 +51,16 @@ logger = logging.getLogger(__name__)
|
||||
COOKIE_NAME = "draco_session"
|
||||
_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
|
||||
|
||||
|
||||
class RoleAucunRefuse(Exception):
|
||||
"""HUB-19 : le registre des rôles du Hub attribue le niveau
|
||||
``aucun`` à ce ``sub`` pour Flowdeck — l'ouverture de session
|
||||
draco est refusée (403), avant tout provisionnement JIT."""
|
||||
|
||||
def __init__(self, sub: str):
|
||||
super().__init__(sub)
|
||||
self.sub = sub
|
||||
|
||||
_revoked_cache: dict[str, int] = {}
|
||||
_revoked_fetched_at: float = 0.0
|
||||
|
||||
@@ -215,12 +231,22 @@ def _provision_jit(conn, claims: dict) -> dict | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
def user_for_draco(request, refuser_aucun: bool = False) -> dict | None:
|
||||
"""Utilisateur Flowdeck prouvé par le cookie draco_session, ou None.
|
||||
|
||||
Résolution : lien ``draco_links`` existant, sinon repli
|
||||
mono-utilisateur, sinon provisionnement JIT (HUB-15) — le
|
||||
pendant local est créé depuis les claims et lié au ``sub``.
|
||||
|
||||
HUB-19 (registre des rôles du Hub, contrat D3(a)) : avec
|
||||
``refuser_aucun=True`` — le pont d'ouverture de session
|
||||
``GET /auth/draco``, seul appelant qui crée une session locale —
|
||||
un rôle **connu** ``aucun`` dans le registre du Hub lève
|
||||
``RoleAucunRefuse`` **avant** toute résolution de compte et donc
|
||||
avant tout provisionnement JIT : aucun compte n'est créé pour un
|
||||
``sub`` refusé. Rôle inconnu (Hub injoignable/erreur) : repli,
|
||||
comportement actuel inchangé. Les autres appelants (recherche
|
||||
fédérée) gardent le défaut ``False`` : aucun refus nouveau.
|
||||
"""
|
||||
if not enabled():
|
||||
return None
|
||||
@@ -233,6 +259,12 @@ def user_for_draco(request) -> dict | None:
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
if refuser_aucun:
|
||||
from app.services import hub_roles
|
||||
|
||||
if hub_roles.role_pour(
|
||||
request.headers.get("cookie", ""), sub) == "aucun":
|
||||
raise RoleAucunRefuse(sub)
|
||||
with get_conn() as conn:
|
||||
user = _linked_user(conn, sub)
|
||||
if user is None:
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Réglages généraux de la suite — consommation du Hub (HUB-21).
|
||||
|
||||
Propagation du pilote TikTak (0.61.22, PR ticktick-clone #244) à
|
||||
Flowdeck, sur le contrat figé du Hub 0.16.0 : ``GET
|
||||
{hub}/api/reglages/effectifs`` → 200 JSON
|
||||
``{"recherche_partout": bool, "banniere": string ("" = aucune),
|
||||
"domaine_defaut": "pro"|"famille"|"perso"|null,
|
||||
"ttl_secondes": 60}``, ``Cache-Control: public, max-age=60`` —
|
||||
endpoint public, aucun cookie relayé.
|
||||
|
||||
Petit client serveur sur le patron du pilote et du client des
|
||||
rôles (``app.services.hub_roles``) : cache mémoire 60 s,
|
||||
horodatage posé **avant** l'appel — un échec ne déclenche pas de
|
||||
rafale et l'ancien cache reste en vigueur. L'URL du Hub est
|
||||
résolue comme la barre commune (``app.services.draco_urls.hub_url()``
|
||||
: variable explicite ``HUB_URL`` / réglage, dérivation par l'hôte
|
||||
de la requête, puis par l'hôte sœur de l'agent, puis défaut
|
||||
officiel) — jamais d'URL dev figée. Consultation côté serveur,
|
||||
délai court de 2 s, jamais au démarrage (premier appel paresseux,
|
||||
au premier rendu de gabarit).
|
||||
|
||||
**Repli obligatoire** : Hub muet, en erreur ou réponse de forme
|
||||
inattendue → valeurs par défaut actuelles (pas de bannière,
|
||||
recherche partout active, aucun domaine par défaut) —
|
||||
comportement d'aujourd'hui à l'identique. Ce module ne lève
|
||||
jamais.
|
||||
|
||||
**Périmètre Flowdeck (cette PR)** :
|
||||
- ``banniere`` : bandeau d'annonce dans le gabarit principal
|
||||
(``app/templates/base.html``, sous la barre commune), exposé
|
||||
aux gabarits par ``app.templating`` (fonction, pas un
|
||||
instantané : chaque rendu lit le cache de 60 s) ; texte échappé
|
||||
par l'autoescape Jinja, masquage de session navigateur.
|
||||
- ``recherche_partout`` : la route ``GET /recherche`` rend
|
||||
l'attribut ``data-recherche-partout`` de la coquille ; quand le
|
||||
drapeau est faux, ``recherche.js`` (copie vendangée, même
|
||||
modification dans chaque hôte) n'interroge plus les
|
||||
applications sœurs ni la console — la recherche locale
|
||||
Flowdeck (sa propre section, même origine) continue.
|
||||
- ``domaine_defaut`` : **non consommé** — Flowdeck n'a pas de
|
||||
notion de domaine (Pro/Famille/Perso) propre : ses vues ne sont
|
||||
filtrées par aucun domaine ; le sélecteur de la barre commune
|
||||
pilote le panneau Spark, pas Flowdeck. Le réglage est lu et
|
||||
validé par le client, mais pas appliqué ici.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub.
|
||||
CACHE_TTL = 60.0 # secondes
|
||||
|
||||
#: Délai de consultation du Hub : court par contrat (2 s).
|
||||
DELAI = 2.0 # secondes
|
||||
|
||||
#: Valeurs par défaut actuelles (repli quand le Hub ne répond pas).
|
||||
DEFAUTS: dict = {
|
||||
"recherche_partout": True,
|
||||
"banniere": "",
|
||||
"domaine_defaut": None,
|
||||
}
|
||||
|
||||
_DOMAINES = ("pro", "famille", "perso")
|
||||
|
||||
_verrou = threading.Lock()
|
||||
_cache: dict | None = None
|
||||
_cache_at: float = 0.0
|
||||
|
||||
|
||||
def reinitialiser_cache() -> None:
|
||||
"""Vide le cache (tests uniquement)."""
|
||||
global _cache, _cache_at
|
||||
with _verrou:
|
||||
_cache = None
|
||||
_cache_at = 0.0
|
||||
|
||||
|
||||
def _valider(data) -> dict | None:
|
||||
"""Valide la charge utile du Hub ; dict normalisé ou None."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
recherche = data.get("recherche_partout")
|
||||
banniere = data.get("banniere")
|
||||
domaine = data.get("domaine_defaut")
|
||||
if not isinstance(recherche, bool):
|
||||
return None
|
||||
if not isinstance(banniere, str):
|
||||
return None
|
||||
if domaine is not None and domaine not in _DOMAINES:
|
||||
return None
|
||||
return {
|
||||
"recherche_partout": recherche,
|
||||
"banniere": banniere,
|
||||
"domaine_defaut": domaine,
|
||||
}
|
||||
|
||||
|
||||
def _consulter_hub() -> dict | None:
|
||||
"""Un appel au Hub ; retourne les réglages validés ou None."""
|
||||
from app.services import draco_urls
|
||||
|
||||
url = f"{draco_urls.hub_url().rstrip('/')}/api/reglages/effectifs"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
r = httpx.get(url, headers={"Accept": "application/json"},
|
||||
timeout=DELAI)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
return _valider(r.json())
|
||||
except Exception as exc: # Hub injoignable : repli, jamais d'exception
|
||||
logger.debug("Réglages du Hub indisponibles (%s): %s", url, exc)
|
||||
return None
|
||||
|
||||
|
||||
def reglages_effectifs() -> dict:
|
||||
"""Réglages généraux en vigueur : réponse du Hub (cache 60 s),
|
||||
sinon ancien cache, sinon les valeurs par défaut actuelles."""
|
||||
global _cache, _cache_at
|
||||
with _verrou:
|
||||
if _cache is not None and time.monotonic() - _cache_at < CACHE_TTL:
|
||||
return dict(_cache)
|
||||
# Horodatage posé avant l'appel (patron révocations) :
|
||||
# l'ancien cache reste en vigueur si l'appel échoue.
|
||||
_cache_at = time.monotonic()
|
||||
ancien = dict(_cache) if _cache is not None else None
|
||||
resultat = _consulter_hub()
|
||||
if resultat is not None:
|
||||
with _verrou:
|
||||
_cache = resultat
|
||||
_cache_at = time.monotonic()
|
||||
return dict(resultat)
|
||||
if ancien is not None:
|
||||
return ancien
|
||||
return dict(DEFAUTS)
|
||||
|
||||
|
||||
def banniere() -> str:
|
||||
"""Texte de la bannière d'annonce en vigueur ("" = aucune)."""
|
||||
return str(reglages_effectifs()["banniere"] or "")
|
||||
|
||||
|
||||
def recherche_partout() -> bool:
|
||||
"""Drapeau « Rechercher partout » en vigueur (True = actif)."""
|
||||
return bool(reglages_effectifs()["recherche_partout"])
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Rôles par application — consommation du registre du Hub (HUB-19).
|
||||
|
||||
Propagation du pilote ShaarIt (0.7.0, en production) à Flowdeck,
|
||||
sur le contrat D3(a) du plan Hub v2 : à l'ouverture de session
|
||||
draco, Flowdeck demande au Hub le niveau du porteur du cookie pour
|
||||
l'application ``flowdeck`` (``GET {hub}/api/roles/flowdeck``, en-tête
|
||||
Cookie complet de la requête relayé tel quel, ``Accept:
|
||||
application/json``, délai 2 s) et met la réponse en cache 60 s par
|
||||
``sub`` — patron du client Rust ``hub_roles.rs`` de ShaarIt et du
|
||||
cache de révocations de ``app.services.draco`` (horodatage posé
|
||||
**avant** l'appel : un échec ne déclenche pas de rafale, l'ancien
|
||||
cache reste en vigueur).
|
||||
|
||||
Réponse attendue (200) : ``{"app", "sub", "role", "explicite",
|
||||
"ttl_secondes": 60}``, rôle ∈ {``aucun``, ``utilisateur``,
|
||||
``administrateur``}.
|
||||
|
||||
**Repli obligatoire** : Hub injoignable, erreur (401/404/…), réseau,
|
||||
réponse illisible ou rôle hors ensemble → rôle **inconnu** (None),
|
||||
et l'appelant garde son comportement actuel. Le registre ne crée
|
||||
jamais un verrouillage nouveau.
|
||||
|
||||
L'URL du Hub est résolue comme la barre commune de Flowdeck
|
||||
(``app.services.draco_urls.hub_url()`` : variable explicite
|
||||
``HUB_URL`` / réglage, dérivation par l'hôte de la requête, puis par
|
||||
l'hôte sœur de l'agent, puis défaut officiel) — jamais d'URL dev
|
||||
figée.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Identifiant de Flowdeck dans le registre des applications du Hub.
|
||||
APP = "flowdeck"
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub et
|
||||
#: sur le cache de révocations draco.
|
||||
CACHE_TTL = 60 # secondes
|
||||
|
||||
ROLES_CONNUS = ("aucun", "utilisateur", "administrateur")
|
||||
|
||||
# Cache mémoire par sub : sub -> rôle connu (str) ou None (échec déjà
|
||||
# tenté dans la fenêtre — l'ancien résultat reste servi, voir
|
||||
# ``role_pour``). ``_cache_fetched_at`` est l'horodatage global du
|
||||
# dernier appel, posé avant l'appel (patron révocations).
|
||||
_cache: dict[str, str | None] = {}
|
||||
_cache_fetched_at: float = 0.0
|
||||
|
||||
|
||||
def _consulter_hub(cookie: str) -> str | None:
|
||||
"""Un appel au Hub ; retourne le rôle validé ou None (inconnu)."""
|
||||
from app.services import draco_urls
|
||||
|
||||
base = draco_urls.hub_url()
|
||||
url = f"{base}/api/roles/{APP}"
|
||||
try:
|
||||
import httpx
|
||||
|
||||
r = httpx.get(
|
||||
url,
|
||||
headers={"Cookie": cookie, "Accept": "application/json"},
|
||||
timeout=2.0,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
role = r.json().get("role")
|
||||
except Exception as exc: # réseau, JSON illisible… → repli
|
||||
logger.debug("Registre des rôles du Hub indisponible (%s): %s", url, exc)
|
||||
return None
|
||||
if role not in ROLES_CONNUS:
|
||||
return None
|
||||
return role
|
||||
|
||||
|
||||
def role_pour(cookie: str, sub: str) -> str | None:
|
||||
"""Niveau connu pour ce ``sub`` : ``aucun`` | ``utilisateur`` |
|
||||
``administrateur`` si le Hub a répondu, None si le rôle est
|
||||
inconnu (Hub injoignable/erreur) — repli = comportement actuel.
|
||||
|
||||
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
|
||||
quel au Hub ; ``sub`` est le sujet draco prouvé localement.
|
||||
Cache mémoire par ``sub``, TTL 60 s, horodatage posé avant
|
||||
l'appel : pendant la fenêtre qui suit un échec, l'ancien cache
|
||||
reste en vigueur (pas de rafale vers le Hub).
|
||||
"""
|
||||
global _cache_fetched_at
|
||||
if not sub:
|
||||
return None
|
||||
now = time.monotonic()
|
||||
if now - _cache_fetched_at < CACHE_TTL and sub in _cache:
|
||||
return _cache[sub]
|
||||
# Horodatage posé AVANT l'appel (patron révocations du pilote).
|
||||
ancien = _cache.get(sub)
|
||||
_cache_fetched_at = now
|
||||
resultat = _consulter_hub(cookie)
|
||||
if resultat is not None:
|
||||
_cache[sub] = resultat
|
||||
return resultat
|
||||
# Échec : l'ancien cache reste en vigueur.
|
||||
logger.debug(
|
||||
"Registre des rôles du Hub indisponible — comportement actuel conservé")
|
||||
return ancien
|
||||
|
||||
|
||||
def role_draco(request) -> str | None:
|
||||
"""Rôle du porteur draco de cette requête, ou None (inconnu).
|
||||
|
||||
None aussi quand il n'y a pas de session draco prouvée : pas de
|
||||
cookie ``draco_session``, jeton invalide/expiré/révoqué, ou SSO
|
||||
draco non configuré — l'appelant garde son comportement actuel.
|
||||
Le ``sub`` vient de la vérification locale du jeton (mêmes
|
||||
sémantiques que ``user_for_draco``, sans résolution de compte ni
|
||||
provisionnement).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return None
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
jti = str(claims.get("jti", ""))
|
||||
if jti and draco.is_revoked(jti):
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
# En-tête Cookie complet de la requête, relayé tel quel au Hub.
|
||||
cookie = request.headers.get("cookie", "")
|
||||
return role_pour(cookie, sub)
|
||||
|
||||
|
||||
def admin_hub_ok(request) -> bool:
|
||||
"""Vrai si les surfaces d'administration restent ouvertes.
|
||||
|
||||
Session draco + rôle connu → exiger ``administrateur``. Pas de
|
||||
session draco (session locale par mot de passe, jeton d'API
|
||||
propre à Flowdeck, OAuth externe) ou rôle inconnu (Hub
|
||||
injoignable) : comportement actuel (True).
|
||||
"""
|
||||
from app.services import draco
|
||||
|
||||
if not draco.enabled():
|
||||
return True
|
||||
claims = draco.verify_token(request.cookies.get(draco.COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return True
|
||||
role = role_draco(request)
|
||||
if role is None:
|
||||
return True
|
||||
return role == "administrateur"
|
||||
@@ -150,6 +150,34 @@
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% else %} class="has-draco-bar"{% endif %}>
|
||||
{% if not embed_mode %}{% include "_draco_bar.html" %}{% endif %}
|
||||
{% if not embed_mode %}{% set _banniere_hub = banniere_hub() %}{% if _banniere_hub %}
|
||||
<div class="banniere-hub" id="banniere-hub" role="status" hidden>
|
||||
<span class="banniere-hub-texte">{{ _banniere_hub }}</span>
|
||||
<button type="button" class="banniere-hub-fermer" id="banniere-hub-fermer" aria-label="Masquer la bannière" title="Masquer pour cette session du navigateur">×</button>
|
||||
</div>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||
masquage par l'utilisateur pour la session du navigateur —
|
||||
sessionStorage, valeur = texte de la bannière (une nouvelle
|
||||
annonce réapparaît donc toujours). La consultation du Hub se
|
||||
fait côté serveur (app.services.hub_reglages) ; le texte est
|
||||
échappé par l'autoescape Jinja. Sans JS, le bandeau reste
|
||||
masqué (attribut hidden). */
|
||||
(function () {
|
||||
var bandeau = document.getElementById("banniere-hub");
|
||||
if (!bandeau) return;
|
||||
var texte = {{ _banniere_hub | tojson }};
|
||||
try {
|
||||
if (sessionStorage.getItem("flowdeck_banniere_masquee") === texte) return;
|
||||
} catch (e) { /* stockage indisponible : on affiche */ }
|
||||
bandeau.hidden = false;
|
||||
document.getElementById("banniere-hub-fermer").addEventListener("click", function () {
|
||||
bandeau.hidden = true;
|
||||
try { sessionStorage.setItem("flowdeck_banniere_masquee", texte); } catch (err) { /* tant pis */ }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}{% endif %}
|
||||
<div class="app-layout" x-data="appState()" x-cloak>
|
||||
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
||||
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
||||
<main class="rp-page" id="recherche" data-host="flowdeck"
|
||||
data-recherche-partout="{{ 'true' if recherche_partout else 'false' }}"
|
||||
data-base-obsigate="{{ bases.obsigate }}"
|
||||
data-base-flowdeck="{{ bases.flowdeck }}"
|
||||
data-base-tiktak="{{ bases.tiktak }}"
|
||||
|
||||
@@ -91,6 +91,12 @@ ENV.globals["app_base_url"] = lambda: _app_base_url()
|
||||
ENV.globals["draco_urls"] = lambda: _draco_urls()
|
||||
ENV.globals["draco_agent_url"] = lambda: _draco_urls()["agent"]
|
||||
ENV.globals["gitea_url"] = lambda: _draco_urls()["gitea"]
|
||||
# Bannière d'annonce des réglages généraux du Hub (HUB-21) : la
|
||||
# FONCTION est exposée (pas un instantané) — chaque rendu du
|
||||
# gabarit principal lit le cache serveur de 60 s de
|
||||
# app.services.hub_reglages. Hub muet → chaîne vide → aucune
|
||||
# bannière, comme aujourd'hui.
|
||||
ENV.globals["banniere_hub"] = lambda: _banniere_hub()
|
||||
|
||||
|
||||
def _app_base_url() -> str:
|
||||
@@ -105,6 +111,12 @@ def _draco_urls() -> dict:
|
||||
return draco_urls.toutes()
|
||||
|
||||
|
||||
def _banniere_hub() -> str:
|
||||
from app.services import hub_reglages
|
||||
|
||||
return hub_reglages.banniere()
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
from app.services.plugins import is_enabled
|
||||
|
||||
|
||||
@@ -5342,3 +5342,21 @@ body.has-draco-bar .app-layout { height: calc(100vh - 60px); }
|
||||
background: transparent; cursor: pointer; font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-erreur { color: #c0392b; font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
/* Bannière d'annonce des réglages généraux du Hub (HUB-21) :
|
||||
bandeau rendu par base.html sous la barre commune, patron du
|
||||
pilote TikTak — accent Flowdeck, texte centré. */
|
||||
.banniere-hub {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--accent, #2f6fed); color: #fff;
|
||||
font-size: 14px; line-height: 1.35;
|
||||
}
|
||||
.banniere-hub[hidden] { display: none; }
|
||||
.banniere-hub-texte { flex: 1 1 auto; text-align: center; overflow-wrap: anywhere; }
|
||||
.banniere-hub-fermer {
|
||||
flex: 0 0 auto; border: 0; background: transparent; color: inherit;
|
||||
font-size: 18px; line-height: 1; cursor: pointer; padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.banniere-hub-fermer:hover { background: rgba(255, 255, 255, .18); }
|
||||
|
||||
@@ -64,6 +64,25 @@
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
}
|
||||
|
||||
// Thème commun de la suite (HUB-18, fusion #132) : la barre
|
||||
// commune (barre v0.5.0) applique la préférence du Hub à
|
||||
// l'ouverture — directement sur data-theme depuis son cache, et
|
||||
// via l'événement « draco:theme » quand le fetch aboutit. Le mode
|
||||
// effectif est mémorisé dans la clé locale « theme » (convergence :
|
||||
// la préférence du Hub devient le choix local courant) ; le
|
||||
// sélecteur de thème de Flowdeck (Réglages) reste le maître pour
|
||||
// la suite de la session. Repli : si le Hub ne répond pas, aucun
|
||||
// événement n'arrive et le choix local ci-dessus reste seul
|
||||
// maître, comme avant. app.js (defer) s'exécute avant la barre :
|
||||
// l'écouteur est donc toujours posé à temps pour le fetch.
|
||||
document.addEventListener('draco:theme', function(e) {
|
||||
const mode = e && e.detail && e.detail.mode;
|
||||
if (mode === 'dark' || mode === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
try { localStorage.setItem('theme', mode); } catch (err) { /* stockage indisponible */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Close context menu on click outside
|
||||
document.addEventListener('click', function(e) {
|
||||
const menu = document.getElementById('context-menu');
|
||||
|
||||
+74
-2
@@ -246,6 +246,21 @@
|
||||
return [tab];
|
||||
}
|
||||
|
||||
/* HUB-21 (propagation des réglages généraux du Hub) : le drapeau
|
||||
* « Rechercher partout », rendu par l'hôte dans l'attribut
|
||||
* data-recherche-partout de #recherche (« true »/« false », lu
|
||||
* côté serveur depuis GET {hub}/api/reglages/effectifs, cache
|
||||
* 60 s, patron du pilote TikTak 0.61.22). Seules les valeurs
|
||||
* explicitement négatives désactivent la fédération ; attribut
|
||||
* absent ou illisible = fédération active — le comportement
|
||||
* actuel, qui est aussi le repli quand le Hub est muet.
|
||||
* Fonction pure, exposée aux tests. */
|
||||
function federationAutorisee(valeur) {
|
||||
if (valeur == null) return true;
|
||||
var v = String(valeur).trim().toLowerCase();
|
||||
return v !== "false" && v !== "0" && v !== "non";
|
||||
}
|
||||
|
||||
/* Troncature d'affichage : liste complète dans l'onglet propre à
|
||||
* l'application, PER_APP_LIMIT partout ailleurs (vue « Tout »). */
|
||||
function pageItems(items, full) {
|
||||
@@ -514,6 +529,7 @@
|
||||
visibleKeys: visibleKeys,
|
||||
pageItems: pageItems,
|
||||
buildSearchUrl: buildSearchUrl,
|
||||
federationAutorisee: federationAutorisee,
|
||||
tabCounts: tabCounts,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
@@ -609,6 +625,14 @@
|
||||
".</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "desactivee") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Recherche fédérée désactivée dans les ' +
|
||||
"réglages généraux de la suite (Hub) : cette application " +
|
||||
"n'est pas interrogée.</p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "idle") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
@@ -651,6 +675,8 @@
|
||||
var input = document.getElementById("rp-q");
|
||||
if (!input) return;
|
||||
var hostApp = root.getAttribute("data-host") || "";
|
||||
var federation = federationAutorisee(
|
||||
root.getAttribute("data-recherche-partout"));
|
||||
var explicit = {};
|
||||
APPS.forEach(function (app) {
|
||||
var v = root.getAttribute("data-base-" + app.key);
|
||||
@@ -772,7 +798,23 @@
|
||||
applyTab(tab, opts);
|
||||
/* La console n'est interrogée qu'une fois affichée. */
|
||||
if (parseTab(tab) === "console" && currentQ && consoleFetchedQ !== currentQ) {
|
||||
fetchConsole(currentQ, runId);
|
||||
if (!federation) {
|
||||
var secConsOnglet = sections.console;
|
||||
if (secConsOnglet) {
|
||||
states.console = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: currentQ,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(secConsOnglet, states.console, true);
|
||||
renderTabs();
|
||||
}
|
||||
} else {
|
||||
fetchConsole(currentQ, runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,6 +907,22 @@
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
if (!federation && app.key !== hostApp) {
|
||||
/* HUB-21 : fédération désactivée par le Hub — la section
|
||||
* sœur n'est pas interrogée, la recherche locale
|
||||
* (app.key === hostApp, même origine) continue. */
|
||||
states[app.key] = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: bases[app.key],
|
||||
app: app.key,
|
||||
};
|
||||
renderSection(sec, states[app.key], activeTab === app.key);
|
||||
renderTabs();
|
||||
return;
|
||||
}
|
||||
states[app.key] = {
|
||||
status: "loading",
|
||||
items: [],
|
||||
@@ -915,7 +973,21 @@
|
||||
});
|
||||
/* Console : seulement si son onglet est affiché ; sinon son
|
||||
* compteur reste vide (pas de faux 0) jusqu'à son activation. */
|
||||
if (activeTab === "console") {
|
||||
if (activeTab === "console" && !federation) {
|
||||
var secConsRecherche = sections.console;
|
||||
if (secConsRecherche) {
|
||||
states.console = {
|
||||
status: "desactivee",
|
||||
items: [],
|
||||
message: "",
|
||||
q: q,
|
||||
base: consoleBase,
|
||||
app: "console",
|
||||
};
|
||||
renderSection(secConsRecherche, states.console, true);
|
||||
renderTabs();
|
||||
}
|
||||
} else if (activeTab === "console") {
|
||||
fetchConsole(q, myRun);
|
||||
} else {
|
||||
states.console = {
|
||||
|
||||
Vendored
+135
@@ -36,6 +36,12 @@
|
||||
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
|
||||
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
|
||||
* comportement : liens résolus et Déconnexion relayée inchangés.
|
||||
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
|
||||
* la barre apprend la préférence de thème du profil du Hub
|
||||
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
*
|
||||
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||
@@ -268,6 +274,131 @@
|
||||
}
|
||||
var resolvedUrls = resolveUrls();
|
||||
|
||||
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
|
||||
* La préférence de thème de la suite vit dans le profil du Hub :
|
||||
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
|
||||
* authentifié par le cookie draco (credentials: "include").
|
||||
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
|
||||
* défaut officiel) — jamais un nom figé de plus.
|
||||
* Application par le mécanisme data-theme existant des hôtes :
|
||||
* "sombre" → data-theme="dark", "clair" → data-theme="light",
|
||||
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
|
||||
* html:not([data-theme]) du CSS de la barre comme des hôtes).
|
||||
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
|
||||
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
|
||||
* propre doit suivre (icônes et libellés, feuilles de style,
|
||||
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
|
||||
* s'initialise après la barre lit la même information dans
|
||||
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
|
||||
* Ordre d'application : 1) le cache local FRAIS est appliqué de
|
||||
* façon SYNCHRONE à l'initialisation de la barre (script en defer
|
||||
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
|
||||
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
|
||||
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
|
||||
* a changé. À la toute première visite (aucun cache), un bref
|
||||
* flash du thème local de l'hôte reste possible le temps du fetch :
|
||||
* c'est le prix du repli — l'état affiché transitoire est le thème
|
||||
* que l'hôte aurait de toute façon choisi seul, jamais un état
|
||||
* cassé, et les visites suivantes sont servies par le cache.
|
||||
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
|
||||
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
|
||||
* jamais le choix local de l'hôte.
|
||||
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
|
||||
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
|
||||
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
|
||||
* existant. Le comportement actuel de l'hôte (son localStorage,
|
||||
* puis le système) reste alors exactement le maître, comme avant
|
||||
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
|
||||
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
|
||||
* fetch) ; elle ne ré-applique ensuite que sur changement du
|
||||
* schéma système, et seulement tant que la préférence résolue est
|
||||
* "systeme". */
|
||||
var THEME_CACHE_CLE = "draco-theme-hub";
|
||||
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
|
||||
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
|
||||
|
||||
function themeSystemeEstSombre() {
|
||||
try {
|
||||
return !!(window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function lireCacheTheme() {
|
||||
try {
|
||||
var brut = window.localStorage &&
|
||||
window.localStorage.getItem(THEME_CACHE_CLE);
|
||||
if (!brut) return null;
|
||||
var c = JSON.parse(brut);
|
||||
if (!c || typeof c.ts !== "number") return null;
|
||||
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
|
||||
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
|
||||
return c.theme;
|
||||
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
|
||||
return null;
|
||||
}
|
||||
function ecrireCacheTheme(theme) {
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.setItem(THEME_CACHE_CLE,
|
||||
JSON.stringify({ theme: theme, ts: Date.now() }));
|
||||
}
|
||||
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
|
||||
}
|
||||
function appliquerThemeCommun(theme) {
|
||||
var root = document.documentElement;
|
||||
if (!root) return;
|
||||
if (theme === "sombre" || theme === "clair") {
|
||||
themeMode = theme === "sombre" ? "dark" : "light";
|
||||
root.setAttribute("data-theme", themeMode);
|
||||
} else if (theme === "systeme") {
|
||||
themeMode = themeSystemeEstSombre() ? "dark" : "light";
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
themePref = theme;
|
||||
if (window.DRACO_BAR) {
|
||||
window.DRACO_BAR.themePref = themePref;
|
||||
window.DRACO_BAR.themeMode = themeMode;
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent("draco:theme",
|
||||
{ detail: { theme: themePref, mode: themeMode } }));
|
||||
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
|
||||
}
|
||||
function initThemeCommun() {
|
||||
var enCache = lireCacheTheme();
|
||||
if (enCache) appliquerThemeCommun(enCache);
|
||||
/* Suivi du schéma système tant que la préférence est "systeme" :
|
||||
* sans data-theme, le CSS suit tout seul ; l'événement est
|
||||
* re-dispatché pour les hôtes qui matérialisent le mode. */
|
||||
try {
|
||||
var mq = window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)");
|
||||
if (mq && mq.addEventListener) {
|
||||
mq.addEventListener("change", function () {
|
||||
if (themePref === "systeme") appliquerThemeCommun("systeme");
|
||||
});
|
||||
}
|
||||
} catch (e) { /* pas de matchMedia : pas de suivi */ }
|
||||
if (typeof window.fetch !== "function") return;
|
||||
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
|
||||
"api/profil/theme";
|
||||
window.fetch(url, { credentials: "include" }).then(function (resp) {
|
||||
if (!resp || !resp.ok) return null;
|
||||
return resp.json();
|
||||
}).then(function (data) {
|
||||
if (!data) return;
|
||||
var t = data.theme;
|
||||
if (t === "sombre" || t === "clair" || t === "systeme") {
|
||||
ecrireCacheTheme(t);
|
||||
appliquerThemeCommun(t);
|
||||
}
|
||||
/* theme null ou inconnu : repli intégral, rien n'est touché. */
|
||||
}).catch(function () { /* Hub injoignable : repli intégral */ });
|
||||
}
|
||||
|
||||
function setCounts(counts) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
@@ -695,6 +826,9 @@
|
||||
window.DRACO_BAR = {
|
||||
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||
urls: resolvedUrls,
|
||||
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
|
||||
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
|
||||
themePref: null, themeMode: null,
|
||||
setUrls: function (urls) {
|
||||
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||
resolvedUrls = resolveUrls();
|
||||
@@ -709,6 +843,7 @@
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -197,4 +197,17 @@ ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide re
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
/* federationAutorisee (HUB-21) : seules les valeurs explicitement
|
||||
négatives désactivent la fédération ; absent/illisible = actif
|
||||
(repli = comportement actuel, Hub muet compris). */
|
||||
ok(rp.federationAutorisee("false") === false, "fédération : false désactive");
|
||||
ok(rp.federationAutorisee("0") === false, "fédération : 0 désactive");
|
||||
ok(rp.federationAutorisee(" FALSE ") === false, "fédération : casse/espaces ignorés");
|
||||
ok(rp.federationAutorisee("true") === true, "fédération : true active");
|
||||
ok(rp.federationAutorisee("1") === true, "fédération : 1 active");
|
||||
ok(rp.federationAutorisee(null) === true, "fédération : attribut absent = actif");
|
||||
ok(rp.federationAutorisee(undefined) === true, "fédération : undefined = actif");
|
||||
ok(rp.federationAutorisee("") === true, "fédération : vide = actif");
|
||||
ok(rp.federationAutorisee("nimporte") === true, "fédération : illisible = actif");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
|
||||
@@ -115,3 +115,19 @@ def test_barre_intacte_apres_suppression():
|
||||
# draco-barre.js depuis les onglets : l'onglet agent suffit a l'alimenter.
|
||||
js_barre = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert "Console Spark" in js_barre
|
||||
|
||||
|
||||
def test_barre_v050_theme_commun_hub18():
|
||||
# v0.5.0 (HUB-18, fusion #132) : la barre apprend la préférence
|
||||
# de thème du Hub et l'applique à l'ouverture. Vendange verbatim
|
||||
# commons ; l'initialisation propre à Flowdeck vit dans app.js
|
||||
# (écouteur draco:theme → data-theme + clé locale « theme »).
|
||||
js = (ROOT / "static/js/vendor/draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "api/profil/theme" in js
|
||||
assert 'credentials: "include"' in js
|
||||
assert "draco-theme-hub" in js
|
||||
assert 'CustomEvent("draco:theme"' in js
|
||||
assert "themePref" in js and "themeMode" in js
|
||||
assert "resolvedUrls.hub" in js
|
||||
app = (ROOT / "static/js/app.js").read_text(encoding="utf-8")
|
||||
assert 'addEventListener("draco:theme"' in app or "addEventListener('draco:theme'" in app
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""HUB-21 — réglages généraux du Hub consommés par Flowdeck.
|
||||
|
||||
Propagation du pilote TikTak 0.61.22 : client serveur
|
||||
``app.services.hub_reglages`` (cache 60 s, horodatage posé avant
|
||||
l'appel, repli intégral sur les défauts actuels), bannière
|
||||
d'annonce dans le gabarit principal (texte échappé par
|
||||
l'autoescape Jinja) et drapeau ``recherche_partout`` rendu en
|
||||
attribut ``data-recherche-partout`` de la page /recherche (la
|
||||
logique JS associée est couverte par tests/recherche.test.js).
|
||||
``domaine_defaut`` n'est pas appliqué : Flowdeck n'a pas de
|
||||
notion de domaine propre (voir le docstring du module).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import hub_reglages
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cache_propre():
|
||||
hub_reglages.reinitialiser_cache()
|
||||
yield
|
||||
hub_reglages.reinitialiser_cache()
|
||||
|
||||
|
||||
def _pose_reglages(monkeypatch, reglages, compteur=None):
|
||||
"""Simule la consultation du Hub (appel réseau évité)."""
|
||||
def faux_consulter():
|
||||
if compteur is not None:
|
||||
compteur.append(1)
|
||||
return reglages
|
||||
monkeypatch.setattr(hub_reglages, "_consulter_hub", faux_consulter)
|
||||
|
||||
|
||||
# ── Client : validation, cache, repli ───────────────────────────────
|
||||
|
||||
def test_validation_forme_du_contrat():
|
||||
assert hub_reglages._valider({
|
||||
"recherche_partout": False, "banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille", "ttl_secondes": 60}) == {
|
||||
"recherche_partout": False, "banniere": "Maintenance dimanche",
|
||||
"domaine_defaut": "famille"}
|
||||
for charge in (
|
||||
None, "texte", ["x"], {},
|
||||
{"recherche_partout": "oui", "banniere": "", "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": 42, "domaine_defaut": None},
|
||||
{"recherche_partout": True, "banniere": "", "domaine_defaut": "travail"},
|
||||
):
|
||||
assert hub_reglages._valider(charge) is None, charge
|
||||
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||
import httpx
|
||||
|
||||
from app.services import draco_urls
|
||||
monkeypatch.setattr(draco_urls, "hub_url",
|
||||
lambda: "https://hub.example")
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
if appels is not None:
|
||||
appels.append((url, headers, timeout))
|
||||
if isinstance(reponse_ou_exception, Exception):
|
||||
raise reponse_ou_exception
|
||||
return reponse_ou_exception
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
|
||||
|
||||
def test_consultation_contrat_exact(monkeypatch):
|
||||
appels = []
|
||||
_pose_http(monkeypatch, _Reponse(200, {
|
||||
"recherche_partout": True, "banniere": "Annonce",
|
||||
"domaine_defaut": None, "ttl_secondes": 60}), appels)
|
||||
assert hub_reglages._consulter_hub() == {
|
||||
"recherche_partout": True, "banniere": "Annonce",
|
||||
"domaine_defaut": None}
|
||||
url, headers, timeout = appels[0]
|
||||
assert url == "https://hub.example/api/reglages/effectifs"
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert "Cookie" not in headers # endpoint public
|
||||
assert timeout == 2.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reponse", [
|
||||
_Reponse(500, {}),
|
||||
_Reponse(200, {"recherche_partout": "oui", "banniere": "",
|
||||
"domaine_defaut": None}),
|
||||
_Reponse(200, ValueError("json illisible")),
|
||||
ConnectionError("hub injoignable"),
|
||||
])
|
||||
def test_consultation_erreurs_repli_none(monkeypatch, reponse):
|
||||
_pose_http(monkeypatch, reponse)
|
||||
assert hub_reglages._consulter_hub() is None
|
||||
|
||||
|
||||
def test_hub_muet_repli_defauts(monkeypatch):
|
||||
_pose_reglages(monkeypatch, None)
|
||||
assert hub_reglages.reglages_effectifs() == hub_reglages.DEFAUTS
|
||||
assert hub_reglages.banniere() == ""
|
||||
assert hub_reglages.recherche_partout() is True
|
||||
|
||||
|
||||
def test_cache_60_s(monkeypatch):
|
||||
appels = []
|
||||
_pose_reglages(monkeypatch, {
|
||||
"recherche_partout": False, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert hub_reglages.recherche_partout() is False
|
||||
assert len(appels) == 1 # cache 60 s
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
assert len(appels) == 2 # expiration → rappel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_en_vigueur(monkeypatch):
|
||||
appels = []
|
||||
_pose_reglages(monkeypatch, {
|
||||
"recherche_partout": True, "banniere": "Annonce A",
|
||||
"domaine_defaut": None}, appels)
|
||||
assert hub_reglages.banniere() == "Annonce A"
|
||||
hub_reglages._cache_at = time.monotonic() - 61
|
||||
_pose_reglages(monkeypatch, None, appels) # Hub tombe
|
||||
assert hub_reglages.banniere() == "Annonce A" # ancien cache
|
||||
assert len(appels) == 2 # pas de rafale
|
||||
|
||||
|
||||
# ── Intégration : bannière et drapeau dans les pages ────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def hub_banniere(monkeypatch):
|
||||
etat = {"reglages": None}
|
||||
monkeypatch.setattr(
|
||||
hub_reglages, "_consulter_hub", lambda: etat["reglages"])
|
||||
hub_reglages.reinitialiser_cache()
|
||||
return etat
|
||||
|
||||
|
||||
def test_banniere_presente_et_echappee(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True,
|
||||
"banniere": "Maintenance dimanche <b>gras</b> & cie",
|
||||
"domaine_defaut": None}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert 'id="banniere-hub"' in r.text
|
||||
assert "Maintenance dimanche <b>gras</b> & cie" in r.text
|
||||
assert "sessionStorage" in r.text
|
||||
assert "flowdeck_banniere_masquee" in r.text
|
||||
|
||||
|
||||
def test_banniere_absente_si_vide_ou_hub_muet(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": "pro"}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
hub_banniere["reglages"] = None
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert "banniere-hub" not in r.text
|
||||
|
||||
|
||||
def test_recherche_drapeau_rendu_dans_la_coquille(client, hub_banniere):
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": False, "banniere": "", "domaine_defaut": None}
|
||||
r = client.get("/recherche")
|
||||
assert r.status_code == 200
|
||||
assert 'data-recherche-partout="false"' in r.text
|
||||
hub_banniere["reglages"] = {
|
||||
"recherche_partout": True, "banniere": "", "domaine_defaut": None}
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
hub_banniere["reglages"] = None # Hub muet → défaut actif
|
||||
hub_reglages.reinitialiser_cache()
|
||||
r = client.get("/recherche")
|
||||
assert 'data-recherche-partout="true"' in r.text
|
||||
|
||||
|
||||
def test_version_7_85_3():
|
||||
from pathlib import Path
|
||||
version = (Path(__file__).parent.parent / "VERSION").read_text().strip()
|
||||
assert version == "7.85.3"
|
||||
@@ -0,0 +1,308 @@
|
||||
"""HUB-19 — propagation des rôles du Hub à Flowdeck (suite du pilote
|
||||
ShaarIt 0.7.0) : client ``app.services.hub_roles`` et ses effets.
|
||||
|
||||
Contrat D3(a) : à l'ouverture de session draco, consultation de
|
||||
``GET {hub}/api/roles/flowdeck`` (cookie complet relayé, délai 2 s),
|
||||
cache mémoire par ``sub`` TTL 60 s, horodatage posé avant l'appel.
|
||||
Toute erreur = rôle inconnu → comportement actuel conservé.
|
||||
|
||||
Effets vérifiés ici :
|
||||
- rôle connu ``aucun`` → 403 en français au pont ``GET /auth/draco``,
|
||||
avant la création de la session locale et avant le provisionnement
|
||||
JIT HUB-15 (aucun compte créé pour un ``sub`` refusé) ;
|
||||
- session locale préexistante exemptée (accès de secours) ;
|
||||
- surfaces d'administration d'instance (``/api/admin/*``) : session
|
||||
draco + rôle connu → ``administrateur`` exigé ;
|
||||
- rôle inconnu (Hub injoignable/erreur/rôle hors ensemble) → repli
|
||||
intégral.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from conftest import anon
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import draco, hub_roles
|
||||
|
||||
SECRET = "secret-partage-de-test"
|
||||
|
||||
|
||||
def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-hub19", 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)}"
|
||||
|
||||
|
||||
def _requete(cookie_complet="", draco_token=None):
|
||||
cookies = {}
|
||||
headers = {}
|
||||
if draco_token is not None:
|
||||
cookies["draco_session"] = draco_token
|
||||
complet = f"draco_session={draco_token}"
|
||||
headers["cookie"] = complet if not cookie_complet else cookie_complet
|
||||
elif cookie_complet:
|
||||
headers["cookie"] = cookie_complet
|
||||
return SimpleNamespace(cookies=cookies, headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _caches_propres(monkeypatch):
|
||||
monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
|
||||
monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
yield
|
||||
draco._revoked_cache.clear()
|
||||
hub_roles._cache.clear()
|
||||
hub_roles._cache_fetched_at = 0.0
|
||||
|
||||
|
||||
def _pose_role(monkeypatch, role, compteur=None):
|
||||
"""Simule la réponse du Hub pour ``role_pour`` (appel réseau évité)."""
|
||||
def faux_consulter(cookie):
|
||||
if compteur is not None:
|
||||
compteur.append(cookie)
|
||||
return role
|
||||
monkeypatch.setattr(hub_roles, "_consulter_hub", faux_consulter)
|
||||
|
||||
|
||||
# ── Client : cache par sub, TTL, horodatage avant l'appel ───────────
|
||||
|
||||
def test_role_connu_mis_en_cache_par_sub(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert hub_roles.role_pour("cookie-a", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 1 # 2e lecture : cache
|
||||
assert hub_roles.role_pour("cookie-b", "sub-2") == "utilisateur"
|
||||
assert len(appels) == 2 # autre sub : nouvel appel
|
||||
|
||||
|
||||
def test_echec_ancien_cache_conserve_pas_de_rafale(monkeypatch):
|
||||
appels = []
|
||||
_pose_role(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
# Fenêtre expirée, Hub désormais en échec : l'ancien cache reste.
|
||||
hub_roles._cache_fetched_at = time.monotonic() - 61
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
# Pas de rafale : dans la fenêtre qui suit l'échec, aucun rappel.
|
||||
assert hub_roles.role_pour("c", "sub-1") == "utilisateur"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_echec_sans_cache_role_inconnu(monkeypatch):
|
||||
# Fidèle au pilote : sans entrée en cache pour ce sub, l'échec
|
||||
# rend « inconnu » et la demande suivante retente (la protection
|
||||
# anti-rafale de l'horodatage posé avant l'appel ne sert que les
|
||||
# subs déjà en cache — test précédent).
|
||||
appels = []
|
||||
_pose_role(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert hub_roles.role_pour("c", "sub-x") is None
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_role_sans_sub_inconnu(monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
assert hub_roles.role_pour("c", "") is None
|
||||
|
||||
|
||||
# ── Client : validation de la réponse du Hub ────────────────────────
|
||||
|
||||
class _Reponse:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def _pose_http(monkeypatch, reponse_ou_exception, appels=None):
|
||||
import httpx
|
||||
|
||||
from app.services import draco_urls
|
||||
monkeypatch.setattr(draco_urls, "hub_url", lambda: "https://hub.example")
|
||||
|
||||
def faux_get(url, headers=None, timeout=None):
|
||||
if appels is not None:
|
||||
appels.append((url, headers, timeout))
|
||||
if isinstance(reponse_ou_exception, Exception):
|
||||
raise reponse_ou_exception
|
||||
return reponse_ou_exception
|
||||
|
||||
monkeypatch.setattr(httpx, "get", faux_get)
|
||||
|
||||
|
||||
def test_consultation_contrat_exact(monkeypatch):
|
||||
appels = []
|
||||
_pose_http(monkeypatch, _Reponse(200, {
|
||||
"app": "flowdeck", "sub": "sub-1", "role": "administrateur",
|
||||
"explicite": True, "ttl_secondes": 60}), appels)
|
||||
cookie = "draco_session=jeton; autre=1"
|
||||
assert hub_roles._consulter_hub(cookie) == "administrateur"
|
||||
url, headers, timeout = appels[0]
|
||||
assert url == "https://hub.example/api/roles/flowdeck"
|
||||
assert headers["Cookie"] == cookie # cookie complet relayé
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert timeout == 2.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reponse", [
|
||||
_Reponse(401, {}), _Reponse(404, {}), _Reponse(500, {}),
|
||||
_Reponse(200, {"role": "superadmin"}), # hors ensemble
|
||||
_Reponse(200, {"role": None}),
|
||||
_Reponse(200, ValueError("json illisible")),
|
||||
ConnectionError("hub injoignable"),
|
||||
])
|
||||
def test_consultation_erreurs_role_inconnu(monkeypatch, reponse):
|
||||
_pose_http(monkeypatch, reponse)
|
||||
assert hub_roles._consulter_hub("c") is None
|
||||
|
||||
|
||||
# ── role_draco / admin_hub_ok ───────────────────────────────────────
|
||||
|
||||
def test_role_draco_sans_cookie_inconnu():
|
||||
assert hub_roles.role_draco(_requete()) is None
|
||||
|
||||
|
||||
def test_role_draco_avec_cookie(monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
assert hub_roles.role_draco(_requete(draco_token=_jeton())) == "administrateur"
|
||||
|
||||
|
||||
def test_admin_hub_ok_sans_draco_comportement_actuel():
|
||||
assert hub_roles.admin_hub_ok(_requete()) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role,attendu", [
|
||||
("administrateur", True), ("utilisateur", False), ("aucun", False),
|
||||
(None, True), # inconnu → repli
|
||||
])
|
||||
def test_admin_hub_ok_selon_role(monkeypatch, role, attendu):
|
||||
_pose_role(monkeypatch, role)
|
||||
assert hub_roles.admin_hub_ok(_requete(draco_token=_jeton())) is attendu
|
||||
|
||||
|
||||
# ── Intégration : pont /auth/draco ──────────────────────────────────
|
||||
|
||||
def _compte_utilisateurs():
|
||||
with get_conn() as conn:
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
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 test_pont_refus_aucun_403_en_francais(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert "registre des rôles" in r.text and "Hub" in r.text
|
||||
assert "« aucun »" in r.text
|
||||
assert "flowdeck_session" not in r.headers.get("set-cookie", "")
|
||||
assert c.get("/auth/user").json()["authenticated"] is False
|
||||
|
||||
|
||||
def test_pont_refus_aucun_avant_provisionnement_jit(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-refuse", name="Refusée"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
# Ordre vérifié : refus avant JIT — aucun compte, aucun lien créés.
|
||||
assert _compte_utilisateurs() == avant
|
||||
with get_conn() as conn:
|
||||
assert conn.execute(
|
||||
"SELECT COUNT(*) FROM draco_links WHERE sub = 'og-refuse'"
|
||||
).fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_pont_role_utilisateur_jit_inchange(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_ajoute_deuxieme_utilisateur()
|
||||
avant = _compte_utilisateurs()
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
c.cookies.set("draco_session", _jeton(sub="og-nouveau", name="Nouvelle"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
assert _compte_utilisateurs() == avant + 1 # JIT HUB-15 intact
|
||||
|
||||
|
||||
def test_pont_role_inconnu_repli_comportement_actuel(client, monkeypatch):
|
||||
c = anon(client)
|
||||
_pose_role(monkeypatch, None) # Hub injoignable
|
||||
c.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = c.get("/auth/draco", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/workspaces"
|
||||
|
||||
|
||||
def test_pont_session_locale_preexistante_exemptee(client, monkeypatch):
|
||||
# Le client de la fixture porte déjà une session locale (admin de
|
||||
# test) : patron du pilote — l'accès de secours n'est pas refusé.
|
||||
_pose_role(monkeypatch, "aucun")
|
||||
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"
|
||||
|
||||
|
||||
# ── Intégration : surfaces d'administration (/api/admin/*) ──────────
|
||||
|
||||
def test_admin_instance_role_utilisateur_refuse(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "utilisateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert "registre" in r.text and "Hub" in r.text
|
||||
|
||||
|
||||
def test_admin_instance_role_administrateur_accepte(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "administrateur")
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_instance_role_inconnu_repli(client, monkeypatch):
|
||||
_pose_role(monkeypatch, None)
|
||||
client.cookies.set("draco_session", _jeton(sub="admin-1"))
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200 # comportement actuel
|
||||
|
||||
|
||||
def test_admin_instance_sans_draco_inchange(client, monkeypatch):
|
||||
_pose_role(monkeypatch, "aucun") # jamais consulté sans draco
|
||||
r = client.get("/api/admin/users")
|
||||
assert r.status_code == 200
|
||||
Reference in New Issue
Block a user