diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c1fafd..bd0c1f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,54 @@ # Changelog - FlowDeck +## v7.82.4 (2026-10-25) — Résolution dev/prod par l'hôte de la requête : le mode console du panneau revit en dev + +- **Bug corrigé (dev)** : sur flowdeck-dev, le mode console du + panneau Spark restait une page morte — prouvé en direct avant + correctif : la 7.82.3 tournait bien (`GET /api/health` → + `7.82.3`), mais sa CSP servait `frame-src 'self' + https://agent.dracodev.net` (et `connect-src` idem), pointant + l'agent **prod, pas encore installée**, et la source de l'iframe + console (`DRACO_PANNEAU_CONFIG.agent_url`, rendue par + `base.html`) valait la même origine morte. Deux trous laissés par + la v7.82.3 : (1) `agent_url()` n'avait **aucune dérivation** — + même avec `AGENT_EVENEMENTS_URL` en `-dev`, l'agent lui-même + retombait sur le nom officiel ; (2) **aucune** résolution ne + lisait l'hôte de la requête — un `.env` dev sans + `DRACO_AGENT_URL` ni `AGENT_EVENEMENTS_URL` retombait + entièrement sur la prod. +- **Cure** (`app/services/draco_urls.py`, `app/middleware/security.py`) : + pour tout ce qui est **rendu à une requête** (liens de la barre / + `DRACO_URLS` servi, source de l'iframe du panneau, origine agent + des CSP `frame-src`/`connect-src`, boutons et redirections SSO — + émettrice via `DRACO_SSO_URL` résolu, callback = + `OAUTH_REDIRECT_URI` dont l'origine est sa propre origine + résolue), l'ordre de résolution devient : 1) variable explicite + de l'installation, prioritaire et inchangée ; 2) **hôte public de + la requête** (posé par le middleware CSP dans un `ContextVar`, + même patron que le nonce CSP ; `X-Forwarded-Host` sinon `Host`) — + motif fermé `^(og|flowdeck|tiktak|kanbante|agent)()\.dracodev\.net$`, + suffixe optionnel (`-dev`, vide en prod) appliqué à toutes les + sœurs **et à l'agent** ; jamais de suffixe inventé depuis un hôte + arbitraire, hors motif (LAN, IP) on passe à la suite ; 3) + dérivation par les adresses de l'agent connues en configuration, + **élargie** (URL d'événements **ou** base du service — notamment + `agent_url()` se dérive désormais de l'URL d'événements + explicite) — aussi la résolution des contextes **sans requête**, + inchangée sinon ; 4) défaut officiel. L'iframe du panneau et la + CSP de la même réponse consomment la **même** origine résolue : + les deux lisent `agent_url()` pendant la même requête. +- Tests : `tests/test_draco_urls_devprod.py` +12 (25/25 au total) — + requête `Host: flowdeck-dev.dracodev.net` et **aucune variable + d'environnement** → barre, iframe, CSP et SSO rendus entièrement + en `-dev` ; Host prod → noms officiels ; Host LAN → repli + config/défauts ; explicite gagne sur l'hôte ; motif fermé + (hôtes arbitraires → aucune dérivation) ; agent dérivé du seul + `AGENT_EVENEMENTS_URL` ; contextes sans requête inchangés. + Suite complète relue : **4 failed / 26 errors, identiques sur + arbre propre** (1307 passed en propre ; familles préexistantes + MCP/realtime/my_tasks/gallery/sync, sans rapport avec ce + correctif) — 1319 passed avec le correctif. Ruff vert. + ## v7.82.3 (2026-10-25) — URLs sœurs dérivées de l'environnement de l'agent - **Bug corrigé (dev)** : les URL sœurs servies par `draco_urls()` diff --git a/VERSION b/VERSION index eeb49e3..8ec0b31 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.82.3 +7.82.4 diff --git a/app/main.py b/app/main.py index ab0262f..dd609e2 100644 --- a/app/main.py +++ b/app/main.py @@ -216,7 +216,7 @@ def _lire_version() -> str: # Littéral de version synchronisé avec le fichier VERSION : -# version="7.82.3" +# version="7.82.4" # 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() diff --git a/app/middleware/security.py b/app/middleware/security.py index 4724959..1a65711 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -134,8 +134,9 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): # service d'agents commun (premier parti, même programme) — # seul hôte externe nommé, rien d'autre ne s'ouvre. # v7.82.0 (dev/prod) : cet hôte n'est plus figé — {agent} est - # l'origine résolue par la config (DRACO_AGENT_URL ; défaut = - # nom officiel, un dev déclare son agent-dev dans le .env). + # l'origine résolue par draco_urls (DRACO_AGENT_URL explicite, + # sinon hôte de la requête — v7.82.4 —, sinon dérivation par + # l'agent connu en config ; défaut = nom officiel). # Même origine pour frame-src : la console du service est # encadrée en iframe par le panneau (surimpression v3). "connect-src 'self' {agent} ws://{host} wss://{host}; " @@ -152,6 +153,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): # tâche créée maintenant → le contexte (donc le nonce) y est copié, # exactement ce que les templates liront via `csp_nonce()`. CSP_NONCE.set(nonce) + # v7.82.4 : même patron pour l'hôte public de la requête — + # `draco_urls` le lit pour résoudre l'environnement (dev/prod) + # de tout ce qui est rendu à cette requête (barre, iframe du + # panneau, SSO), et la CSP ci-dessous consomme la MÊME origine + # agent résolue. X-Forwarded-Host prime, comme + # `get_redirect_uri()` (app/routers/auth.py). + from app.services import draco_urls + + draco_urls.definir_hote_requete( + request.headers.get("x-forwarded-host", "") + or request.headers.get("host", "")) response = await call_next(request) # Only set CSP on HTML responses content_type = response.headers.get("content-type", "") @@ -160,8 +172,6 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware): # on retire quand même tout caractère hors base URL par sécurité. host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "", request.headers.get("host", "")) - from app.services import draco_urls - agent = re.sub(r"[^0-9A-Za-z.\-:/]", "", draco_urls.origine(draco_urls.agent_url())) response.headers[self.CSP_HEADER] = self.CSP_VALUE.format( diff --git a/app/routers/auth.py b/app/routers/auth.py index 252d35a..2e2ccad 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -33,12 +33,14 @@ def get_redirect_uri(request: Request) -> str: return f"{scheme}://{host}/auth/callback" def _sso_login_url() -> str: - """URL du bouton « Login with ObsiGate (SSO) », résolue par la config. + """URL du bouton « Login with ObsiGate (SSO) ». - Émettrice = DRACO_SSO_URL (défaut : nom officiel) ; retour = - URL publique de cette instance (FLOWDECK_URL / OAUTH_REDIRECT_URI / - APP_BASE_URL, défaut : nom officiel) + ``/auth/draco``. Aucun nom - public figé : un dev déclare ses noms en ``-dev`` dans le .env. + Émettrice = ``obsigate_url()`` (DRACO_SSO_URL explicite, sinon + l'environnement de la requête — v7.82.4) ; retour = URL publique + de cette instance résolue de la même façon (FLOWDECK_URL / + OAUTH_REDIRECT_URI explicites, sinon hôte de la requête, le + callback = sa propre origine résolue) + ``/auth/draco``. Aucun + nom public figé. """ from urllib.parse import quote diff --git a/app/services/draco_urls.py b/app/services/draco_urls.py index 299142e..5a47707 100644 --- a/app/services/draco_urls.py +++ b/app/services/draco_urls.py @@ -32,16 +32,63 @@ qui ne déclare que l'agent pointe ses liens inter-apps en ``-dev`` sans quatre variables de plus. Si l'hôte de l'agent ne correspond pas au motif (autre domaine, LAN, IP), **aucune dérivation** : on ne fabrique jamais un lien deviné. L'URL Gitea ne change pas. + +**Résolution par l'hôte de la requête (v7.82.4)** — la v7.82.3 +laissait deux trous, prouvés en direct sur flowdeck-dev (CSP +``frame-src 'self' https://agent.dracodev.net`` alors que la prod +n'existe pas encore → console du panneau = page morte) : + +1. ``agent_url()`` n'avait **aucune** dérivation : même avec + ``AGENT_EVENEMENTS_URL`` en ``-dev``, l'agent lui-même retombait + sur le nom officiel ; +2. **aucune** résolution ne lisait l'hôte de la requête : un + ``.env`` dev sans ``DRACO_AGENT_URL`` ni ``AGENT_EVENEMENTS_URL`` + retombait entièrement sur la prod. + +Pour tout ce qui est **rendu à une requête** (liens de la barre / +``DRACO_URLS`` servi, source de l'iframe du panneau, origine agent +des CSP ``frame-src``/``connect-src``, boutons et redirections +SSO), l'ordre de résolution devient : + +1. variable explicite de l'installation — prioritaire, inchangée ; +2. **hôte public de la requête** (posé par le middleware CSP dans + ``HOTE_REQUETE``, même patron que le nonce CSP) : motif fermé + ``^(og|flowdeck|tiktak|kanbante|agent)()\\.dracodev\\.net$`` + (suffixe optionnel, p. ex. ``-dev``, vide en prod) → + l'environnement = ce suffixe, appliqué à toutes les sœurs **et + à l'agent**. Jamais de suffixe inventé depuis un hôte arbitraire ; + hors motif (LAN, IP) → étape suivante ; +3. dérivation par les adresses de l'agent connues en configuration + (URL d'événements **ou** base du service) — aussi la résolution + des contextes **sans requête**, inchangée sinon ; +4. défaut officiel. + +L'iframe du panneau et la CSP de la même réponse consomment la +même origine résolue : les deux lisent ``agent_url()`` pendant la +même requête, avec le même ``HOTE_REQUETE``. """ from __future__ import annotations import os import re +from contextvars import ContextVar from urllib.parse import urlparse from app.config import settings +# Hôte public de la requête en cours (``X-Forwarded-Host`` sinon +# ``Host``), posé par le middleware CSP avant le traitement — même +# patron que ``CSP_NONCE`` : le contexte est copié dans la tâche qui +# rend les templates. Vide hors requête (tâches de fond, CLI, tests +# unitaires) → la résolution retombe sur la configuration. +HOTE_REQUETE: ContextVar[str] = ContextVar("draco_hote_requete", default="") + + +def definir_hote_requete(hote: str) -> None: + """Mémorise l'hôte public de la requête (premier de la liste).""" + HOTE_REQUETE.set((hote or "").split(",")[0].strip().lower()) + OFFICIELLES = { "obsigate": "https://og.dracodev.net", "agent": "https://agent.dracodev.net", @@ -77,6 +124,16 @@ _SLUGS_SOEURS = { # Hôte de l'agent : agent.dracodev.net (SUFFIXE = ``-dev``…). _MOTIF_HOTE_AGENT = re.compile(r"^agent(-[a-z0-9-]+)?\.dracodev\.net$") +# Hôte public d'une requête : motif FERMÉ des cinq applications. +# Le suffixe capturé (groupe 2, p. ex. ``-dev``) est l'environnement +# de TOUTE l'installation — jamais un suffixe inventé depuis un +# hôte arbitraire : hors motif (autre domaine, LAN, IP), None. +_MOTIF_HOTE_PUBLIC = re.compile( + r"^(og|flowdeck|tiktak|kanbante|agent)(-[a-z0-9-]+)?\.dracodev\.net$") + +# Slugs pour la résolution par l'hôte de la requête (l'agent inclus). +_SLUGS_TOUTES = {**_SLUGS_SOEURS, "agent": "agent"} + def _suffixe_environnement() -> str | None: """Suffixe d'environnement porté par l'hôte de l'agent, ou None. @@ -105,13 +162,60 @@ def _url_derivee(app: str) -> str: return f"https://{slug}{suffixe}.dracodev.net" +def _suffixe_requete() -> str | None: + """Suffixe d'environnement porté par l'hôte de la requête, ou None. + + Motif fermé ``_MOTIF_HOTE_PUBLIC`` sur l'hôte public mémorisé + (port retiré) : ``""`` pour un nom officiel, ``"-dev"`` pour un + nom en ``-dev``, None hors motif ou hors requête. + """ + hote_req = HOTE_REQUETE.get().split(":")[0] + if not hote_req: + return None + m = _MOTIF_HOTE_PUBLIC.match(hote_req) + if not m: + return None + return m.group(2) or "" + + +def _url_depuis_requete(app: str) -> str: + """URL d'``app`` dérivée de l'hôte de la requête ("" si aucune).""" + slug = _SLUGS_TOUTES.get(app) + if not slug: + return "" + suffixe = _suffixe_requete() + if suffixe is None: + return "" + return f"https://{slug}{suffixe}.dracodev.net" + + +def _suffixe_evenements_explicite() -> str | None: + """Suffixe porté par l'URL d'événements **explicite**, ou None. + + Lit ``AGENT_EVENEMENTS_URL`` (environnement puis settings) sans + passer par ``agent_evenements_url()`` : pas de récursion avec + ``agent_url()``, dont c'est une source de dérivation. + """ + for candidate in (os.environ.get("AGENT_EVENEMENTS_URL", ""), + getattr(settings, "agent_evenements_url", "") or ""): + candidate = _nettoyer(candidate) + if candidate: + hote_agent = (urlparse(candidate).hostname or "").lower() + m = _MOTIF_HOTE_AGENT.match(hote_agent) + return (m.group(1) or "") if m else None + return None + + def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str: - """Résolution d'une URL sœur : explicite > dérivation > officiel. + """Résolution d'une URL sœur, dans l'ordre du chantier : + + explicite > **hôte de la requête** > dérivation par l'agent + connu en configuration > officiel. La valeur ``settings`` égale au nom officiel est le *défaut* de ``app/config.py``, pas un choix explicite : elle ne doit pas - bloquer la dérivation d'environnement. Une valeur settings - différente (dev, LAN, domaine propre) reste prioritaire. + bloquer les dérivations. Une valeur settings différente (dev, + LAN, domaine propre) reste prioritaire. """ env = _nettoyer(os.environ.get(env_name, "")) if env: @@ -119,6 +223,9 @@ def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str: cfg = _nettoyer(getattr(settings, settings_attr, "") or "") if cfg and cfg != OFFICIELLES[app]: return cfg + depuis_requete = _url_depuis_requete(app) + if depuis_requete: + return depuis_requete derivee = _url_derivee(app) if derivee: return derivee @@ -133,8 +240,30 @@ def obsigate_url() -> str: def agent_url() -> str: - """Service d'agents (Spark) — base sans chemin.""" - return _resoudre("DRACO_AGENT_URL", "draco_agent_url", "agent") + """Service d'agents (Spark) — base sans chemin. + + Même ordre que les sœurs (la v7.82.3 l'avait laissé sans aucune + dérivation — trou n° 1 du mode console mort en dev) : explicite + (``DRACO_AGENT_URL`` / settings non officiel) > hôte de la + requête > dérivation par l'URL d'événements explicite + (``AGENT_EVENEMENTS_URL`` : ``agent-dev…`` → base ``agent-dev…``) + > défaut officiel. + """ + env = _nettoyer(os.environ.get("DRACO_AGENT_URL", "")) + if env: + return env + cfg = _nettoyer(getattr(settings, "draco_agent_url", "") or "") + if cfg and cfg != OFFICIELLES["agent"]: + return cfg + depuis_requete = _url_depuis_requete("agent") + if depuis_requete: + return depuis_requete + suffixe = _suffixe_evenements_explicite() + if suffixe is not None: + return f"https://agent{suffixe}.dracodev.net" + if cfg: + return cfg + return OFFICIELLES["agent"] def tiktak_url() -> str: @@ -154,7 +283,8 @@ def flowdeck_url() -> str: FLOWDECK_URL > origine de OAUTH_REDIRECT_URI (déjà déclarée en dev : ``https://flowdeck-dev.dracodev.net/auth/callback``) > - APP_BASE_URL si elle n'est pas locale > nom officiel. + hôte de la requête (motif fermé) > APP_BASE_URL si elle n'est + pas locale > dérivation par l'agent > nom officiel. """ env = _nettoyer(os.environ.get("FLOWDECK_URL", "")) if env: @@ -171,6 +301,13 @@ def flowdeck_url() -> str: p = urlparse(candidate) if p.scheme and p.netloc: return f"{p.scheme}://{p.netloc}" + # Étape 2 du chantier : l'hôte de la requête — c'est l'origine + # que le navigateur utilise déjà pour cette instance, donc + # aussi celle du callback SSO (``/auth/draco``) quand rien + # d'explicite n'est déclaré. + depuis_requete = _url_depuis_requete("flowdeck") + if depuis_requete: + return depuis_requete base = _nettoyer(getattr(settings, "app_base_url", "") or "") if base and "localhost" not in base and "127.0.0.1" not in base: return base @@ -208,7 +345,12 @@ def hote(url: str) -> str: def toutes() -> dict[str, str]: - """Toutes les URL résolues — injectées aux templates (hôte = config).""" + """Toutes les URL résolues — injectées aux templates. + + Résolution à l'appel : dans une requête, l'hôte public de la + requête fait foi après les variables explicites ; hors requête, + la configuration seule (voir le docstring du module). + """ return { "obsigate": obsigate_url(), "agent": agent_url(), diff --git a/tests/test_draco_urls_devprod.py b/tests/test_draco_urls_devprod.py index 781f734..bfd860c 100644 --- a/tests/test_draco_urls_devprod.py +++ b/tests/test_draco_urls_devprod.py @@ -188,3 +188,185 @@ def test_bouton_sso_login_depuis_config(monkeypatch): assert _sso_login_url() == ( "https://og-dev.dracodev.net/sso/authorize?return_to=" "https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") + + +# ── v7.82.4 — résolution par l'hôte de la requête ───────────────────── +# Correctif du mode console mort sur flowdeck-dev : l'agent n'avait +# aucune dérivation (v7.82.3) et rien ne lisait l'hôte de la requête. +# Ordre : explicite > hôte public de la requête (motif fermé) > +# dérivation par l'agent connu en configuration > officiel. + +@pytest.fixture +def avec_hote(): + """Pose l'hôte de requête comme le middleware CSP le fait.""" + jetons = [] + + def poser(hote): + jetons.append(draco_urls.HOTE_REQUETE.set(hote)) + + yield poser + for jeton in reversed(jetons): + draco_urls.HOTE_REQUETE.reset(jeton) + + +def test_agent_url_derivee_depuis_evenements_seul(monkeypatch): + # Trou n° 1 de la v7.82.3 : seul AGENT_EVENEMENTS_URL posé (cas + # réel de la dev) → l'agent lui-même suit en ``-dev``, pas + # seulement les sœurs. + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", + "https://agent-dev.dracodev.net/api/v1/evenements") + assert draco_urls.agent_url() == "https://agent-dev.dracodev.net" + assert draco_urls.agent_evenements_url() == \ + "https://agent-dev.dracodev.net/api/v1/evenements" + + +def test_hote_requete_dev_toutes_urls_sans_env(avec_hote): + # Aucune variable d'environnement (fixture _env_propre) : l'hôte + # public suffit — sœurs ET agent en ``-dev``, Gitea inchangée. + avec_hote("flowdeck-dev.dracodev.net") + urls = draco_urls.toutes() + assert urls["obsigate"] == "https://og-dev.dracodev.net" + assert urls["agent"] == "https://agent-dev.dracodev.net" + assert urls["tiktak"] == "https://tiktak-dev.dracodev.net" + assert urls["kanbante"] == "https://kanbante-dev.dracodev.net" + assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net" + assert urls["gitea"] == "https://git.dracodev.net" + + +def test_hote_requete_quelconque_des_cinq_slugs(avec_hote): + # L'environnement est celui de l'installation, pas de l'app + # demandée : un hôte ``agent-dev`` ou ``og-dev`` vaut ``-dev`` + # pour toutes, port éventuel ignoré. + for hote in ("agent-dev.dracodev.net", "og-dev.dracodev.net:443", + "tiktak-dev.dracodev.net", "kanbante-dev.dracodev.net"): + avec_hote(hote) + assert draco_urls.agent_url() == "https://agent-dev.dracodev.net", hote + assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net", hote + + +def test_hote_requete_prod_noms_officiels(avec_hote): + avec_hote("flowdeck.dracodev.net") + urls = draco_urls.toutes() + assert urls["obsigate"] == "https://og.dracodev.net" + assert urls["agent"] == "https://agent.dracodev.net" + assert urls["flowdeck"] == "https://flowdeck.dracodev.net" + assert all("-dev" not in u for u in urls.values()) + + +def test_hote_requete_motif_ferme_aucune_derivation(avec_hote): + # Jamais de suffixe inventé depuis un hôte arbitraire. + for hote in ("evil-dev.dracodev.net", + "flowdeck-dev.dracodev.net.evil.com", + "monflowdeck-dev.dracodev.net", + "flowdeckdev.dracodev.net", + "flowdeck-dev.example.net"): + avec_hote(hote) + urls = draco_urls.toutes() + assert urls["agent"] == "https://agent.dracodev.net", hote + assert urls["obsigate"] == "https://og.dracodev.net", hote + assert urls["flowdeck"] == "https://flowdeck.dracodev.net", hote + + +def test_hote_requete_lan_repli_configuration(avec_hote, monkeypatch): + # Hors motif (LAN, IP) : repli configuration/défauts, comme + # hors requête — ici la dérivation par AGENT_EVENEMENTS_URL. + avec_hote("192.168.30.101:8080") + urls = draco_urls.toutes() + assert urls["agent"] == "https://agent.dracodev.net" + assert urls["obsigate"] == "https://og.dracodev.net" + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", + "https://agent-dev.dracodev.net/api/v1/evenements") + urls = draco_urls.toutes() + assert urls["agent"] == "https://agent-dev.dracodev.net" + assert urls["obsigate"] == "https://og-dev.dracodev.net" + assert urls["tiktak"] == "https://tiktak-dev.dracodev.net" + + +def test_hote_requete_explicite_gagne_sur_hote(avec_hote, monkeypatch): + avec_hote("flowdeck-dev.dracodev.net") + # Explicites prioritaires, y compris quand elles pointent le + # nom officiel ou un domaine propre. + monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net") + monkeypatch.setenv("DRACO_SSO_URL", "https://og.example.net") + urls = draco_urls.toutes() + assert urls["agent"] == "https://agent.dracodev.net" + assert urls["obsigate"] == "https://og.example.net" + # Non déclarées : l'hôte de la requête fait foi. + assert urls["tiktak"] == "https://tiktak-dev.dracodev.net" + assert urls["kanbante"] == "https://kanbante-dev.dracodev.net" + assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net" + + +def test_hote_requete_bouton_sso_tout_dev(avec_hote): + from app.routers.auth import _sso_login_url + + avec_hote("flowdeck-dev.dracodev.net") + # Émettrice via DRACO_SSO_URL résolu par l'hôte + callback = + # propre origine résolue de l'instance. + assert _sso_login_url() == ( + "https://og-dev.dracodev.net/sso/authorize?return_to=" + "https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") + + +def test_http_dev_sans_env_tout_en_dev(client): + # Requête complète, aucune variable d'environnement : barre, + # panneau (config de l'iframe console), CSP et SSO en ``-dev``. + r = client.get("/workspaces", + headers={"Host": "flowdeck-dev.dracodev.net"}) + assert r.status_code == 200 + html = r.text + # Barre inter-apps (les quatre sœurs). + for soeur in ("og-dev", "flowdeck-dev", "tiktak-dev", "kanbante-dev"): + assert f'href="https://{soeur}.dracodev.net/"' in html + # Panneau : la source de l'iframe console vient de cette config. + assert 'agent_url: "https://agent-dev.dracodev.net"' in html + # CSP de la MÊME réponse : même origine agent résolue. + csp = r.headers.get("content-security-policy", "") + assert "frame-src 'self' https://agent-dev.dracodev.net" in csp + assert "connect-src 'self' https://agent-dev.dracodev.net" in csp + # Aucun nom officiel nu ne subsiste dans la réponse. + for officiel in ("https://og.dracodev.net", "https://agent.dracodev.net", + "https://tiktak.dracodev.net", + "https://kanbante.dracodev.net", + "https://flowdeck.dracodev.net"): + assert officiel not in html.replace("-dev.dracodev.net", ""), officiel + assert officiel not in csp.replace("-dev.dracodev.net", ""), officiel + # Bouton SSO de la page de connexion, même requête type. + r = client.get("/auth/login?provider=local", + headers={"Host": "flowdeck-dev.dracodev.net"}) + assert ("https://og-dev.dracodev.net/sso/authorize?return_to=" + "https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") in r.text + + +def test_http_prod_noms_officiels(client): + r = client.get("/workspaces", + headers={"Host": "flowdeck.dracodev.net"}) + assert r.status_code == 200 + assert 'agent_url: "https://agent.dracodev.net"' in r.text + assert 'href="https://og.dracodev.net/"' in r.text + csp = r.headers.get("content-security-policy", "") + assert "frame-src 'self' https://agent.dracodev.net" in csp + assert "-dev.dracodev.net" not in r.text + assert "-dev.dracodev.net" not in csp + + +def test_http_lan_repli_defauts(client): + r = client.get("/workspaces", + headers={"Host": "192.168.30.101:8080"}) + assert r.status_code == 200 + assert 'agent_url: "https://agent.dracodev.net"' in r.text + csp = r.headers.get("content-security-policy", "") + assert "frame-src 'self' https://agent.dracodev.net" in csp + + +def test_http_explicite_gagne_sur_hote(client, monkeypatch): + monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net") + r = client.get("/workspaces", + headers={"Host": "flowdeck-dev.dracodev.net"}) + assert 'agent_url: "https://agent.dracodev.net"' in r.text + csp = r.headers.get("content-security-policy", "") + assert "frame-src 'self' https://agent.dracodev.net;" in csp + # Le reste suit l'hôte de la requête. + assert 'href="https://og-dev.dracodev.net/"' in r.text