From 6ff88237fc5319c1ffc11a5cbf1a8e16f2f83cc2 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 12:08:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20A20=20phase=203=20LOT=201=20=E2=80=94?= =?UTF-8?q?=20shell=20+=20library=20migres,=20harnais=20csp=5Fpreview=20ve?= =?UTF-8?q?rt=20(v7.38.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout : - e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a la place de alpine.min.js par interception Playwright ; toute expression que le parseur maison ne digere pas = pageerror (filet). Premiere surface VERTE : library (composant lie, icones SVG via Alpine.effect, recherche ouverte + focalisee, 0 erreur). Changed : - 16 composants x-data="fn()" enregistres via Alpine.data (registre = seule resolution du build CSP, probe « Undefined variable » ; scripts classiques executes pendant le parsing => alpine:init toujours joint) : appState, libraryPage, workspacesPage, editorState, board x4, settings/import/table_view/team_load/trash/workspace/welcome/accounts/ card_detail. - base.html (shell) migre : x-effect document.* -> syncSidebarClass(), $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* -> fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/ window.innerWidth dans x-for et :style -> sidebarSections()/ sectionMenuPos() — tout = simple appel de methode. - x-html restants du shell -> x-init + Alpine.effect : icone agent, carte projet, library x3 ; recherche library -> toggleSearch() (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression. - eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1, /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels). suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2) · docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION) --- CHANGELOG.md | 39 ++++++++++++++ ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/templates/accounts.html | 5 ++ app/templates/base.html | 65 ++++++++++++++++++----- app/templates/card_detail.html | 5 ++ app/templates/library.html | 12 ++--- app/templates/table_view.html | 5 ++ app/templates/team_load.html | 5 ++ app/templates/trash.html | 5 ++ app/templates/welcome.html | 5 ++ app/templates/workspace.html | 7 ++- docs/openapi-v2.json | 2 +- e2e/_csp_probe3.js | 40 ++++++++++++++ e2e/csp_preview.spec.js | 90 ++++++++++++++++++++++++++++++++ e2e/fixtures/alpine.csp.js | 23 ++++++++ eslint.config.mjs | 2 + static/js/app.js | 1 + static/js/board.js | 24 ++++++++- static/js/import.js | 5 ++ static/js/library.js | 30 ++++++++++- static/js/page_editor_scripts.js | 5 ++ static/js/settings.js | 5 ++ static/js/workspaces.js | 5 ++ 26 files changed, 365 insertions(+), 30 deletions(-) create mode 100644 e2e/_csp_probe3.js create mode 100644 e2e/csp_preview.spec.js create mode 100644 e2e/fixtures/alpine.csp.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e81d92..4ff3d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog - FlowDeck +## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres + +### Added + +- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le + build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`) + est servi **à la place** de `alpine.min.js` par interception Playwright ; + tout échec du parseur maison = `pageerror` (filet). **Première surface + verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`, + recherche ouverte + focalisée, 0 erreur console/page). + +### Changed + +- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 + + `appState` + `libraryPage`) : le build CSP ne résout que le registre + (probe : globale window → `Undefined variable`) — scripts/classiques + chargés pendant le parsing = `alpine:init` toujours joint à temps. +- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`, + `$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` → + `fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/ + `window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/ + `sectionMenuPos()` — toutes les formes = simple appel de méthode. +- **x-html restants du shell** → `x-init` + `Alpine.effect` : + icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`), + library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library + (`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library). +- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans + les globals du config, `/* exported openCardDetail */` + + `/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion + A38 supprimés. + +### Notes + +- Portes A20 ph3 : surfaces restantes = settings, local_workspace, + gitea_workspace, page_editor, board (partiels), agent_panel, import, + welcome, accounts, trash, team_load, workspace, table_view, card_detail ; + **bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert + sur toutes les pages principales. + ## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index de51396..5af334a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1137,7 +1137,9 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.* - [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.* - [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.* -- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant (library → settings → local_workspace → gitea → base) avec gate E2E dédiée par surface, puis retrait `unsafe-eval`. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus). +- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`. +**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093. +**Reste ph3** : surfaces settings, local_workspace, gitea_workspace, page_editor, board, agent_panel, import, welcome/accounts/trash/team_load/workspace/table_view/card_detail (partiels) → csp_preview vert partout, puis bascule réelle. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus). - [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait). - [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.* - [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.* diff --git a/VERSION b/VERSION index 5c55210..863b3e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.37.0 +7.38.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index e7dfd04..e4de35d 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.37.0 (A20 htmx allowEval off + probe Alpine CSP = plan phase 3 (13 exprs + 24 x-html)) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.38.0 (A20 ph3 LOT 1 : shell base + library migres Alpine.data, harness csp_preview vert, eslint 0/0) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index c1e44ca..e17f77a 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.37.0", + version="7.38.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/templates/accounts.html b/app/templates/accounts.html index 60fb9f8..1cb860a 100644 --- a/app/templates/accounts.html +++ b/app/templates/accounts.html @@ -105,6 +105,11 @@ {% block scripts %} +`; + +(async () => { + const browser = await chromium.launch({ headless: true, executablePath: findChromium() }); + const page = await browser.newPage(); + const errs = []; + page.on('pageerror', (e) => errs.push(e.message.slice(0, 120))); + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(500); + const res = await page.evaluate(() => ({ + global: document.querySelector('#s1').textContent, + alpineData: document.querySelector('#s2').textContent, + })); + console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune'); + await browser.close(); +})(); diff --git a/e2e/csp_preview.spec.js b/e2e/csp_preview.spec.js new file mode 100644 index 0000000..bdd61fb --- /dev/null +++ b/e2e/csp_preview.spec.js @@ -0,0 +1,90 @@ +const { test, expect } = require('@playwright/test'); + +/** + * Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP + * d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de + * alpine.min.js via interception — SANS déployer. Toute expression que le + * parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ; + * les x-html restants = directive interdite du build (console error). + * Quand toutes les surfaces passent ici → bascule réelle + retrait + * d'unsafe-eval (ROADMAP A20 phase 3). + */ +const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080'; +const USER = process.env.FD_USER || 'e2e@flowdeck.local'; +const PASS = process.env.FD_PASS || 'e2e-secret-123'; + +test.use({ serviceWorkers: 'block' }); + +const errors = []; +test.beforeEach(async ({ page }) => { + errors.length = 0; + await page.route('**/static/js/alpine.min.js', (route) => + route.fulfill({ + path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'), + contentType: 'application/javascript', + }) + ); + page.on('console', (m) => { + if (m.type() !== 'error') return; + if (/Failed to load resource/.test(m.text())) return; + errors.push(m.text()); + }); + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); +}); +test.afterEach(() => expect(errors).toEqual([])); + +async function login(page) { + await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' }); + await page.fill('#email', USER); + await page.fill('#password', PASS); + await page.click('.btn-primary'); + const ok = await page + .waitForURL('**/workspaces', { timeout: 10000 }) + .then(() => true) + .catch(() => false); + if (!ok) { + const resp = await page.request.post(`${FD_BASE}/auth/register`, { + data: { email: USER, password: PASS, name: 'E2E' }, + }); + if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects'); + if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`); + await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' }); + await page.waitForURL('**/workspaces', { timeout: 15000 }); + } +} + +test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => { + await login(page); + await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(600); + + // le composant est lié par le registre Alpine.data (scope CSP) + const bound = await page.evaluate(() => { + const el = document.querySelector('[x-data]'); + if (!el || !window.Alpine) return 'absent'; + try { + const d = window.Alpine.$data(el); + return d && typeof d === 'object' ? 'ok' : 'vide'; + } catch (e) { + return 'throw:' + e.message; + } + }); + expect(bound).toBe('ok'); + + // icône du empty-state : x-html remplacé par x-init + Alpine.effect + await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 }); + const svg = await page.evaluate( + () => document.querySelector('#lib-empty .empty-icon').innerHTML + ); + expect(svg).toContain(' document.activeElement && document.activeElement.id === 'lib-search-input' + ); + expect(focused).toBe(true); +}); diff --git a/e2e/fixtures/alpine.csp.js b/e2e/fixtures/alpine.csp.js new file mode 100644 index 0000000..5e8d494 --- /dev/null +++ b/e2e/fixtures/alpine.csp.js @@ -0,0 +1,23 @@ +(()=>{var bt=!1,vt=!1,K=[],wt=-1,Fe=!1,At=!1;function wr(e){_n(e)}function Ar(){At=!0}function Or(){At=!1,Sr()}function _n(e){K.includes(e)||(K.push(e),e._x_schedulerPriority!==void 0&&(Fe=!0)),Sr()}function he(e){let t=K.indexOf(e);t!==-1&&t>wt&&K.splice(t,1)}function Sr(){if(!vt&&!bt){if(At)return;bt=!0,queueMicrotask(gn)}}function gn(){bt=!1,vt=!0;for(let e=0;exn(i,n,t));for(let i=0;ie.effect(t,{scheduler:r=>{Ot?wr(r):r()}}),St=e.raw}function Nt(e){j=e}function Cr(e){let t=()=>{};return[(i,n)=>{let s=n?.priority==="structural"?En++:void 0,o=j(i);return s!==void 0&&o!==void 0&&(o._x_schedulerPriority={el:e,order:s}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(o),t=()=>{o!==void 0&&(e._x_effects.delete(o),q(o))},o},()=>{t()}]}function $e(e,t){let r=!0,i,n,s=j(()=>{let o=e(),a=JSON.stringify(o);if(!r&&(typeof o=="object"||o!==i)){let c=typeof i=="object"?JSON.parse(n):i;queueMicrotask(()=>{t(o,c)})}i=o,n=a,r=!1});return()=>{he(s),q(s)}}async function Rr(e){Ar();try{await e(),await Promise.resolve()}finally{Or()}}var kr=[],Dr=[],Pr=[];function Ir(e){Pr.push(e)}function me(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Dr.push(t))}function He(e){kr.push(e)}function Be(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Tt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,i])=>{(t===void 0||t.includes(r))&&(i.forEach(n=>n()),delete e._x_attributeCleanups[r])})}function Mr(e){for(e._x_effects?.forEach(he);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Ke=new MutationObserver(qe),Ct=!1;function Oe(){Ke.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Ct=!0}function Rt(){bn(),Ke.disconnect(),Ct=!1}var te=[];function bn(){let e=Ke.takeRecords();te.push(()=>e.length>0&&qe(e));let t=te.length;queueMicrotask(()=>{if(te.length===t)for(;te.length>0;)te.shift()()})}function Lr(){for(;te.length>0;)te.shift()();let e=Ke.takeRecords();e.length>0&&qe(e)}function _(e){if(!Ct)return e();Rt();try{return e()}finally{Oe()}}var kt=!1,Ve=[];function Ur(){kt=!0}function jr(){kt=!1,qe(Ve),Ve=[]}function qe(e){if(kt){Ve=Ve.concat(e);return}let t=[],r=new Set,i=new Map,n=new Map;for(let s=0;s{o.nodeType===1&&o._x_marker&&r.add(o)}),e[s].addedNodes.forEach(o=>{if(o.nodeType===1){if(r.has(o)){r.delete(o);return}o._x_marker||t.push(o)}})),e[s].type==="attributes")){let o=e[s].target,a=e[s].attributeName,c=e[s].oldValue,l=()=>{i.has(o)||i.set(o,[]),i.get(o).push({name:a,value:o.getAttribute(a)})},u=()=>{n.has(o)||n.set(o,[]),n.get(o).push(a)};o.hasAttribute(a)&&c===null?l():o.hasAttribute(a)?(u(),l()):u()}n.forEach((s,o)=>{Tt(o,s)}),i.forEach((s,o)=>{kr.forEach(a=>a(o,s))});for(let s of r)t.some(o=>o.contains(s))||Dr.forEach(o=>o(s));for(let s of t)s.isConnected&&Pr.forEach(o=>o(s));t=null,r=null,i=null,n=null}function ze(e){return P(z(e))}function F(e,t,r){return e._x_dataStack=[t,...z(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(i=>i!==t)}}function z(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?z(e.host):e.parentNode?z(e.parentNode):[]}function P(e){return new Proxy({objects:e},vn)}function Fr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:Fr(Object.getPrototypeOf(e),t)}var vn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?wn:Reflect.get(e.find(i=>Reflect.has(i,t))||{},t,r)},set({objects:e},t,r,i){let n;for(let o of e)if(n=Fr(o,t),n)break;n||(n=e[e.length-1]);let s=Object.getOwnPropertyDescriptor(n,t);return s?.set&&s?.get?s.set.call(i,r)||!0:Reflect.set(n,t,r)}};function wn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function _e(e,t=()=>{}){let r=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,i=(n,s="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=s===""?o:`${s}.${o}`;typeof a=="object"&&a!==null&&a._x_interceptor?n[o]=a.initialize(e,l,o,t):r(a)&&a!==n&&!(a instanceof Element)&&i(a,l)})};return i(e)}function We(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(i,n,s,o){return e(this.initialValue,()=>An(i,n),a=>Dt(i,n,a),n,s,o)}};return t(r),i=>{if(typeof i=="object"&&i!==null&&i._x_interceptor){let n=r.initialize.bind(r);r.initialize=(s,o,a,c)=>{let l=i.initialize(s,o,a,c);return r.initialValue=l,n(s,o,a,c)}}else r.initialValue=i;return r}}function An(e,t){return t.split(".").reduce((r,i)=>r[i],e)}function Dt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Dt(e[t[0]],t.slice(1),r)}}var $r={};function v(e,t){$r[e]=t}function X(e,t){let r=On(t);return Object.entries($r).forEach(([i,n])=>{Object.defineProperty(e,`$${i}`,{get(){return n(t,r)},enumerable:!1})}),e}function On(e){let[t,r]=Pt(e),i={interceptor:We,...t};return me(e,r),i}function It(e,t,r,...i){try{return r(...i)}catch(n){re(n,e,t)}}function re(...e){return Vr(...e)}var Vr=Sn;function Hr(e){Vr=e}function Sn(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Z=!0;function Ge(e){let t=Z;Z=!1;let r=e();return Z=t,r}function I(e,t,r={}){let i;return b(e,t)(n=>i=n,r),i}function b(...e){return Br(...e)}var Br=()=>{};function Kr(e){Br=e}var qr;function zr(e){qr=e}function Wr(e,t){return(r=()=>{},{scope:i={},params:n=[],context:s}={})=>{if(!Z){Mt(r,t,P([i,...e]),n);return}let o=t.apply(P([i,...e]),n);Mt(r,o)}}function Mt(e,t,r,i,n){if(Z&&typeof t=="function"){let s=t.apply(r,i);s instanceof Promise?s.then(o=>Mt(e,o,r,i)).catch(o=>re(o,n,t)):e(s)}else typeof t=="object"&&t instanceof Promise?t.then(s=>e(s)):e(t)}function Gr(...e){return qr(...e)}var jt="x-";function R(e=""){return jt+e}function Yr(e){jt=e}var Ye={};function h(e,t){return Ye[e]=t,{before(r){if(!Ye[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let i=ie.indexOf(r);ie.splice(i>=0?i:ie.indexOf("DEFAULT"),0,e)}}}function Jr(e){return Object.keys(Ye).includes(e)}function ne(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let s=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),o=Ft(s);s=s.map(a=>o.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(s)}let i={};return t.map(Qr((s,o)=>i[s]=o)).filter(ti).map(Tn(i,r)).sort(Cn).map(s=>Nn(e,s))}function Ft(e){return Array.from(e).map(Qr()).filter(t=>!ti(t))}var Lt=!1,Se=new Map,Xr=Symbol();function Zr(e){Lt=!0;let t=Symbol();Xr=t,Se.set(t,[]);let r=()=>{for(;Se.get(t).length;)Se.get(t).shift()();Se.delete(t)},i=()=>{Lt=!1,r()};e(r),i()}function Pt(e){let t=[],r=a=>t.push(a),[i,n]=Cr(e);return t.push(n),[{Alpine:W,effect:i,cleanup:r,evaluateLater:b.bind(b,e),evaluate:I.bind(I,e)},()=>t.forEach(a=>a())]}function Nn(e,t){let r=()=>{},i=Ye[t.type]||r,[n,s]=Pt(e);Be(e,t.original,s);let o=()=>{e._x_ignore||e._x_ignoreSelf||(i.inline&&i.inline(e,t,n),i=i.bind(i,e,t,n),Lt?Se.get(Xr).push(i):i())};return o.runCleanups=s,o}var Je=(e,t)=>({name:r,value:i})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:i}),Xe=e=>e;function Qr(e=()=>{}){return({name:t,value:r})=>{let{name:i,value:n}=ei.reduce((s,o)=>o(s),{name:t,value:r});return i!==t&&e(i,t),{name:i,value:n}}}var ei=[];function ge(e){ei.push(e)}function ti({name:e}){return ri().test(e)}var ri=()=>new RegExp(`^${jt}([^:^.]+)\\b`);function Tn(e,t){return({name:r,value:i})=>{r===i&&(i="");let n=r.match(ri()),s=r.match(/:([a-zA-Z0-9\-_:]+)/),o=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:n?n[1]:null,value:s?s[1]:null,modifiers:o.map(c=>c.replace(".","")),expression:i,original:a}}}var Ut="DEFAULT",ie=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",Ut,"teleport"];function Cn(e,t){let r=ie.indexOf(e.type)===-1?Ut:e.type,i=ie.indexOf(t.type)===-1?Ut:t.type;return ie.indexOf(r)-ie.indexOf(i)}function $(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(n=>$(n,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let i=e.firstElementChild;for(;i;)$(i,t,!1),i=i.nextElementSibling}var V=!1;function O(e,t=()=>{}){return(...r)=>V?t(...r):e(...r)}function ii(e){return(...t)=>V&&e(...t)}var ni=[];function H(e){ni.push(e)}function si(e,t){ni.forEach(r=>r(e,t)),V=!0,ai(()=>{S(t,(r,i)=>{i(r,()=>{})})}),V=!1}var Ze=!1;function oi(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),V=!0,Ze=!0,ai(()=>{Rn(t)}),V=!1,Ze=!1}function Rn(e){let t=!1;S(e,(i,n)=>{$(i,(s,o)=>{if(t&&ci(s))return o();t=!0,n(s,o)})})}function ai(e){let t=j;Nt((r,i)=>{let n=t(r);return q(n),()=>{}}),e(),Nt(t)}var Qe=0;function li(e,t){let r=e._x_deferInit;r||(r=e._x_deferInit={pending:0,ownsIgnore:!e._x_ignore,queuedAttributes:new Map},r.ownsIgnore&&(e._x_ignore=!0),Qe++),r.pending++,Promise.resolve(t).catch(i=>{try{re(i,e)}catch(n){setTimeout(()=>{throw n},0)}}).then(()=>kn(e,r))}function kn(e,t){t.pending--,!(t.pending>0)&&(Lr(),!(t.pending>0)&&e._x_deferInit===t&&(delete e._x_deferInit,t.ownsIgnore&&delete e._x_ignore,Qe--,e.isConnected&&(Dn(t),S(e))))}function ui(e,t){if(Qe===0)return!1;let r=N(e,i=>i._x_deferInit);return r?(fi(r._x_deferInit,e,t.map(({name:i})=>i)),!0):!1}function fi(e,t,r){let i=e.queuedAttributes.get(t);(!i||i.marker!==t._x_marker)&&(i={marker:t._x_marker,names:new Set},e.queuedAttributes.set(t,i)),r.forEach(n=>i.names.add(n))}function Dn(e){e.queuedAttributes.forEach((t,r)=>{if(!r.isConnected||!r._x_marker||r._x_marker!==t.marker)return;let i=N(r,s=>s._x_deferInit);if(i){fi(i._x_deferInit,r,Array.from(t.names));return}let n=Array.from(t.names).filter(s=>r.hasAttribute(s)).map(s=>({name:s,value:r.getAttribute(s)}));n.length!==0&&ne(r,n).forEach(s=>s())})}H((e,t)=>{Qe!==0&&(!e||e.nodeType!==1||!t||t.nodeType!==1||N(e,r=>r._x_deferInit)&&(t._x_ignore=!0))});function se(e,t,r={},i={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...i}))}function T(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var pi=!1;function di(){pi&&T("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),pi=!0,document.body||T("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `