# Changelog - FlowDeck ## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon) ### Fixed - **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro `fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur `GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le code) → `{% from '_icons.html' import fd_icon %}` ajouté ### Tests - Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) : · stubs manuels sur `gitea_client.gitea` avec **état mutable partagé** (le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé aurait masqué la mise à jour) · `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne recalculée **sans perdre la carte** · `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404 · `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en base**, 404 sans board - `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089** - **A32 complet** : plus aucun router « 0 test » (webhooks, notes, sidebar_config, github_routes, library, api, dashboard, api_v2) ## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44) ### Tests - +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests : · **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté** : les retours `(..., 400)` de ces routes sont sérialisés FastAPI en tableau + 200 (`[{"error": "Invalid role"}, 400]`) · `upload-folder` : **validations seules** (structure absente → 400, JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé · `convert-to-database` : collection + propriété `title` + vue `table` + page en `content_format='collection'` **vérifiés en base**, 404 page inconnue, cleanup **dans l'ordre FK** (page avant collection) - **Recoupement final** : scan des 44 routes strictement à 0 ref de `dashboard.py` → **toutes exercées** (19 faux positifs résiduels = paths en f-string dans les tests, rapprochés manuellement) - Suite complète : **1086/1086** ## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13) ### Tests - +13 routes `dashboard.py` (cumul **43→56 sur 63**) — `test_smoke_uncovered.py` : 46 tests : · `/gitea-workspace` : page HTML (200 ou redirection propre) · `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec `github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans nom, nettoyage · **Cycle items local-workspace** (5 routes) : création → renommage **relu en base** → move → soft-delete (`deleted_at` **relu**) → restore (`deleted_at IS NULL` **relu**), nettoyage · **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item, liste workspace, search (shape), suppression vérifiée. Utilisateur + workspace **créés dans le test** : `/api/local-workspace/tags` a besoin d'un workspace actif (fallback « premier workspace du user ») — on ne le fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé - Suite complète : **1083/1083** ## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16) ### Tests - +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les garde-fous A16 — `test_smoke_uncovered.py` : 42 tests : · `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` → **403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit dans le data_dir de test → **200 + octets exacts** (nettoyé) · `GET /api/pages/{id}/download` : page markdown → 404 « downloadable » (pas de 500) ; page « file » avec chemin `../` qui sort de la racine → **jamais 200** (404), et `file-content` → 404/415 · `GET /api/local-workspace/page-content/{id}` : contenu + format relus, 404 sur id inconnu · `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False` (AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans avatar · `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape, ligne créée **retrouvée dans table-data**, nettoyage finally - Suite complète : **1079/1079** ## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes) ### Tests - +9 routes `dashboard.py` (cumul **27→36 sur 63**) : · `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent** du rendu (whitelist A2 vérifiée côté page) · `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine restaurée en `finally` · `PUT /api/user/password` : **403 « current password is incorrect »** (A3 — la session seule ne change pas le mdp) + quirk assumé et documenté : longueur validée AVANT auth → 200 + message d'erreur · `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens` nettoyée en `finally` · `DELETE /api/user/forge/{provider}` : `{"status": "ok"}` · `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp court** (validateur), restauration en `finally` · `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace` vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste - `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075** ## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes) ### Tests - Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10 couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) : · **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence dans la liste, changement de couleur relu, suppression puis absence · **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename` (ok + **400 sur titre vide** + titre relu en base) → POST `trash` (`parent_section='Trash'` + `deleted_at` **relus en base**) · `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie · `settings/avatar-color` : couleur relue **sur l'utilisateur de la session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally` · `workspace/{id}/members` : shape `{"members": [...]}` - Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …) - Suite complète : **1069/1069** ## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes) ### Tests - Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de chemin littérale) → **5 routes à 0 référence**, toutes couvertes : · `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression` (le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble route/auth/parse, pas le moteur) · `POST /properties/compute-rollup` : 400 `collection_id required`, 401 sans bearer · `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis `/users/me`** (le tout premier utilisateur d'un worker est admin, état non contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste · `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*` · `POST /webhooks/verify-signature` : **valid=True** avec `sign_payload(secret, payload)` (même helper que le serveur), False avec une signature bidon - `test_smoke_uncovered.py` : 27 tests au total - Suite complète : **1064/1064** ## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22) ### Tests - `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) : · `board-config` GET/POST : défauts à 5 colonnes sans board, création puis relecture du roundtrip · `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié, suppression vérifiée · `card` POST : 404 sans board, `{"status": "ok"}` avec · `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel) · `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0) · checklist mutations : PATCH item (checked/content relus EN BASE), DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup - Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) → stub de transport httpx. Reste global : `dashboard.py` 17/63, `api_v2.py` 50/115 ### Tests - Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests ## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10) ### Tests - `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes (recents/favorites/published/private/workspace) en un test de boucle, `/private` avec une page seedée et retrouvée, `/children/{id}` avec un parent/enfant seedés (titre retrouvé), `/repository` vide et clé (aucun appel réseau — la clé n'est qu'une string de workspace) - Test de non-régression 404 sur les 2 routes supprimées ### Removed - **2 routes cassées supprimées** (découverte des smokes) : `/api/library/local-workspace-children/{id}` renvoyait un 500 systématique et `/api/library/local-workspace` un 500 dès qu'un workspace existait — les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**. `library._format_size` devenu mort : supprimé aussi (une version vit dans `dashboard.py`, inchangée) - `local_workspace_items` : plus aucune occurrence dans `app/` ### Tests - Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests ## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test) ### Tests - `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4 routers qui n'avaient AUCUN test : · `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ; HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret → 400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé (zéro accès réseau réel) · `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté, échappement HTML vérifié `<b>`) · `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis `users.sidebar_config`, 400 sans `config`, remise en état en fin de test · `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok - Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23, `dashboard.py` 17/63, `api_v2.py` 50/115 - Suite complète : **1047/1047** ## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé) ### Changed - **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé (déterministe, équivalent sémantique de l'`await` d'avant). Note `ponytail:` : clients httpx créés à chaque appel partout → aucun lien de boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan - **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient `request.json` / `_fire_event` / `fire_published` / `fire_unpublished` → paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def` - **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart), `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries` - Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c) ### Tests - Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks prouvent la détermination de `run_event_sync` ; suite complète **1037/1037** en 228 s ## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop) ### Changed - **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await` était `body = await request.json()` passent à un paramètre FastAPI `body: dict = Body(default={})` (parsing async fait par FastAPI avant l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées avant-engagement : corps absent → `{}` (identique au `try/except` d'avant), JSON invalide → **422** (avant : traité silencieusement comme `{}`), zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté) - `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ; il ne reste que **19 routes async** dans ce router (`fire_event`, `request.form`, appels gitea/webhooks — phase 2b) - Bug de transformation évité en cours de route : première version du script supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) — fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35) ### Tests - Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` + `test_v65` + audit : 62/62 verts avant la passe complète ## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop) ### Changed - **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une ligne de logique** (la conversion est sémantiquement neutre : vérifié corps par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25, board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers - **Reste (phase 2)** — les 311 routes qui ont de vrais `await` (`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans `await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour l'instant (rien ne l'appellerait) ### Perf - Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les handlers SQLite ne saturent plus la boucle pendant les tests ## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1) ### Security - **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-'`. Le middleware CSP génère le nonce dans une `ContextVar` avant `call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags `