diff --git a/CHANGELOG.md b/CHANGELOG.md index e461d12..25622f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## 0.32.2 — 2026-10-25 + +### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête + +Sur kanbante-dev, le mode console du panneau était une page morte +et les liens inter-apps n'étaient pas tous en dev. Couche fautive +trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne +couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais +`agent_url` lui-même. Sur la dev, où seul le journal est déclaré +en `-dev`, le panneau recevait donc le défaut prod +`https://agent.dracodev.net` : iframe de console pointant un agent +prod pas encore installé, donc injoignable. Et aucune résolution +ne tenait compte de la requête : sans variable d'environnement, +un hôte public en `-dev` retombait entièrement sur la prod. +Enfin, aucune CSP n'existait : l'origine de l'agent n'était +énoncée nulle part. + +- **Résolution par requête** (`resoudre_urls_draco` dans + `app/config.py`, consommée par `render()` dans `app/main.py`) + pour tout ce qui est rendu à une requête — liens de la barre + (`draco_urls`), source de l'iframe du panneau + (`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans + l'ordre : 1) la variable explicite de l'installation, + prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate, + `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`, + `KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL` + pour l'agent) ; 2) l'hôte public de la requête, motif fermé + `^(og|flowdeck|tiktak|kanbante|agent)\.dracodev\.net$` + (suffixe optionnel : vide en prod, `-dev` en dev), appliqué à + toutes les sœurs et à l'agent — jamais de suffixe inventé + depuis un hôte arbitraire ; hors motif (LAN, IP), étape + suivante ; 3) la dérivation par les adresses de l'agent connues + en configuration, **élargie** : URL d'événements **ou** base du + service, selon ce que l'installation déclare ; 4) le défaut + officiel. +- **Base de l'agent dérivée** (contextes sans requête aussi) : + sans `AGENT_URL` explicite, si l'adresse connue de l'agent + (événements ou base) suit `agent.dracodev.net`, + `agent_url` devient `https://agent.dracodev.net` au + lieu de rester au défaut prod. +- **CSP nouvelle** : chaque page rendue énonce + `Content-Security-Policy: frame-src 'self' ` — + exactement l'origine résolue pour cette même réponse, celle de + l'iframe de la console (seule iframe de l'application). +- Les contextes sans requête gardent la résolution par + configuration, inchangée sinon. Aucun SSO ajouté : Kanbante + n'en a pas par conception ; périmètre limité à ses liens et à + son panneau. +- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune + variable → barre, iframe et CSP entièrement en `-dev` ; hôte + prod → noms officiels ; hôte LAN → repli configuration/défauts ; + explicite gagne sur l'hôte ; base de l'agent dérivée du seul + journal et dérivation élargie à la base du service ; contextes + sans requête inchangés. Suite complète : 278 tests réussis. +- **Déploiement** : cette version part avec le prochain + redéploiement Docker (la 0.32.1 n'a jamais été redéployée) — + preuve en direct **en attente du redéploiement Docker de + Bruno**, la version vivante sur kanbante-dev reste l'ancienne. + ## 0.32.1 — 2026-10-25 ### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent diff --git a/README.md b/README.md index 1e72937..0d51fe6 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ sudo systemctl enable --now docker | `KANBANTE_PUBLIC_URL` | `https://kanbante.dracodev.net` | Propre URL publique de Kanbante (lien de la barre commune, `return_to` du lien SSO) — noms publics par configuration : défauts = noms officiels (prod), la dev déclare ses noms en `-dev` (ex. `https://kanbante-dev.dracodev.net`) | | `DRACO_SSO_URL` | `https://og.dracodev.net` | Base d'ObsiGate, émettrice SSO (lien de barre ObsiGate, révocations, lien de connexion SSO) | | `DRACO_FLOWDECK_URL` / `DRACO_TIKTAK_URL` | `https://flowdeck.dracodev.net` / `https://tiktak.dracodev.net` | Liens Flowdeck / TikTak de la barre commune | -| `AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents (panneau Spark + console, adresse injectée au JS par l'hôte) | +| `AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents (panneau Spark + console, adresse injectée au JS par l'hôte) — sans variable explicite, dérivée de l'hôte d'`AGENT_EVENEMENTS_URL` s'il suit `agent.dracodev.net` ; à chaque requête, l'hôte public (motif fermé `*.dracodev.net`) détermine l'environnement des URL rendues sauf variable explicite (0.32.2) | | `AGENT_EVENEMENTS_URL` | *(dérivé de `AGENT_URL`)* | Journal d'événements du service d'agents ; ne se pose que pour surcharger la dérivation `/api/v1/evenements` | | `AGENT_EVENEMENTS_CLE` | *(vide)* | Clé HMAC signant les événements émis vers ce journal ; vide = émission désactivée | | `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) | diff --git a/app/config.py b/app/config.py index 0643392..d5b4607 100644 --- a/app/config.py +++ b/app/config.py @@ -102,6 +102,13 @@ Environment variables: https://agent.dracodev.net — the dev environment declares its ``-dev`` name here (e.g. https://agent-dev.dracodev.net). + Sans variable explicite, la base est + aussi dérivée de l'hôte de + AGENT_EVENEMENTS_URL quand il suit le + motif ``agent.dracodev.net`` + (0.32.2 : sinon le panneau pointait le + défaut prod alors que le journal était + en dev — console morte sur la dev). AGENT_EVENEMENTS_URL Journal endpoint of the agent service (Lot 2, contract ACC-0.2) receiving this app's signed events. @@ -110,7 +117,9 @@ Environment variables: Son hôte sert aussi de source à la dérivation d'environnement des URL sœurs ci-dessous (voir - ``_url_soeur_draco``). + ``_url_soeur_draco``), au même titre + que la base du service (AGENT_URL) — + dérivation élargie en 0.32.2. KANBANTE_PUBLIC_URL This app's own public base URL (barre commune self-link, SSO ``return_to``). Default (official) @@ -123,20 +132,33 @@ Environment variables: https://tiktak.dracodev.net. ObsiGate's bar link reuses DRACO_SSO_URL (the SSO emitter IS ObsiGate). - Résolution d'une URL sœur, dans - l'ordre : 1) la variable explicite + Résolution d'une URL rendue à une + requête (barre, panneau, CSP), dans + l'ordre (voir ``resoudre_urls_draco``, + 0.32.2) : 1) la variable explicite ci-dessus (prioritaire, contrat - inchangé) ; 2) dérivation depuis - l'hôte de l'agent connu par - AGENT_EVENEMENTS_URL — si cet hôte - est ``agent.dracodev.net`` - (p. ex. ``agent-dev``), l'URL sœur - devient ``https://. + inchangé) ; 2) l'hôte public de la + requête, motif fermé + ``(og|flowdeck|tiktak|kanbante|agent) + .dracodev.net`` (suffixe vide + en prod, ``-dev`` en dev) appliqué à + toutes les sœurs et à l'agent — jamais + de suffixe inventé depuis un hôte + arbitraire ; 3) dérivation depuis les + adresses de l'agent connues en + configuration (AGENT_EVENEMENTS_URL + ou AGENT_URL) — si cet hôte est + ``agent.dracodev.net``, + l'URL sœur devient + ``https://. dracodev.net`` (slugs : og, flowdeck, - tiktak, kanbante) ; hôte hors motif - (autre domaine, LAN, IP) = aucune - dérivation, jamais de lien deviné ; - 3) le défaut officiel (prod). + tiktak, kanbante, agent) ; hôte hors + motif (autre domaine, LAN, IP) = + aucune dérivation, jamais de lien + deviné ; 4) le défaut officiel (prod). + Les contextes sans requête gardent la + résolution par configuration (étapes + 1, 3 et 4), inchangée sinon. No public dracodev name is hard-coded outside these defaults: dev/prod differ only by environment, never by code. @@ -155,7 +177,7 @@ from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.32.1" +APP_VERSION = "0.32.2" def _env_bool(name: str, default: bool) -> bool: @@ -191,28 +213,72 @@ def _env_wip_limits(name: str) -> dict[str, int]: return limits -# ── Dérivation d'environnement des URL sœurs dracodev (0.32.1) ── -# Sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte +# ── Résolution d'environnement des URL publiques dracodev ── +# 0.32.1 : sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte # agent-dev.dracodev.net) : les URL sœurs retombaient donc sur leurs -# défauts prod dans la barre commune. La résolution ci-dessous -# évite de devoir déclarer quatre variables de plus. +# défauts prod dans la barre commune. La dérivation depuis l'hôte de +# l'agent évite de devoir déclarer quatre variables de plus. +# 0.32.2 — couche fautive corrigée : cette dérivation ne couvrait +# que les URL sœurs, jamais ``agent_url`` lui-même — le panneau +# recevait donc le défaut prod (agent injoignable, la prod n'étant +# pas installée) et la console était une page morte sur la dev. +# Et aucune résolution ne tenait compte de la requête : un hôte +# public en ``-dev`` sans aucune variable retombait sur la prod. +# Désormais, pour tout ce qui est rendu à une requête (liens de la +# barre / DRACO_URLS, source de l'iframe du panneau, origine agent +# de la CSP) : 1) variable explicite ; 2) hôte public de la +# requête (motif fermé ci-dessous) ; 3) dérivation par les adresses +# de l'agent connues en configuration — URL d'événements OU base +# du service (élargie en 0.32.2) ; 4) défaut officiel. _MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net") +_MOTIF_HOTE_PUBLIC = re.compile( + r"(?:og|flowdeck|tiktak|kanbante|agent)([a-z0-9-]*)\.dracodev\.net" +) -def _suffixe_environnement_agent(agent_evenements_url: str) -> str | None: - """Suffixe d'environnement lu sur l'hôte de l'agent. +def _suffixe_depuis_hote(hote: str, motif: re.Pattern) -> str | None: + """Suffixe d'environnement porté par un hôte nu, ou ``None``. Retourne le suffixe (``""`` pour le nom officiel, ``"-dev"`` - pour la dev) si l'hôte de ``agent_evenements_url`` est de la - forme ``agent.dracodev.net`` ; ``None`` hors motif - (autre domaine, LAN, IP…) — dans ce cas, aucune dérivation. + pour la dev) si l'hôte suit le motif fermé ; ``None`` hors + motif (autre domaine, LAN, IP…) — aucune dérivation alors, + jamais de suffixe inventé depuis un hôte arbitraire. + """ + correspondance = motif.fullmatch(hote.strip().lower()) + return correspondance.group(1) if correspondance else None + + +def _suffixe_environnement_agent(url_agent: str) -> str | None: + """Suffixe lu sur l'hôte d'une adresse connue de l'agent. + + Vaut pour l'URL d'événements comme pour la base du service : + si son hôte est ``agent.dracodev.net``, le suffixe + est retourné ; ``None`` hors motif. """ try: - hote = urlparse(agent_evenements_url).hostname or "" + hote = urlparse(url_agent).hostname or "" except ValueError: return None - motif = _MOTIF_HOTE_AGENT.fullmatch(hote) - return motif.group(1) if motif else None + return _suffixe_depuis_hote(hote, _MOTIF_HOTE_AGENT) + + +def suffixe_environnement_requete(hote_requete: str | None) -> str | None: + """Suffixe lu sur l'hôte public de la requête entrante. + + Motif fermé ``^(og|flowdeck|tiktak|kanbante|agent) + \\.dracodev\\.net$`` (suffixe optionnel : vide en prod, + ``-dev`` en dev) ; un port éventuel est toléré et retiré. + Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant + retombe alors sur la configuration, jamais sur un suffixe + deviné. + """ + if not hote_requete: + return None + hote = hote_requete.strip().lower() + if hote.startswith("["): # littéral IPv6 : jamais dans le motif + return None + hote = hote.split(":", 1)[0] + return _suffixe_depuis_hote(hote, _MOTIF_HOTE_PUBLIC) def _url_soeur_draco( @@ -233,6 +299,58 @@ def _url_soeur_draco( return defaut +@dataclass(frozen=True) +class UrlsDraco: + """URL publiques dracodev résolues pour un rendu ou un contexte.""" + + sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL) + flowdeck_url: str + tiktak_url: str + public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL) + agent_url: str # base du service d'agents (panneau + CSP) + + +def resoudre_urls_draco( + hote_requete: str | None = None, base: "Settings | None" = None +) -> UrlsDraco: + """Résout les URL publiques pour un rendu à une requête. + + Ordre, par URL : 1) la variable explicite de l'installation — + prioritaire, inchangée (``DRACO_SSO_URL`` pour ObsiGate, + ``KANBANTE_PUBLIC_URL`` pour Kanbante elle-même, ``AGENT_URL`` + pour l'agent) ; 2) l'hôte public de la requête, s'il suit le + motif fermé dracodev : son suffixe d'environnement (``-dev``, + vide en prod) s'applique à toutes les sœurs et à l'agent ; + 3) à défaut, la valeur de configuration ``base`` (défaut : + ``_env_settings()``), qui porte déjà la dérivation par les + adresses de l'agent connues (événements ou base du service) + puis le défaut officiel — c'est aussi la résolution des + contextes sans requête, inchangée. + """ + if base is None: + base = _env_settings() + suffixe_hote = suffixe_environnement_requete(hote_requete) + + def resoudre(nom_env: str, slug: str, valeur_base: str) -> str: + if os.getenv(nom_env): + return valeur_base # explicite : déjà résolue dans base + if suffixe_hote is not None: + return f"https://{slug}{suffixe_hote}.dracodev.net" + return valeur_base + + return UrlsDraco( + sso_url=resoudre("DRACO_SSO_URL", "og", base.sso_url), + flowdeck_url=resoudre( + "DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url + ), + tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url), + public_url=resoudre( + "KANBANTE_PUBLIC_URL", "kanbante", base.public_url + ), + agent_url=resoudre("AGENT_URL", "agent", base.agent_url), + ) + + @dataclass(frozen=True) class Settings: gitea_url: str = "https://git.dracodev.net" @@ -293,17 +411,29 @@ def get_settings() -> Settings: def _env_settings() -> Settings: """Build settings from the current environment only.""" - agent_url = os.getenv( - "AGENT_URL", "https://agent.dracodev.net" + agent_url_explicite = os.getenv("AGENT_URL") + agent_url = ( + agent_url_explicite or "https://agent.dracodev.net" ).rstrip("/") agent_evenements_url = ( os.getenv("AGENT_EVENEMENTS_URL") or f"{agent_url}/api/v1/evenements" ).rstrip("/") - # Origine de l'agent telle que l'app la connaît déjà : c'est - # elle qui porte le suffixe d'environnement (-dev…) repris - # par les URL sœurs dépourvues de variable explicite. + # Origines de l'agent telles que l'app les connaît déjà : elles + # portent le suffixe d'environnement (-dev…) repris par les URL + # sœurs dépourvues de variable explicite. Dérivation élargie + # (0.32.2) : l'URL d'événements d'abord, puis la base du service + # elle-même — selon ce que l'installation déclare. suffixe_agent = _suffixe_environnement_agent(agent_evenements_url) + if suffixe_agent is None: + suffixe_agent = _suffixe_environnement_agent(agent_url) + # Base du service dérivée à son tour (0.32.2) : sans AGENT_URL + # explicite, si l'adresse connue de l'agent est en ``-dev``, le + # panneau et la console doivent viser cette même origine — + # auparavant agent_url restait au défaut prod, d'où la console + # morte sur la dev alors que le journal était bien en dev. + if not agent_url_explicite and suffixe_agent is not None: + agent_url = f"https://agent{suffixe_agent}.dracodev.net" return Settings( gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), gitea_token=os.getenv("GITEA_TOKEN", ""), diff --git a/app/main.py b/app/main.py index 4e2fe98..25a2871 100644 --- a/app/main.py +++ b/app/main.py @@ -41,7 +41,7 @@ from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from starlette.middleware.base import BaseHTTPMiddleware -from app.config import APP_VERSION, get_settings +from app.config import APP_VERSION, get_settings, resoudre_urls_draco from app.gitea_client import GiteaClient from app import code_links as code_links_service from app import digest as digest_service @@ -2149,21 +2149,26 @@ def render( # aucun nom public n'est codé en dur dans les gabarits ni dans le # JS statique — tout vient de app.config (défauts = noms # officiels/prod ; la dev déclare ses noms en ``-dev`` par .env). + # Résolution par requête (0.32.2) : les URL rendues suivent + # l'hôte public de la requête quand aucune variable explicite + # ne les fixe (voir resoudre_urls_draco) — barre, panneau et + # CSP de cette même réponse consomment la même résolution. _pub = get_settings() + _urls = resoudre_urls_draco(request.headers.get("host"), base=_pub) context.setdefault( "draco_urls", { - "obsigate": _pub.sso_url, - "flowdeck": _pub.flowdeck_url, - "tiktak": _pub.tiktak_url, - "kanbante": _pub.public_url, + "obsigate": _urls.sso_url, + "flowdeck": _urls.flowdeck_url, + "tiktak": _urls.tiktak_url, + "kanbante": _urls.public_url, }, ) - context.setdefault("agent_url", _pub.agent_url) + context.setdefault("agent_url", _urls.agent_url) context.setdefault( "sso_authorize_url", - f"{_pub.sso_url}/sso/authorize?return_to=" - f"{quote(_pub.public_url + '/auth/draco', safe='')}", + f"{_urls.sso_url}/sso/authorize?return_to=" + f"{quote(_urls.public_url + '/auth/draco', safe='')}", ) # Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune. context.setdefault( @@ -2194,6 +2199,15 @@ def render( context.setdefault("user_workspaces", []) html = _tpl_env.get_template(name).render(**context) response = HTMLResponse(html, status_code=status_code) + # CSP du panneau (0.32.2) : la seule iframe de l'app est la + # console Spark, chargée depuis l'origine de l'agent résolue + # pour CETTE requête — la même que celle injectée au panneau + # (DRACO_PANNEAU_CONFIG), jamais une autre. Aucune CSP + # n'existait auparavant : l'origine de l'agent n'était énoncée + # nulle part. + response.headers["Content-Security-Policy"] = ( + f"frame-src 'self' {_urls.agent_url}" + ) if generated: response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True) return response diff --git a/pyproject.toml b/pyproject.toml index 1b0b852..2e8c4ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.32.1" +version = "0.32.2" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_draco_barre.py b/tests/test_draco_barre.py index a8b3735..4810c1b 100644 --- a/tests/test_draco_barre.py +++ b/tests/test_draco_barre.py @@ -223,3 +223,200 @@ def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea): r = client.get("/login") assert r.status_code == 200 assert "draco-bar" not in r.text + + +# ── Résolution par requête et base de l'agent dérivée (0.32.2) ── +# Couche fautive corrigée : la dérivation 0.32.1 ne couvrait que les +# URL sœurs depuis AGENT_EVENEMENTS_URL — jamais agent_url, donc le +# panneau visait le défaut prod (agent injoignable : la prod n'est +# pas installée) et la console était une page morte sur la dev. +# Aucune résolution ne tenait compte de l'hôte de la requête, et +# aucune CSP n'énonçait l'origine de l'agent. +_ENV_RESOLUTION = _ENV_URLS_SOEURS + ("AGENT_EVENEMENTS_URL",) + + +def _nettoyer_env_resolution(monkeypatch): + for nom in _ENV_RESOLUTION: + monkeypatch.delenv(nom, raising=False) + + +def test_config_base_agent_derivee_du_seul_agent_evenements(monkeypatch): + # Le cas réel de la dev : seul le journal est déclaré en -dev. + # La base du service (panneau/console) doit suivre, sinon + # l'iframe de la console charge l'agent prod, injoignable. + from app.config import _env_settings + + _nettoyer_env_resolution(monkeypatch) + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", + "https://agent-dev.dracodev.net/api/v1/evenements", + ) + s = _env_settings() + assert s.agent_url == "https://agent-dev.dracodev.net" + assert _urls_soeurs(s) == _URLS_DEV + + +def test_config_derivation_elargie_a_la_base_du_service(monkeypatch): + # Élargie : si l'URL d'événements est hors motif mais que la + # base du service suit le motif, les sœurs dérivent de la base. + from app.config import _env_settings + + _nettoyer_env_resolution(monkeypatch) + monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net") + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", "https://journal.example.test/evenements" + ) + s = _env_settings() + assert s.agent_url == "https://agent-dev.dracodev.net" + assert s.agent_evenements_url == ( + "https://journal.example.test/evenements" + ) + assert _urls_soeurs(s) == _URLS_DEV + + +def test_suffixe_environnement_requete_motif_ferme(): + from app.config import suffixe_environnement_requete + + assert suffixe_environnement_requete("kanbante-dev.dracodev.net") == "-dev" + assert suffixe_environnement_requete("kanbante.dracodev.net") == "" + assert suffixe_environnement_requete("og-dev.dracodev.net") == "-dev" + assert suffixe_environnement_requete("agent-dev.dracodev.net:8790") == "-dev" + assert suffixe_environnement_requete("Kanbante-Dev.Dracodev.Net") == "-dev" + # Hors motif : jamais de suffixe inventé depuis un hôte arbitraire. + assert suffixe_environnement_requete("192.168.30.101:8082") is None + assert suffixe_environnement_requete("127.0.0.1") is None + assert suffixe_environnement_requete("kanbante-dev.autre-domaine.net") is None + assert suffixe_environnement_requete("dev.dracodev.net") is None + assert suffixe_environnement_requete("testserver") is None + assert suffixe_environnement_requete("") is None + assert suffixe_environnement_requete(None) is None + + +def test_requete_hote_dev_sans_env_tout_en_dev(monkeypatch, fake_gitea): + # Aucune variable d'environnement : barre, panneau (source de + # l'iframe de la console) et CSP entièrement en -dev, cohérents. + from fastapi.testclient import TestClient + + import app.main as main + + _nettoyer_env_resolution(monkeypatch) + with TestClient(main.app) as client: + r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"}) + assert r.status_code == 200 + for url in _URLS_DEV: + assert f"{url}/" in r.text + # Source de l'iframe du panneau : l'origine injectée au JS. + assert 'api: "https://agent-dev.dracodev.net"' in r.text + # La CSP de la même réponse énonce la même origine résolue. + assert r.headers["content-security-policy"] == ( + "frame-src 'self' https://agent-dev.dracodev.net" + ) + # Aucun nom prod ne subsiste dans le rendu. + for url in _DEFAUTS_OFFICIELS: + assert f"{url}/" not in r.text + assert 'api: "https://agent.dracodev.net"' not in r.text + + +def test_requete_hote_prod_sans_env_noms_officiels(monkeypatch, fake_gitea): + from fastapi.testclient import TestClient + + import app.main as main + + _nettoyer_env_resolution(monkeypatch) + with TestClient(main.app) as client: + r = client.get("/", headers={"Host": "kanbante.dracodev.net"}) + assert r.status_code == 200 + for url in _DEFAUTS_OFFICIELS: + assert f"{url}/" in r.text + assert 'api: "https://agent.dracodev.net"' in r.text + assert r.headers["content-security-policy"] == ( + "frame-src 'self' https://agent.dracodev.net" + ) + + +def test_requete_hote_lan_repli_configuration(monkeypatch, fake_gitea): + # Hôte hors motif (LAN/IP) : repli sur la configuration, ici + # sans variable — donc les défauts officiels, jamais devinés. + from fastapi.testclient import TestClient + + import app.main as main + + _nettoyer_env_resolution(monkeypatch) + with TestClient(main.app) as client: + r = client.get("/", headers={"Host": "192.168.30.101:8082"}) + assert r.status_code == 200 + for url in _DEFAUTS_OFFICIELS: + assert f"{url}/" in r.text + assert 'api: "https://agent.dracodev.net"' in r.text + assert r.headers["content-security-policy"] == ( + "frame-src 'self' https://agent.dracodev.net" + ) + + +def test_requete_hote_lan_repli_derivation_configuration( + monkeypatch, fake_gitea +): + # Hôte hors motif mais configuration en -dev (journal seul) : + # le repli est la dérivation de configuration, panneau inclus. + from fastapi.testclient import TestClient + + import app.main as main + + _nettoyer_env_resolution(monkeypatch) + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", + "https://agent-dev.dracodev.net/api/v1/evenements", + ) + with TestClient(main.app) as client: + r = client.get("/", headers={"Host": "192.168.30.101:8082"}) + assert r.status_code == 200 + for url in _URLS_DEV: + assert f"{url}/" in r.text + assert 'api: "https://agent-dev.dracodev.net"' in r.text + assert r.headers["content-security-policy"] == ( + "frame-src 'self' https://agent-dev.dracodev.net" + ) + + +def test_requete_explicite_gagne_sur_hote(monkeypatch, fake_gitea): + # L'explicite reste prioritaire, URL par URL : Flowdeck et + # l'agent explicites gagnent sur l'hôte dev ; les autres + # sœurs suivent l'hôte. + from fastapi.testclient import TestClient + + import app.main as main + + _nettoyer_env_resolution(monkeypatch) + monkeypatch.setenv("DRACO_FLOWDECK_URL", "https://flowdeck.example.test") + monkeypatch.setenv("AGENT_URL", "https://agent.example.test") + with TestClient(main.app) as client: + r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"}) + assert r.status_code == 200 + assert "https://flowdeck.example.test/" in r.text + assert "https://flowdeck-dev.dracodev.net/" not in r.text + assert 'api: "https://agent.example.test"' in r.text + # La CSP consomme l'origine explicite, comme l'iframe. + assert r.headers["content-security-policy"] == ( + "frame-src 'self' https://agent.example.test" + ) + for url in ( + "https://og-dev.dracodev.net/", + "https://tiktak-dev.dracodev.net/", + "https://kanbante-dev.dracodev.net/", + ): + assert url in r.text + + +def test_resolution_sans_requete_inchangee(monkeypatch): + # Contextes sans requête : la résolution par configuration + # seule, comme avant (défauts officiels sans environnement). + from app.config import _env_settings, resoudre_urls_draco + + _nettoyer_env_resolution(monkeypatch) + urls = resoudre_urls_draco() + assert urls.sso_url == "https://og.dracodev.net" + assert urls.flowdeck_url == "https://flowdeck.dracodev.net" + assert urls.tiktak_url == "https://tiktak.dracodev.net" + assert urls.public_url == "https://kanbante.dracodev.net" + assert urls.agent_url == "https://agent.dracodev.net" + assert _env_settings().agent_url == "https://agent.dracodev.net"