Compare commits

...
7 Commits
Author SHA1 Message Date
Spark fd79a49d63 fix(draco): résolution dev/prod par l'hôte de la requête (7.82.4)
FlowDeck CI / test (push) Failing after 3h13m27s
FlowDeck CI / lint (push) Successful in 1m47s
FlowDeck CI / docker (push) Skipped
Le mode console du panneau restait une page morte sur flowdeck-dev :
CSP frame-src/connect-src et DRACO_PANNEAU_CONFIG.agent_url pointaient
l'agent prod (pas encore installée). Deux trous de la 7.82.3 :
agent_url() sans aucune dérivation, et aucune résolution lisant
l'hôte de la requête.

Pour tout ce qui est rendu à une requête (barre/DRACO_URLS, iframe
du panneau, CSP, boutons et redirections SSO) : explicite >
hôte public de la requête (motif fermé
^(og|flowdeck|tiktak|kanbante|agent)(SUFFIXE).dracodev.net$,
suffixe appliqué aux sœurs et à l'agent) > dérivation par les
adresses de l'agent connues en config (élargie : agent_url() se
dérive de l'URL d'événements explicite) > défaut officiel.
L'hôte est posé par le middleware CSP dans un ContextVar (patron
du nonce CSP) : iframe et CSP de la même réponse consomment la
même origine résolue. Contextes sans requête inchangés sinon.

Tests : +12 dans test_draco_urls_devprod.py (25/25) — Host dev
sans aucun env → tout en -dev ; Host prod → officiels ; Host LAN
→ repli config/défauts ; explicite gagne sur l'hôte ; motif fermé.
Suite complète : 4 failed / 26 errors identiques sur arbre propre
(familles préexistantes MCP/realtime/my_tasks/gallery/sync).
2026-10-26 03:04:50 +00:00
Spark 283e226adf fix(draco): URLs sœurs dérivées de l'environnement de l'agent (7.82.3)
FlowDeck CI / test (push) Successful in 29m39s
FlowDeck CI / lint (push) Successful in 2m18s
FlowDeck CI / docker (push) Successful in 1m41s
Résolution uniforme des quatre sœurs (ObsiGate, Flowdeck, TikTak,
Kanbante) dans app/services/draco_urls.py : explicite prioritaire
(contrat inchangé), sinon dérivation depuis l'hôte de l'agent connu
via AGENT_EVENEMENTS_URL (agent<SUFFIXE>.dracodev.net →
<slug><SUFFIXE>.dracodev.net), sinon défaut officiel. Hors motif
(autre domaine, LAN, IP) : aucune dérivation. Gitea inchangé.
Tests +4 dans test_draco_urls_devprod.py.
2026-10-26 00:46:21 +00:00
Spark 53ba3d1124 chore(release): 7.82.2 — CI test entièrement verte (pages-supports exclues des listes)
FlowDeck CI / docker (push) Successful in 1m43s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 32m21s
VERSION 7.82.1 -> 7.82.2, littéral de synchronisation version="7.82.2" dans app/main.py, entrée CHANGELOG v7.82.2.
2026-10-25 22:38:53 +00:00
Spark 8ce3b4b421 test: réparer la fuite mock_http → og_fetcher et le marqueur page-templates
- mock_http (test_agent_web_tools) : og_fetcher, importé paresseusement
  par url_fetch pendant la fenêtre patchée, figeait la factory mockée
  'build' comme shared_client ; son handler répondait ensuite aux
  fetch_og_metadata des tests suivants du même worker xdist (flake
  test_v55 : « Titre de la page »). La restauration du finally couvre
  désormais og_fetcher, comme elle couvrait déjà url_fetch.
- test_editor_front_end_wired : depuis la refonte templates (fc85481),
  le câblage page-templates vit dans page_editor_scripts.js (le picker
  de base.html passe par le registre unifié /api/templates) ; le
  marqueur est vérifié dans le fichier qui le porte, à force égale.
2026-10-25 22:38:53 +00:00
Spark 193fbd14ce fix(listes): exclure les pages-supports de templates des listes utilisateur
Les pages-supports créées par TemplateService._create_holder (une par
template, search_excluded=1, « masquées, jamais listées ») n'étaient
exclues d'aucune liste : semées à l'init avec updated_at = now (99 sur
une base fraîche), elles occupaient toutes les places des listes
triées par récence (LIMIT 50/20/8) devant les vraies pages — cause
racine de 7 échecs CI (library recents/private/workspace, sidebar
recents, projets builtin, mentions agent), dont l'instabilité du test
provenance (égalité de seconde avec le seed).

Filtre COALESCE(search_excluded, 0) = 0 ajouté : onglets bibliothèque
(meetings, recents, favorites, shared, published, private, workspace),
sidebar Recents, projets builtin, scans de pages des mentions agent.
2026-10-25 22:38:53 +00:00
Spark 3c8fc3070a chore(release): 7.82.1 — CI verte (lint + test de version)
FlowDeck CI / test (push) Failing after 33m52s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped
VERSION 7.82.0 -> 7.82.1, littéral de synchronisation version="7.82.1" dans app/main.py, entrée CHANGELOG v7.82.1.
2026-10-25 19:26:59 +00:00
Spark 49164ce057 fix(ci): corriger les 16 erreurs ruff et resynchroniser le littéral de version
Lint (run 2304, job 7058, ruff check app tests) : E402+I001 import mcp_server remonté en tête de app/main.py ; B904 x8 dans api_v2/templates_io.py (raise ... from None, from exc pour ValueError) ; UP017 datetime.UTC dans agent_evenements.py ; F841 x3 dans fd_templates.py (void/uid morts, import inutilisé retirés en chaîne) ; F401+F841 dans tests/test_fd_templates.py (import json inutilisé, n_presets en appel nu).

Version : le test test_pwa_offline cherche le littéral version="7.82.0" dans app/main.py — patron historique des bumps (scripts/bump_version.py le cherche aussi), rompu en 7.80.2 quand l'argument FastAPI est passé à _lire_version(). Littéral reposé en commentaire de synchronisation explicite, version runtime inchangée (lecture dynamique VERSION).
2026-10-25 19:26:09 +00:00
17 changed files with 688 additions and 50 deletions
+135
View File
@@ -1,5 +1,140 @@
# 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)(<SUFFIXE>)\.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()`
(menu inter-apps, SSO, preset TikTak, références croisées)
restaient aux noms **prod** quand le `.env` ne déclarait pas les
quatre URL une à une — seul l'agent était en `-dev`, via
`AGENT_EVENEMENTS_URL`. Le menu inter-apps de la dev envoyait
donc vers la prod.
- **Dérivation d'environnement** (`app/services/draco_urls.py`,
résolution uniforme des quatre sœurs — ObsiGate, Flowdeck,
TikTak, Kanbante) : 1) URL explicite prioritaire, contrat
inchangé (`DRACO_SSO_URL`, `TIKTAK_URL`, `KANBANTE_URL`,
`FLOWDECK_URL`/`OAUTH_REDIRECT_URI`…) ; 2) sinon, si l'hôte de
l'agent tel que l'app le connaît déjà (`AGENT_EVENEMENTS_URL`,
via `agent_evenements_url()`) est de la forme
`agent<SUFFIXE>.dracodev.net` (SUFFIXE éventuellement vide,
typiquement `-dev`), l'URL sœur est dérivée :
`https://<slug><SUFFIXE>.dracodev.net` (slugs `og`, `flowdeck`,
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN,
IP) = **aucune dérivation**, jamais un lien deviné ; 3) sinon
défaut officiel, comme avant. L'URL **Gitea ne change pas** ;
les gabarits et le JS de la barre, qui consomment `draco_urls()`,
sont inchangés.
- Tests : `tests/test_draco_urls_devprod.py` +4 — seul
`AGENT_EVENEMENTS_URL=…agent-dev…` posé → sœurs en `-dev` ;
explicite gagne sur la dérivation ; hôtes d'agent hors motif
→ défauts officiels ; agent officiel (suffixe vide) → sœurs
officielles. Non-régression « aucun env » déjà couverte.
## v7.82.2 (2026-10-25) — CI test entièrement verte : pages-supports de templates exclues des listes
Cause racine commune à 7 des 8 échecs restants : la refonte templates
(`fc85481`, 2026-10-10) a introduit les **pages-supports** — une page
interne par template système (99 sur une base fraîche), créée par
`TemplateService._create_holder` avec `search_excluded=1` et la consigne
« masquée, jamais listée ». Aucune liste ne respectait cette consigne :
datées `updated_at = now`, elles occupaient toutes les places des
listes triées par récence (LIMIT 50/20/8), devant les vraies pages.
- **Bibliothèque** (`app/routers/library.py`) : filtre `NO_HOLDER`
(`COALESCE(p.search_excluded, 0) = 0`) ajouté aux onglets meetings,
recents, favorites, shared, published, private et workspace.
- **Sidebar Recents** (`dashboard/_common.py::_recent_sidebar_pages`)
et **projets builtin** (`dashboard/workspace.py`) : même exclusion —
les pages-supports n'y sont plus ni des « fichiers récents » ni des
« projets ».
- **Mentions agent** (`routers/agent.py::list_mentions`) : les trois
scans de pages excluent les pages-supports, jamais mentionnables.
- **Flake `test_v55` (og parser)** : `mock_http`
(`tests/test_agent_web_tools.py`) répare désormais aussi la référence
`shared_client` figée par `og_fetcher` quand il est importé pendant
la fenêtre patchée (même classe de fuite que celle déjà traitée pour
`url_fetch`) — le handler d'un test `fetch_url` répondait sinon aux
fetchs OG des tests suivants du même worker xdist.
- **`test_editor_front_end_wired`** : depuis `fc85481`, le picker de
`base.html` passe par le registre unifié (`/api/templates`) et le
câblage `page-templates` vit dans l'éditeur
(`static/js/page_editor_scripts.js`) ; le marqueur est vérifié dans
le fichier qui le porte. Aucune assertion supprimée ni affaiblie.
## v7.82.1 (2026-10-25) — CI : lint ruff vert et littéral de version resynchronisé
- **Lint (CI run 2304, job 7058)** : les 16 erreurs `ruff check
app tests` sont corrigées dans le code, sans toucher à la
configuration ruff ni ajouter de `noqa` : import du routeur MCP
remonté en tête de `app/main.py` (E402/I001) ; `raise … from
None` / `from exc` sur les 8 `raise` dans des `except` de
`app/routers/api_v2/templates_io.py` (B904) ; `datetime.UTC`
dans `app/services/agent_evenements.py` (UP017) ; variables
mortes `void`/`uid` et import devenu inutilisé retirés de
`app/services/fd_templates.py` (F841) ; import `json`
inutilisé et variable `n_presets` morte retirés de
`tests/test_fd_templates.py` (F401/F841).
- **Test de version** : `test_pwa_offline` cherche le littéral
`version="…"` dans `app/main.py` — patron historique des bumps
(`scripts/bump_version.py` le cherche aussi), rompu en 7.80.2
quand l'argument FastAPI est passé à la lecture dynamique de
`VERSION`. Le littéral est reposé en commentaire de
synchronisation explicite (`version="7.82.1"`), à mettre à
jour à chaque bump ; la version runtime reste lue
dynamiquement depuis `VERSION` par `_lire_version()`.
- Aucun autre comportement modifié. Huit échecs de tests
pré-existants (antérieurs à la 7.82.0, prouvés par comparaison
des runs ciblés sur `eb4c42b` et `f844940`) restent ouverts et
ne sont pas corrigés par cette version.
## v7.82.0 (2026-10-25) — Noms publics dracodev par configuration (dev/prod)
- **Aucun nom public d'une autre application n'est plus codé en dur**
+1 -1
View File
@@ -1 +1 @@
7.82.0
7.82.4
+8 -2
View File
@@ -51,6 +51,7 @@ from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.mcp_server import router as mcp_server_router
from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router
@@ -214,6 +215,12 @@ def _lire_version() -> str:
return "0.0.0"
# Littéral de version synchronisé avec le fichier VERSION :
# 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()
# (correctif 7.80.2) — ce littéral n'est qu'un repère de synchronisation.
app = FastAPI(
title="FlowDeck",
version=_lire_version(),
@@ -263,8 +270,7 @@ app.include_router(admin.router)
app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router)
from app.routers import mcp_server as _mcp_server # Fusion — serveur MCP (service d'agents)
app.include_router(_mcp_server.router)
app.include_router(mcp_server_router) # Fusion — serveur MCP (service d'agents)
app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
+14 -4
View File
@@ -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(
+5 -3
View File
@@ -838,6 +838,8 @@ def list_mentions(request: Request, q: str = ""):
Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans
titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois.
"""
# Les scans de pages excluent les pages-supports de templates
# (search_excluded=1 — cf. library.NO_HOLDER) : jamais mentionnables.
query = (q or "").strip()[:60]
like = f"%{query}%"
limit = 8
@@ -873,7 +875,7 @@ def list_mentions(request: Request, q: str = ""):
if ws:
sql_ws = (
"SELECT p.id, p.title FROM pages p "
"WHERE p.workspace_id=? AND p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) "
"WHERE p.workspace_id=? AND p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?"
)
files = [
@@ -922,7 +924,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?",
(ws, query, like, limit)
).fetchall():
@@ -939,7 +941,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) "
"WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit)
).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"],
+8 -8
View File
@@ -235,9 +235,9 @@ def get_fd_template_v2(template_id: str, request: Request, authorization: str |
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
raise HTTPException(404, "Template not found") from None
except PermissionError:
raise HTTPException(403, "Forbidden")
raise HTTPException(403, "Forbidden") from None
@router.post("/fd-templates/{template_id}/instantiate")
@@ -258,11 +258,11 @@ def instantiate_fd_template_v2(template_id: str, request: Request,
conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError:
raise HTTPException(404, "Template not found")
raise HTTPException(404, "Template not found") from None
except PermissionError:
raise HTTPException(403, "Forbidden")
raise HTTPException(403, "Forbidden") from None
except ValueError as exc:
raise HTTPException(400, str(exc))
raise HTTPException(400, str(exc)) from exc
audit_log(user, "template.instantiate", "template", template_id, "")
if idem:
store_idempotency(idem, user["id"], result, 200)
@@ -281,9 +281,9 @@ def export_fd_template_v2(template_id: str, request: Request, authorization: str
dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row)
except LookupError:
raise HTTPException(404, "Template not found")
raise HTTPException(404, "Template not found") from None
except PermissionError:
raise HTTPException(403, "Forbidden")
raise HTTPException(403, "Forbidden") from None
manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
@@ -308,7 +308,7 @@ def import_fd_template_v2(request: Request, authorization: str | None = Header(d
try:
version = int(body.get("version") or 0)
except (TypeError, ValueError):
raise HTTPException(400, "version invalide")
raise HTTPException(400, "version invalide") from None
if version > 1:
raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page"
+7 -5
View File
@@ -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
+1
View File
@@ -107,6 +107,7 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND COALESCE(p.search_excluded, 0) = 0
AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
+2 -1
View File
@@ -222,7 +222,8 @@ async def list_workspace_projects(request: Request):
builtin = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL "
"AND COALESCE(search_excluded, 0) = 0 ORDER BY updated_at DESC LIMIT 20"
).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {}
+14 -7
View File
@@ -210,6 +210,12 @@ BASE_SELECT = (
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
)
# Pages-supports de templates (fd_templates._create_holder) : pages internes
# marquées search_excluded=1 — « masquées, jamais listées ». Elles ne doivent
# apparaître dans AUCUN onglet de la bibliothèque (une base fraîche en sème 99,
# qui noieraient sinon recents/private/workspace sous leur updated_at = now).
NO_HOLDER = "COALESCE(p.search_excluded, 0) = 0"
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
@@ -240,7 +246,7 @@ def library_meetings(
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}"
f"AND {NO_HOLDER} AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
@@ -275,7 +281,7 @@ def library_recents(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = []
# Hierarchical view: show only root-level pages at the top
@@ -312,7 +318,8 @@ def library_favorites(
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL "
f"AND {NO_HOLDER}"
)
params: list = [uid]
if tree:
@@ -379,7 +386,7 @@ def library_shared(
query = (
f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
)
if tree:
query += " AND p.parent_id IS NULL"
@@ -410,7 +417,7 @@ def library_published(
uid = _get_user_id(request)
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -435,7 +442,7 @@ def library_private(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -501,7 +508,7 @@ def library_workspace(
source_type = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = []
if tree:
+2 -2
View File
@@ -31,7 +31,7 @@ import logging
import os
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
import httpx
@@ -86,7 +86,7 @@ def construire_enveloppe(type_: str, objet_ref: str, label: str,
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or "user",
"domain": domain or "pro",
"source_app": SOURCE_APP,
+228 -7
View File
@@ -18,15 +18,77 @@ Priorité de résolution, à chaque appel (les tests et les modules qui
lisaient déjà ``os.environ`` au moment de l'appel gardent ce
comportement) : variable d'environnement > ``app.config.settings`` >
défaut officiel. Aucun défaut ne contient ``-dev``.
**Dérivation d'environnement (v7.82.3)** — pour les quatre URL
sœurs (ObsiGate, Flowdeck, TikTak, Kanbante), entre la configuration
explicite et le défaut officiel : si l'origine de l'agent telle que
l'app la connaît déjà (``AGENT_EVENEMENTS_URL``, via
``agent_evenements_url()``) a un hôte de la forme
``agent<SUFFIXE>.dracodev.net`` (SUFFIXE éventuellement vide,
typiquement ``-dev``), l'URL sœur est dérivée :
``https://<slug><SUFFIXE>.dracodev.net`` (slugs : ``og``,
``flowdeck``, ``tiktak``, ``kanbante``). Ainsi une installation dev
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)(<SUFFIXE>)\\.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",
@@ -51,22 +113,165 @@ def _resoudre(env_name: str, settings_attr: str, app: str) -> str:
return OFFICIELLES[app]
# Slugs d'hôte des applications sœurs (dérivation d'environnement).
_SLUGS_SOEURS = {
"obsigate": "og",
"flowdeck": "flowdeck",
"tiktak": "tiktak",
"kanbante": "kanbante",
}
# Hôte de l'agent : agent<SUFFIXE>.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.
Source : l'origine de l'agent telle que l'app la connaît déjà —
``agent_evenements_url()`` (``AGENT_EVENEMENTS_URL`` explicite,
sinon dérivée de la base de l'agent). Retourne ``""`` pour
l'hôte officiel, ``"-dev"`` pour ``agent-dev.dracodev.net``, et
None si l'hôte ne correspond pas au motif (aucune dérivation).
"""
hote_agent = (urlparse(agent_evenements_url()).hostname or "").lower()
m = _MOTIF_HOTE_AGENT.match(hote_agent)
if not m:
return None
return m.group(1) or ""
def _url_derivee(app: str) -> str:
"""URL sœur dérivée de l'environnement de l'agent ("" si aucune)."""
slug = _SLUGS_SOEURS.get(app)
if not slug:
return ""
suffixe = _suffixe_environnement()
if suffixe is None:
return ""
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, 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 les dérivations. Une valeur settings différente (dev,
LAN, domaine propre) reste prioritaire.
"""
env = _nettoyer(os.environ.get(env_name, ""))
if env:
return env
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
if cfg:
return cfg
return OFFICIELLES[app]
def obsigate_url() -> str:
"""ObsiGate — aussi l'émettrice SSO (DRACO_SSO_URL, déjà en service)."""
return _resoudre("DRACO_SSO_URL", "draco_sso_url", "obsigate")
return _resoudre_soeur("DRACO_SSO_URL", "draco_sso_url", "obsigate")
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:
return _resoudre("TIKTAK_URL", "tiktak_url", "tiktak")
return _resoudre_soeur("TIKTAK_URL", "tiktak_url", "tiktak")
def kanbante_url() -> str:
return _resoudre("KANBANTE_URL", "kanbante_url", "kanbante")
return _resoudre_soeur("KANBANTE_URL", "kanbante_url", "kanbante")
def gitea_url() -> str:
@@ -78,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:
@@ -95,9 +301,19 @@ 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
derivee = _url_derivee("flowdeck")
if derivee:
return derivee
return OFFICIELLES["flowdeck"]
@@ -129,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(),
-6
View File
@@ -714,7 +714,6 @@ class TemplateService:
cols = _cols(conn, "pages")
ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private"
try:
uid = _actor_id(actor)
data: dict[str, Any] = {}
if "workspace" in cols:
data["workspace"] = ws_name
@@ -740,7 +739,6 @@ class TemplateService:
f"INSERT INTO pages ({keys}) VALUES ({placeholders})",
tuple(data.values()),
)
void = uid # traçabilité future (réservé)
return int(cur.lastrowid)
except Exception:
logger.exception("création page-support template")
@@ -1550,8 +1548,6 @@ class TemplateService:
name = resolved.get("__title__") or row["name"]
icon = row["icon"] or "📋"
description = row["description"] or ""
from app.services.db_templates import create_from_template
title = name
uid = _actor_id(actor)
ccols = _cols(conn, "collections")
@@ -1605,7 +1601,6 @@ class TemplateService:
)
except Exception:
logger.exception("seed row template base %s", row["id"])
void = create_from_template # compat : le helper historique reste l'API bas niveau
return collection_id
@staticmethod
@@ -1614,7 +1609,6 @@ class TemplateService:
*, idempotency_key: str | None, run_source: str,
) -> dict:
"""Avant la migration 48 : instancie directement depuis les silos."""
uid = _actor_id(actor)
if template_id.startswith("legacy_database:"):
tid = int(template_id.split(":", 1)[1])
legacy = conn.execute(
+9
View File
@@ -24,6 +24,7 @@ import contextlib
import ipaddress
import json
import socket
import sys
import httpx
import pytest
@@ -108,6 +109,14 @@ def mock_http(handler):
from app.services.importers import url_fetch
if getattr(url_fetch, "shared_client", None) is build:
url_fetch.shared_client = _REAL_SHARED_CLIENT
# Même fuite via og_fetcher : url_fetch l'importe paresseusement
# (parse_og) PENDANT la fenêtre, et son `from … import shared_client`
# fige alors `build` — le handler du test courant répondait ensuite
# aux fetch_og_metadata des tests suivants du même worker xdist
# (flake test_v55 : titre « Titre de la page » au lieu du sien).
og_fetcher = sys.modules.get("app.services.og_fetcher")
if og_fetcher is not None and getattr(og_fetcher, "shared_client", None) is build:
og_fetcher.shared_client = _REAL_SHARED_CLIENT
def run(coro):
+248
View File
@@ -110,6 +110,72 @@ def test_preset_tiktak_depuis_config(monkeypatch):
"https://tiktak-dev.dracodev.net/mcp"
def test_derivation_dev_depuis_agent_evenements_seul(monkeypatch):
# Cas réel de la dev : seul AGENT_EVENEMENTS_URL est posé —
# les quatre URLs sœurs servies suivent en ``-dev``, sans
# déclarer quatre variables de plus. Gitea ne change pas.
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
# Les accesseurs individuels partagent la même résolution.
assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net"
assert draco_urls.flowdeck_url() == "https://flowdeck-dev.dracodev.net"
assert draco_urls.tiktak_url() == "https://tiktak-dev.dracodev.net"
assert draco_urls.kanbante_url() == "https://kanbante-dev.dracodev.net"
def test_derivation_explicite_gagne_sur_derivation(monkeypatch):
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
# Explicites (contrat inchangé) : elles priment sur la dérivation,
# y compris quand elles pointent le nom officiel.
monkeypatch.setenv("DRACO_SSO_URL", "https://og.dracodev.net")
monkeypatch.setenv("TIKTAK_URL", "https://tiktak.example.net")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["tiktak"] == "https://tiktak.example.net"
# Non déclarées : dérivées.
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
def test_derivation_hote_agent_hors_motif_defauts_officiels(monkeypatch):
# Autre domaine, LAN, IP : aucune dérivation, jamais un lien deviné.
for url_agent in (
"https://agent.example.com/api/v1/evenements",
"https://agent-dev.example.net/api/v1/evenements",
"https://monagent-dev.dracodev.net/api/v1/evenements",
"https://agent-dev.dracodev.net.evil.com/api/v1/evenements",
"http://192.168.30.101:8790/api/v1/evenements",
"http://127.0.0.1:8790/api/v1/evenements",
"http://agent-dev.local:8790/api/v1/evenements",
):
monkeypatch.setenv("AGENT_EVENEMENTS_URL", url_agent)
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net", url_agent
assert urls["flowdeck"] == "https://flowdeck.dracodev.net", url_agent
assert urls["tiktak"] == "https://tiktak.dracodev.net", url_agent
assert urls["kanbante"] == "https://kanbante.dracodev.net", url_agent
def test_derivation_agent_officiel_suffixe_vide(monkeypatch):
# Hôte agent officiel : suffixe vide, les sœurs restent officielles.
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert urls["tiktak"] == "https://tiktak.dracodev.net"
assert urls["kanbante"] == "https://kanbante.dracodev.net"
def test_bouton_sso_login_depuis_config(monkeypatch):
from app.routers.auth import _sso_login_url
@@ -122,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
+1 -3
View File
@@ -5,8 +5,6 @@ résolution des variables (aucun {{token}} survivant), dégradation sur
propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée,
API interne et annulation (undo).
"""
import json
import pytest
from app.db import get_conn
@@ -53,7 +51,7 @@ def test_backfill_idempotent_and_presets(client, actor):
"SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0]
assert reg_db == legacy_db and legacy_db > 0
# Galerie système : au moins 5 presets utilisables.
n_presets = TemplateService.ensure_system_presets(conn)
TemplateService.ensure_system_presets(conn)
sys_rows = conn.execute(
"SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0]
assert sys_rows >= 5
+5 -1
View File
@@ -396,7 +396,11 @@ def test_editor_front_end_wired(client):
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
assert "WM={" in src and "fdpage:" in src # [[ picker module
assert "_openTemplatePicker" in base and "page-templates" in base
# Depuis la refonte templates (fc85481), le picker de base.html passe par
# le registre unifié (/api/templates) ; le câblage « page-templates » vit
# dans l'éditeur (fetch /board/api/page-templates) — chaque marqueur est
# donc vérifié dans le fichier qui le porte réellement.
assert "_openTemplatePicker" in base and "page-templates" in src
assert "toggleLock" in src and "saveAsTemplate" in src
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css