Compare commits

...
9 Commits
Author SHA1 Message Date
bruno 0cb476e336 test: A32 phase 2g — dashboard +13 routes, cycles items/tags (v7.18.0)
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 14m55s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 43 → 56 des 63 routes. 4 nouveaux tests (fichier à 46) :

- GET /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 (DELETE page)
- Cycle items local-workspace (5 routes) : POST création (titre relu),
  PUT rename (relu en base), PUT move, DELETE soft-delete (deleted_at relu),
  POST restore (deleted_at NULL relu) — nettoyage finally
- Cycle tags d'item (5 routes) : POST (urgenta32 lowercasé), tags de l'item,
  liste workspace, search (shape), suppression vérifiée. Utilisateur DÉDIÉ +
  workspace créé dans le test (le endpoint /api/local-workspace/tags exige un
  workspace actif : fallback « premier workspace du user » — on n'attache pas
  ce workspace à l'utilisateur fixture partagé), tout est nettoyé.

Reste A32 : dashboard 7 routes (members invite/role/unsubscribe,
upload-folder, convert-to-database) + 6 routes Gitea d'api.py (stub httpx).

suite **1083/1083** · `ruff check app tests` OK · docs à jour
2026-10-01 15:11:45 -04:00
bruno 2339fa2586 test: A32 phase 2f — dashboard +7 routes, garde-fous A16 (v7.17.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 5m54s
Cumul dashboard : 36 → 43 des 63 routes. 4 nouveaux tests (fichier à 42),
centrés sur les garde-fous A16 :

- GET /api/files/{ws}/{path} : traversal encodé %2e%2e%2f → 403
  « Path traversal denied » (décodé par Starlette puis bloqué par resolve) ;
  inexistant → 404 ; vrai fichier écrit dans le data_dir de test →
  200 + octets exacts, nettoyé en finally
- GET /api/pages/{id}/download : page markdown → 404 « No downloadable file »
  (pas de 500) ; page « file » avec chemin ../ sortant de la racine →
  jamais 200 ; file-content → 404/415 sans fuite
- 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), 404 sans avatar
- GET/POST /api/collections/{id}/table-data|pages : 404 inconnu, shape,
  ligne créée retrouvée dans table-data, nettoyage finally

Reste A32 : dashboard 20 routes (upload/local-workspace items/members/
projects/HTML gitea) + 6 routes Gitea d'api.py (stub transport httpx).

suite **1079/1079** · `ruff check app tests` OK · docs à jour
2026-10-01 14:40:06 -04:00
bruno 8b48dbdd4b test: A32 phase 2e — dashboard +9 routes, comptes A2/A3 (v7.16.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 27 → 36 des 63 routes. 6 nouveaux tests (fichier à 38) :

- /accounts + /accounts/settings : 200 HTML et « password_hash » ABSENT du
  rendu (whitelist A2 vérifiée côté page)
- PUT /api/user/profile : persistance relue en base, restauration finally
- PUT /api/user/password : 403 « current password is incorrect » (A3 — la
  session seule ne change pas le mdp) + quirk assumé documenté : la longueur
  est validée AVANT l'auth et répond 200 + message
- POST /api/user/token : format fd_ + 64 hex ; ligne user_tokens nettoyée
- DELETE /api/user/forge/{provider} : {"status": "ok"}
- PUT /api/settings/account : full_name/email persistés + 400 sur mdp court,
  restauration finally
- POST /api/workspaces/1/select : Set-Cookie flowdeck_workspace vérifié ;
  GET /api/local-workspace/breadcrumb : shape liste

Reste A32 : dashboard 27 routes (fichiers/avatars/local-workspace/collections)
+ 6 routes Gitea d'api.py (stub transport httpx).

suite **1075/1075** · `ruff check app tests` OK · docs à jour
2026-10-01 13:59:00 -04:00
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :

- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
  Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
  (bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
  401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
  depuis /users/me (le tout premier utilisateur d'un worker est admin :
  état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
  helper que le serveur), False avec signature bidon.

test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).

suite **1064/1064** · `ruff check app tests` OK · docs à jour
2026-10-01 13:09:32 -04:00
bruno b2e38aece7 test: A32 phase 2b — api.py 3 → 16/22 routes couvertes (v7.13.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
  relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès 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 (COUNT=0) — seed + cleanup en finally

Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.

suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
2026-10-01 12:35:22 -04:00
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
  couvertes) : les 5 listes en boucle (recents/favorites/published/private/
  workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
  avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
  non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
  renvoyait un 500 systématique (test vert → ASGI double response.start) et
  `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
  lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
  (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
  `library._format_size` devenu mort (la copie de dashboard.py est inchangée).
  `local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
  finally (pas de pollution des autres tests).

suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke 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, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
bruno 3a1276596c fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `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 : le worker est bloqué, JAMAIS la boucle
  d'event, et la réponse n'est produite qu'une fois l'événement terminé
  (déterministe, équivalent sémantique de l'await). ponytail: les clients
  httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
  run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
  `_fire_event`, `fire_published`, `fire_unpublished` →
  `Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
  : wrapping par appariement de parenthèses chaîne-aware, assert de flip
  « plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
  réseau et restent async volontairement : import_csv_v2 (multipart),
  project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (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**
(228 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:35:43 -04:00
10 changed files with 1211 additions and 317 deletions
+194
View File
@@ -1,5 +1,199 @@
# Changelog - FlowDeck
## 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é `&lt;b&gt;`)
· `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
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.9.0
7.18.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.9.0 (audit — A21 phase 2a : api_v2 à 96/115 hors loop) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.18.0 (audit — A32 phase 2g : dashboard +13, cycle items/tags) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.9.0",
version="7.18.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+31 -74
View File
@@ -31,6 +31,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
validate_scopes_input,
)
from app.services.automations import fire_event as _fire_event
from app.services.automations import run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
logger = logging.getLogger(__name__)
@@ -661,16 +662,12 @@ def list_collection_pages_v2(collection_id: int, request: Request, authorization
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
@router.post("/collections/{collection_id}/pages")
async def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
idem = check_idempotency(request, user["id"])
if idem:
return JSONResponse(content=idem["data"], status_code=idem["status"])
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
icon = body.get("icon", "file")
parent_id = body.get("parent_id")
@@ -703,7 +700,7 @@ async def create_collection_page_v2(collection_id: int, request: Request, author
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request)
try:
await _fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception:
logger.exception("create_collection_page_v2")
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
@@ -741,13 +738,9 @@ def get_page_v2(page_id: int, request: Request, authorization: str | None = Head
return d
@router.patch("/pages/{page_id}")
async def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not row:
@@ -782,13 +775,13 @@ async def patch_page_v2(page_id: int, request: Request, authorization: str | Non
conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception:
logger.exception("patch_page_v2")
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}")
async def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
@@ -799,13 +792,13 @@ async def delete_page_v2(page_id: int, request: Request, authorization: str | No
conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request)
try:
await _fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception:
logger.exception("delete_page_v2")
return {"id": page_id, "status": "deleted"}
@router.post("/pages/{page_id}/restore")
async def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
@@ -815,7 +808,7 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
await _fire_event("page.restored", {"page_id": page_id})
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
except Exception:
logger.exception("restore_page_v2")
return {"id": page_id, "status": "restored"}
@@ -1029,13 +1022,9 @@ def list_views_v2(collection_id: int, request: Request, authorization: str | Non
return {"views": [row_to_dict(r) for r in rows]}
@router.post("/collections/{collection_id}/views")
async def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "New View").strip()
vtype = body.get("view_type") or body.get("type") or "table"
config = body.get("config") or body.get("config_json") or {}
@@ -1048,7 +1037,7 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st
conn.commit()
audit_log(user, "view.create", "view", vid, name, request)
try:
await _fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype})
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
except Exception:
logger.exception("create_view_v2")
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
@@ -1167,13 +1156,9 @@ def list_comments_v2(page_id: int, request: Request, authorization: str | None =
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/pages/{page_id}/comments")
async def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
text = (body.get("body") or body.get("content") or "").strip()
if not text:
raise HTTPException(400, "body is required")
@@ -1184,19 +1169,15 @@ async def create_comment_v2(page_id: int, request: Request, authorization: str |
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
audit_log(user, "comment.create", "comment", nid, text[:80], request)
try:
await _fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]})
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("create_comment_v2")
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
@router.patch("/comments/{comment_id}")
async def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
@@ -1210,7 +1191,7 @@ async def patch_comment_v2(comment_id: int, request: Request, authorization: str
conn.commit()
if int(bool(resolved)) and not was_resolved:
try:
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
except Exception:
logger.exception("patch_comment_v2")
return {"id": comment_id, "status": "updated"}
@@ -1230,13 +1211,9 @@ def delete_comment_v2(comment_id: int, request: Request, authorization: str | No
return {"id": comment_id, "status": "deleted"}
@router.post("/pages/{page_id}/mentions")
async def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
targets = body.get("user_ids") or body.get("mentions") or []
if isinstance(targets, int):
targets = [targets]
@@ -1253,7 +1230,7 @@ async def create_mention_v2(page_id: int, request: Request, authorization: str |
conn.commit()
if created:
try:
await _fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created})
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
except Exception:
logger.exception("create_mention_v2")
return {"mentions": created, "status": "created"}
@@ -1327,13 +1304,9 @@ def list_favorites_v2(request: Request, authorization: str | None = Header(defau
return {"favorites": [row_to_dict(r) for r in rows]}
@router.post("/favorites")
async def add_favorite_v2(request: Request, authorization: str | None = Header(default=None)):
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
pid = body.get("page_id")
if not pid:
raise HTTPException(400, "page_id required")
@@ -1344,20 +1317,20 @@ async def add_favorite_v2(request: Request, authorization: str | None = Header(d
except Exception as e:
raise HTTPException(409, str(e)) from None
try:
await _fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]})
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
except Exception:
logger.exception("add_favorite_v2")
return {"page_id": pid, "status": "added"}
@router.delete("/favorites/{page_id}")
async def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
conn.commit()
try:
await _fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]})
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
except Exception:
logger.exception("remove_favorite_v2")
return {"page_id": page_id, "status": "removed"}
@@ -1462,13 +1435,9 @@ def list_shares_v2(page_id: int, request: Request, authorization: str | None = H
return {"shares": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/shares")
async def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
perm = (body.get("permission") or "view").strip().lower()
if perm not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
@@ -1483,7 +1452,7 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N
nid = cur.lastrowid
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
try:
await _fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm})
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
except Exception:
logger.exception("create_share_v2")
return {"id": nid, "page_id": page_id, "status": "shared"}
@@ -1519,25 +1488,21 @@ def delete_share_v2(share_id: int, request: Request, authorization: str | None =
return {"id": share_id, "status": "revoked"}
@router.post("/pages/{page_id}/publish")
async def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
slug, _title = publish(page_id, explicit_slug=slug_in)
audit_log(user, "page.publish", "page", page_id, slug, request)
await fire_published(page_id, slug)
run_event_sync(fire_published(page_id, slug))
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
@router.delete("/pages/{page_id}/publish")
async def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
unpublish(page_id)
await fire_unpublished(page_id)
run_event_sync(fire_unpublished(page_id))
return {"page_id": page_id, "status": "unpublished"}
# ── History ───────────────────────────────────────────────────────────────
@@ -1591,13 +1556,9 @@ def list_sprints_v2(collection_id: int, request: Request, authorization: str | N
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "Sprint").strip()
start = body.get("start_date") or body.get("start") or ""
end = body.get("end_date") or body.get("end") or ""
@@ -1608,19 +1569,15 @@ async def create_sprint_v2(collection_id: int, request: Request, authorization:
sid = cur.lastrowid
conn.commit()
try:
await _fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name})
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
except Exception:
logger.exception("create_sprint_v2")
return {"id": sid, "name": name, "status": "created"}
@router.patch("/sprints/{sprint_id}")
async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
user = require_scope("write")(request, authorization)
_v2_rate_check(request, user)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
if not row:
@@ -1633,7 +1590,7 @@ async def patch_sprint_v2(sprint_id: int, request: Request, authorization: str |
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
conn.commit()
try:
await _fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status})
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
except Exception:
logger.exception("patch_sprint_v2")
return {"id": sprint_id, "status": "updated"}
-153
View File
@@ -341,74 +341,6 @@ def library_private(
return {"items": items}
@router.get("/local-workspace-children/{item_id:int}")
def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
[item_id],
).fetchone()
if not item:
return {"items": []}
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE parent_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[item_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": "",
"workspace": "",
"workspace_name": "",
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
@router.get("/children/{page_id:int}")
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
@@ -449,91 +381,6 @@ def library_repository(
return {"items": items}
@router.get("/local-workspace")
def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
"""Return local workspace items (files/folders) formatted for Library display."""
from app.routers.dashboard import _get_active_workspace
uid = _get_user_id(request)
# Get the active workspace
ws = _get_active_workspace(request, user_id=uid)
if not ws:
return {"items": []}
ws_id = workspace_id or ws["id"]
# Query local workspace tree
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[ws_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": ws.get("name", "Workspace"),
"workspace": ws.get("name", ""),
"workspace_name": ws.get("name", ""),
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
@router.get("/workspace")
def library_workspace(
request: Request,
+13
View File
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
return {"status": "error", "detail": str(exc)}
def run_event_sync(coro, timeout: float = 60.0):
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
déterministe, exactement ce que faisait l'await avant la conversion des
routes en `def`.
ponytail: les clients httpx des services sont créés à chaque appel (aucun
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
"""
return asyncio.run(asyncio.wait_for(coro, timeout))
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
+122 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.9.0"
"version": "7.18.0"
},
"paths": {
"/auth/register": {
@@ -10765,47 +10765,6 @@
}
}
},
"/api/library/local-workspace-children/{item_id}": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace Children",
"description": "Return children of a local workspace item for tree expansion.",
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Item Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/children/{page_id}": {
"get": {
"tags": [
@@ -10899,48 +10858,6 @@
}
}
},
"/api/library/local-workspace": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace",
"description": "Return local workspace items (files/folders) formatted for Library display.",
"operationId": "library_local_workspace_api_library_local_workspace_get",
"parameters": [
{
"name": "workspace_id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"title": "Workspace Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/workspace": {
"get": {
"tags": [
@@ -18288,6 +18205,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18398,6 +18326,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19417,6 +19356,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19957,6 +19907,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20013,6 +19974,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20123,6 +20095,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20470,6 +20453,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21039,6 +21033,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21216,6 +21221,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21503,6 +21519,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21559,6 +21586,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
+845
View File
@@ -0,0 +1,845 @@
"""A32 — smoke tests : un par route des 4 routers à 0 test.
`webhooks.py` (3), `notes.py` (2), `sidebar_config.py` (2), `github_routes.py` (2).
Aucun accès réseau réel : le secret webhook est piloté par monkeypatch et
`gitea.list_webhooks` est stubbé.
"""
from app.config import settings
OWNER, REPO = "smoke-a32", "repo-x"
# ── webhooks.py (0/3) ────────────────────────────────────────────────────────
def test_webhook_receive_ok_without_secret(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post("/api/webhook", json={"zen": "smoke"})
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_webhook_receive_rejects_bad_signature(client, monkeypatch):
monkeypatch.setattr(settings, "gitea_webhook_secret", "s3cret")
r = client.post("/api/webhook", json={}) # pas d'en-tête X-Gitea-Signature
assert r.status_code == 401
assert r.json()["detail"] == "Invalid signature"
def test_webhook_register_fails_fast_without_secret(client, monkeypatch):
# secret vide → 400 AVANT tout appel réseau (gitea.list_webhooks)
monkeypatch.setattr(settings, "gitea_webhook_secret", "")
r = client.post(f"/api/webhook/register/{OWNER}/{REPO}")
assert r.status_code == 400
assert "not configured" in r.json()["detail"]
def test_webhook_status_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _no_hooks(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "list_webhooks", _no_hooks)
r = client.get(f"/api/webhook/status/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"registered": False}
# ── notes.py (0/2) ───────────────────────────────────────────────────────────
def test_notes_get_renders_html(client):
r = client.get(f"/notes/{OWNER}/{REPO}")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_notes_save_roundtrip(client):
payload = "note de smoke A32"
html_payload = "<b>&co</b>"
r = client.post(
f"/notes/{OWNER}/{REPO}", data={"content": f"{payload} {html_payload}"}
)
assert r.status_code == 200
assert payload in r.text # contenu rendu
assert "&lt;b&gt;&amp;co&lt;/b&gt;" in r.text # échappé (A10 — pas d'HTML cru)
again = client.get(f"/notes/{OWNER}/{REPO}")
assert payload in again.text # persistée en base (upsert)
# ── sidebar_config.py (0/2) ──────────────────────────────────────────────────
def test_sidebar_config_get(client):
r = client.get("/api/sidebar/config")
assert r.status_code == 200
cfg = r.json()["config"]
assert isinstance(cfg, dict) and "meetings" in cfg
def test_sidebar_config_put_persists_and_validates(client):
r = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": False, "order": 2, "show_count": 5}}},
)
assert r.status_code == 200
back = client.get("/api/sidebar/config").json()["config"]
assert back["meetings"]["visible"] is False # relu depuis users.sidebar_config
bad = client.put("/api/sidebar/config", json={})
assert bad.status_code == 400 # config dict requis
# remise en état pour les autres tests
restore = client.put(
"/api/sidebar/config",
json={"config": {"meetings": {"visible": True, "order": 2, "show_count": 5}}},
)
assert restore.status_code == 200
# ── github_routes.py (0/2) ───────────────────────────────────────────────────
def test_github_status_unlinked(client):
r = client.get("/api/github/status")
assert r.status_code == 200
assert r.json() == {"linked": False}
def test_github_disconnect_ok(client):
r = client.delete("/api/github/disconnect")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
# ── library.py (1/10 → 10/10) ────────────────────────────────────────────────
def _seed_page(title: str, **cols) -> int:
"""Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé."""
from app.db import get_conn
base = {"workspace": "", "title": title, "content": "", "content_format": "markdown"}
base.update(cols) # surcharge (content=, parent_id=, parent_section=…)
columns = list(base)
placeholders = ", ".join("?" for _ in columns)
values = list(base.values())
with get_conn() as conn:
cur = conn.execute(
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
)
pid = cur.lastrowid
conn.commit()
return pid
def test_library_all_lists_return_items(client):
"""Les 5 listes de la bibliothèque répondent {"items": [...]}."""
for path in ("/recents", "/favorites", "/published", "/private", "/workspace"):
r = client.get(f"/api/library{path}")
assert r.status_code == 200, (path, r.status_code)
assert isinstance(r.json()["items"], list), path
def test_library_private_lists_seeded_page(client):
from app.db import get_conn
pid = _seed_page("A32 private page", parent_section="Private")
try:
items = client.get("/api/library/private").json()["items"]
assert "A32 private page" in [i["title"] for i in items]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_library_children_lists_child(client):
from app.db import get_conn
parent = _seed_page("A32 parent")
child = _seed_page("A32 child", parent_id=parent)
try:
items = client.get(f"/api/library/children/{parent}").json()["items"]
assert [i["title"] for i in items] == ["A32 child"]
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (parent, child))
conn.commit()
def test_library_local_workspace_routes_removed(client):
"""A32 → A30 : ces 2 routes lisaient `local_workspace_items`, une table
inexistante nulle part dans le codebase (500 systématique côté children,
500 d?s qu'un workspace existe côté local-workspace) et AUCUNE référence
front → supprimées avec `_format_size` devenu mort."""
assert client.get("/api/library/local-workspace-children/1").status_code == 404
assert client.get("/api/library/local-workspace").status_code == 404
def test_library_repository_empty_and_keyed(client):
empty = client.get("/api/library/repository")
assert empty.status_code == 200 and empty.json() == {"items": []}
keyed = client.get("/api/library/repository?gitea_owner=acme&gitea_repo=flowdeck")
assert keyed.status_code == 200
assert isinstance(keyed.json()["items"], list)
def test_library_lists_still_available_after_removal(client):
"""Les listes restantes (celles qu'utilise le front) répondent toujours."""
r = client.get("/api/library/recents")
assert r.status_code == 200 and isinstance(r.json()["items"], list)
# ── api.py (3/23 → 11/23) ────────────────────────────────────────────────────
def _seed_board(client) -> None:
"""Board via l'endpoint lui-même (pas de SQL brut)."""
r = client.post(
f"/api/board-config/{OWNER}/{REPO}",
params={"columns": '["Todo", "Doing"]', "wip_limits": "{}"},
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
def test_api_board_config_default_then_roundtrip(client):
# pas de board → défauts
r = client.get(f"/api/board-config/{OWNER}/other-repo")
assert r.status_code == 200
assert r.json()["columns"] == ["Backlog", "À faire", "En cours", "Révision", "Terminé"]
# création puis relecture
_seed_board(client)
back = client.get(f"/api/board-config/{OWNER}/{REPO}")
assert back.json()["columns"] == ["Todo", "Doing"]
def test_api_col_mapping_requires_board_and_works(client):
# board inexistant → 404
r = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": "absent", "column": "Doing", "gitea_label": "x"},
)
assert r.status_code == 404
# avec board → ok (upsert) puis suppression
_seed_board(client)
r2 = client.post(
"/api/col-mapping",
params={"owner": OWNER, "repo": REPO, "column": "Doing", "gitea_label": "in-progress"},
)
assert r2.status_code == 200
assert r2.json() == {"status": "ok", "column": "Doing", "label": "in-progress"}
r3 = client.delete(
"/api/col-mapping", params={"owner": OWNER, "repo": REPO, "column": "Doing"}
)
assert r3.status_code == 200 and r3.json() == {"status": "ok", "column": "Doing"}
def test_api_card_metadata(client):
r = client.post(f"/api/card/{OWNER}/absent/1", params={"priority": "high"})
assert r.status_code == 404 # board requis
_seed_board(client)
r2 = client.post(
f"/api/card/{OWNER}/{REPO}/1", params={"priority": "high", "due_date": "2026-10-01"}
)
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
def test_api_collaborators_stubbed(client, monkeypatch):
from app.services import gitea_client
async def _none(*_a, **_k):
return []
monkeypatch.setattr(gitea_client.gitea, "get_collaborators", _none)
r = client.get(f"/api/collaborators/{OWNER}/{REPO}")
assert r.status_code == 200
assert r.json() == {"collaborators": []}
def test_api_checklist_item_toggle_and_deletes(client):
"""PATCH item / DELETE item / DELETE checklist : mutation vérifiée en base."""
from app.db import get_conn
_seed_board(client)
with get_conn() as conn:
board_id = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(OWNER, REPO),
).fetchone()["id"]
cur = conn.execute(
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, 1, 'A32')",
(board_id,),
)
cl_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, 'fait ?')",
(cl_id,),
)
item_id = cur.lastrowid
conn.commit()
try:
r = client.patch(
f"/api/checklist-items/{item_id}", params={"checked": True, "content": "fait !"}
)
assert r.status_code == 200 and r.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT checked, content FROM checklist_items WHERE id=?", (item_id,)
).fetchone()
assert row["checked"] == 1 and row["content"] == "fait !"
assert client.delete(f"/api/checklist-items/{item_id}").json() == {"status": "ok"}
assert client.delete(f"/api/checklists/{cl_id}").json() == {"status": "ok"}
with get_conn() as conn:
left = conn.execute(
"SELECT COUNT(*) AS n FROM checklists WHERE id=?", (cl_id,)
).fetchone()["n"]
assert left == 0
finally:
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (cl_id,))
conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,))
conn.commit()
def _v2_headers(client, login: str) -> dict:
"""Compte local + token v1 en session (recette de test_public_api_v2)."""
r = client.post(
"/auth/register",
json={"email": f"{login}@test.dev", "password": "secret123", "name": login},
)
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
return {"Authorization": f"Bearer {tok}"}
def test_api_frontend_error_capture_and_read(client):
# purge d'un éventuel résidu d'un autre test
client.get("/api/frontend-errors")
ok = client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
assert ok.status_code == 200 and ok.json() == {"status": "ok"}
invalid = client.post(
"/api/frontend-error", content="pas du json",
headers={"Content-Type": "application/json"},
)
assert invalid.status_code == 200 and invalid.json()["status"] == "ignored"
# doublon consécutif dédupliqué (count=2), lecture puis purge
client.post("/api/frontend-error", json={"message": "boom a32", "type": "error"})
out = client.get("/api/frontend-errors").json()
assert out["count"] == 1 and out["errors"][0]["count"] == 2 and out["cleared"] is True
assert client.get("/api/frontend-errors").json()["count"] == 0
# ── api_v2.py (+5 routes à 0 ref) ────────────────────────────────────────────
def test_v2_evaluate_formula(client):
headers = _v2_headers(client, "smoke-formula")
r = client.post(
"/api/v2/properties/evaluate-formula",
json={"expression": "1 + 2"}, headers=headers,
)
assert r.status_code == 200
d = r.json()
# le moteur décide du résultat (1 + 2 y est renvoyé tel quel aujourd'hui) :
# on valide le câble route (auth, parse, shape), pas le moteur lui-même.
assert d["expression"] == "1 + 2" and "result" in d
missing = client.post("/api/v2/properties/evaluate-formula", json={}, headers=headers)
assert missing.status_code == 400 and "expression" in missing.json()["detail"]
def test_v2_compute_rollup_validates_inputs(client):
headers = _v2_headers(client, "smoke-rollup")
r = client.post("/api/v2/properties/compute-rollup", json={}, headers=headers)
assert r.status_code == 400
assert "collection_id required" in r.json()["detail"]
sans_auth = client.post("/api/v2/properties/compute-rollup", json={})
assert sans_auth.status_code == 401
def test_v2_admin_audit_logs_gated_then_readable(client):
headers = _v2_headers(client, "smoke-audit")
# le portail dépend de l'utilisateur porteur (le TOUT premier utilisateur
# du worker est admin) → on calcule l'attendu depuis /users/me plutôt que
# de durcir un état qu'on ne contrôle pas.
me = client.get("/api/v2/users/me", headers=headers).json()
r = client.get("/api/v2/admin/audit-logs", headers=headers)
if me.get("is_admin"):
assert r.status_code == 200
else:
assert r.status_code == 403
assert "Admin scope" in r.json()["detail"]
# token avec scope admin → 200 + liste paginée
admin_tok = client.post(
"/api/v2/tokens", json={"name": "adm", "scopes": "read,admin"}, headers=headers
).json()["token"]
r2 = client.get(
"/api/v2/admin/audit-logs", headers={"Authorization": f"Bearer {admin_tok}"}
)
assert r2.status_code == 200
assert isinstance(r2.json()["logs"], list)
def test_v2_webhooks_events_catalogue(client):
headers = _v2_headers(client, "smoke-events")
r = client.get("/api/v2/webhooks/events", headers=headers)
assert r.status_code == 200
d = r.json()
assert isinstance(d["events"], list) and len(d["events"]) > 0
assert "*" in d["wildcards"] and "page.*" in d["wildcards"]
def test_v2_webhook_verify_signature(client):
from app.services.webhook_outbound import sign_payload
headers = _v2_headers(client, "smoke-sig")
secret, payload = "topsecret", '{"event": "page.updated"}'
good = sign_payload(secret, payload.encode())
ok = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": good},
headers=headers,
)
assert ok.status_code == 200 and ok.json()["valid"] is True
bad = client.post(
"/api/v2/webhooks/verify-signature",
json={"secret": secret, "payload": payload, "signature": "deadbeef"},
headers=headers,
)
assert bad.json()["valid"] is False
# ── dashboard.py (17/63 → 27/63) ─────────────────────────────────────────────
def test_dashboard_tags_crud(client):
"""POST/GET/PUT/DELETE /api/settings/tags — cycle complet vérifié."""
r = client.post("/api/settings/tags", json={"name": "SmokeTag", "color": "#111111"})
assert r.status_code == 200
tag = r.json()["tag"]
assert tag["name"] == "smoketag" and tag["color"] == "#111111" # lowercasé
allr = client.get("/api/settings/tags/all").json()["tags"]
assert any(t["id"] == tag["id"] for t in allr)
assert client.put(
f"/api/settings/tags/{tag['id']}", json={"color": "#222222"}
).json() == {"status": "ok"}
allr2 = client.get("/api/settings/tags/all").json()["tags"]
mine = next(t for t in allr2 if t["id"] == tag["id"])
assert mine["color"] == "#222222"
assert client.delete(f"/api/settings/tags/{tag['id']}").json() == {"status": "ok"}
assert all(t["id"] != tag["id"] for t in client.get("/api/settings/tags/all").json()["tags"])
def test_dashboard_page_content_rename_trash(client):
"""GET content → PUT rename → POST trash : la vie d'une page vérifiée en base."""
from app.db import get_conn
pid = _seed_page("Page A32", content="contenu a32")
try:
r = client.get(f"/api/pages/{pid}/content")
assert r.status_code == 200
d = r.json()
assert d["title"] == "Page A32" and d["content"] == "contenu a32"
rr = client.put(f"/api/pages/{pid}/rename", json={"title": "Renommée A32"})
assert rr.status_code == 200 and rr.json() == {"status": "ok", "title": "Renommée A32"}
empty = client.put(f"/api/pages/{pid}/rename", json={"title": " "})
assert empty.status_code == 400 # titre vide refusé
rt = client.post(f"/api/pages/{pid}/trash")
assert rt.status_code == 200 and rt.json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute(
"SELECT title, parent_section, deleted_at FROM pages WHERE id=?", (pid,)
).fetchone()
assert row["title"] == "Renommée A32"
assert row["parent_section"] == "Trash" and row["deleted_at"] is not None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (pid,))
conn.commit()
def test_dashboard_sidebar_workspace_tree(client):
r = client.get("/api/sidebar/workspace-tree")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
# pas de cookie workspace → fragment « No pages yet »
assert "No pages yet" in r.text
def test_dashboard_avatar_color_persisted(client):
"""POST avatar-color → couleur relue SUR L'UTILISATEUR DE LA SESSION."""
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
row = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
prev_color, prev_url = row["avatar_color"], row["avatar_url"]
try:
r = client.post("/api/settings/avatar-color", json={"color": "#112233"})
assert r.status_code == 200
assert r.json() == {"status": "ok", "color": "#112233"}
with get_conn() as conn:
row2 = conn.execute(
"SELECT avatar_color, avatar_url FROM users WHERE id=?", (uid,)
).fetchone()
assert row2["avatar_color"] == "#112233" and row2["avatar_url"] == ""
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET avatar_color=?, avatar_url=? WHERE id=?",
(prev_color, prev_url, uid),
)
conn.commit()
def test_dashboard_workspace_members_list(client):
r = client.get("/api/workspace/1/members")
assert r.status_code == 200
assert isinstance(r.json()["members"], list)
def test_dashboard_account_pages_html(client):
"""Pages HTML /accounts et /accounts/settings (whitelist A2 vérifiée)."""
for path in ("/accounts", "/accounts/settings"):
r = client.get(path)
assert r.status_code == 200, path
assert "text/html" in r.headers["content-type"], path
# la page ne doit plus contenir de hash (colonnes whitelistées en A29)
assert "password_hash" not in client.get("/accounts").text
def test_dashboard_user_profile_update_persisted(client):
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
prev = conn.execute("SELECT full_name FROM users WHERE id=?", (uid,)).fetchone()[
"full_name"
]
try:
r = client.put("/api/user/profile", json={"full_name": "Smoke A32"})
assert r.status_code == 200 and r.json() == {"status": "ok"}
with get_conn() as conn:
now = conn.execute(
"SELECT full_name FROM users WHERE id=?", (uid,)
).fetchone()["full_name"]
assert now == "Smoke A32"
finally:
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (prev, uid))
conn.commit()
def test_dashboard_password_change_gated_by_current(client):
"""A3 : sans mdp actuel correct, la session ne permet PAS de changer le mdp."""
court = client.put("/api/user/password", json={"password": "abc"})
# quirk assumé : la longueur est validée AVANT auth et répond 200 + erreur
assert "at least 6 characters" in court.json().get("error", "")
faux = client.put(
"/api/user/password",
json={"password": "nouveaumdp1", "current_password": "pas-le-bon"},
)
assert faux.status_code == 403
assert "incorrect" in faux.json()["detail"].lower()
def test_dashboard_user_token_and_forge_disconnect(client):
import re as _re
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
try:
r = client.post("/api/user/token")
assert r.status_code == 200
assert _re.fullmatch(r"fd_[0-9a-f]{64}", r.json()["token"])
r2 = client.delete("/api/user/forge/gitea")
assert r2.status_code == 200 and r2.json() == {"status": "ok"}
finally:
with get_conn() as conn:
conn.execute("DELETE FROM user_tokens WHERE gitea_user_id=?", (uid,))
conn.commit()
def test_dashboard_settings_account_update(client):
from app.db import get_conn
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
prev = conn.execute(
"SELECT full_name, email FROM users WHERE id=?", (uid,)
).fetchone()
try:
r = client.put(
"/api/settings/account",
json={"full_name": "Compte A32", "email": "[email protected]"},
)
assert r.status_code == 200
with get_conn() as conn:
row = conn.execute(
"SELECT full_name, email FROM users WHERE id=?", (uid,)
).fetchone()
assert row["full_name"] == "Compte A32" and row["email"] == "[email protected]"
# mdp trop court → 400 (validateur, pas de changement)
court = client.put("/api/settings/account", json={"password": "abc"})
assert court.status_code == 400
finally:
with get_conn() as conn:
conn.execute(
"UPDATE users SET full_name=?, email=? WHERE id=?",
(prev["full_name"], prev["email"], uid),
)
conn.commit()
def test_dashboard_gitea_workspace_page(client):
r = client.get("/gitea-workspace")
assert r.status_code in (200, 302), r.status_code
if r.status_code == 200:
assert "text/html" in r.headers["content-type"]
def test_dashboard_workspace_projects_crud(client):
from app.db import get_conn
listing = client.get("/api/workspace/projects")
assert listing.status_code == 200
d = listing.json()
assert isinstance(d["builtin"], list) and isinstance(d["gitea"], list)
assert d["github"] == []
created = client.post("/api/workspace/projects", json={"name": "Projet A32"})
assert created.status_code == 200
body = created.json()
assert body["name"] == "Projet A32" and body["forge"] == "builtin"
try:
# la page créée est un projet racine → elle apparaît dans builtin
names = [p["name"] for p in client.get("/api/workspace/projects").json()["builtin"]]
assert "Projet A32" in names
vide = client.post("/api/workspace/projects", json={"name": " "})
assert "error" in vide.json() # quirk : 200 + message
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (body["id"],))
conn.commit()
def test_dashboard_local_workspace_item_lifecycle(client):
"""POST → rename → move → soft-delete → restore (5 routes, vérifié en base)."""
from app.db import get_conn
created = client.post("/api/local-workspace/items", json={"name": "Fichier A32"})
assert created.status_code == 200
item = created.json()
iid = item["id"]
try:
assert item["name"] == "Fichier A32" and item["type"] == "page"
with get_conn() as conn:
row = conn.execute("SELECT title FROM pages WHERE id=?", (iid,)).fetchone()
assert row["title"] == "Fichier A32"
assert client.put(
f"/api/local-workspace/items/{iid}", json={"name": "Renommé A32"}
).json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute("SELECT title FROM pages WHERE id=?", (iid,)).fetchone()
assert row["title"] == "Renommé A32"
assert client.put(
f"/api/local-workspace/items/{iid}/move", json={"parent_id": None}
).json() == {"status": "ok"}
assert client.delete(f"/api/local-workspace/items/{iid}").json() == {"status": "ok"}
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (iid,)).fetchone()
assert row["deleted_at"] is not None
assert client.post(f"/api/local-workspace/items/{iid}/restore").json() == {
"status": "ok"
}
with get_conn() as conn:
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (iid,)).fetchone()
assert row["deleted_at"] is None
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id=?", (iid,))
conn.commit()
def test_dashboard_item_tags_lifecycle(client):
"""POST tag item → lecture → liste workspace → search → suppression.
L'utilisateur et son workspace sont créés DANS le test : /tags a besoin
d'un workspace actif (fallback « premier workspace du user ») et on ne
veut pas que ce workspace appartienne à l'utilisateur fixture partagé.
"""
from app.db import get_conn
reg = client.post(
"/auth/register",
json={"email": "[email protected]", "password": "secret123", "name": "taguser"},
)
assert reg.status_code == 200, reg.text
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Ws A32', ?)", (uid,)
)
ws_id = cur.lastrowid
conn.commit()
iid = _seed_page("Item taggé A32")
tag_id = None
try:
created = client.post(
f"/api/local-workspace/items/{iid}/tags", json={"name": "UrgentA32"}
)
assert created.status_code == 200
tag_id = created.json()["tag"]["id"]
assert created.json()["tag"]["name"] == "urgenta32" # lowercasé
item_tags = client.get(f"/api/local-workspace/items/{iid}/tags").json()["tags"]
assert any(t["id"] == tag_id for t in item_tags)
all_tags = client.get("/api/local-workspace/tags")
assert all_tags.status_code == 200
assert any(t["id"] == tag_id for t in all_tags.json()["tags"])
search = client.get("/api/local-workspace/tags/search", params={"tags": "urgentA32"})
assert search.status_code == 200 and isinstance(search.json()["items"], list)
assert client.delete(
f"/api/local-workspace/items/{iid}/tags/{tag_id}"
).json() == {"status": "ok"}
after = client.get(f"/api/local-workspace/items/{iid}/tags").json()["tags"]
assert all(t["id"] != tag_id for t in after)
finally:
with get_conn() as conn:
if tag_id is not None:
conn.execute("DELETE FROM tags WHERE id=?", (tag_id,))
conn.execute("DELETE FROM pages WHERE id=?", (iid,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
def test_dashboard_files_traversal_denied_and_serve(client):
"""A16 : /api/files refuse le traversal et sert les vrais fichiers."""
import pathlib as _pathlib
from app.config import settings as _settings
# traversal encodé (%2e%2e%2f) → décodé par Starlette, bloqué par resolve()
r = client.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc/passwd")
assert r.status_code == 403
assert r.json()["error"] == "Path traversal denied"
# inexistant → 404
assert client.get("/api/files/1/rien-du-tout.txt").status_code == 404
# vrai fichier écrit dans le data_dir de test → 200 + contenu
base = _pathlib.Path(_settings.data_dir) / "uploads" / "workspace_1"
base.mkdir(parents=True, exist_ok=True)
fp = base / "smoke-a32.txt"
fp.write_text("bonjour depuis le disque", encoding="utf-8")
try:
ok = client.get("/api/files/1/smoke-a32.txt")
assert ok.status_code == 200
assert ok.content == b"bonjour depuis le disque"
finally:
fp.unlink(missing_ok=True)
def test_dashboard_download_and_file_content_guards(client):
"""A16 : download/file-content sur page fichier avec chemin qui s'échappe."""
from app.db import get_conn
md = _seed_page("Doc A32", content="du markdown")
fpage = _seed_page(
"Fichier A32", content="../outside.txt", content_format="file"
)
try:
# page markdown → pas de fichier téléchargeable (404 propre, pas de 500)
dl = client.get(f"/api/pages/{md}/download")
assert dl.status_code == 404 and "downloadable" in dl.json()["error"]
# page « file » dont le chemin sort de la racine → ni 200 ni fuite
fc = client.get(f"/api/pages/{fpage}/file-content")
assert fc.status_code in (404, 415), fc.status_code
dl2 = client.get(f"/api/pages/{fpage}/download")
assert dl2.status_code == 404
finally:
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE id IN (?, ?)", (md, fpage))
conn.commit()
def test_dashboard_page_content_and_avatar_redirect(client):
from app.db import get_conn
pid = _seed_page("Preview A32", content="aperçu a32", content_format="markdown")
uid = client.get("/api/users/me").json()["id"]
with get_conn() as conn:
prev = conn.execute(
"SELECT avatar_url FROM users WHERE id=?", (uid,)
).fetchone()["avatar_url"]
try:
r = client.get(f"/api/local-workspace/page-content/{pid}")
assert r.status_code == 200
assert r.json() == {"content": "aperçu a32", "format": "markdown"}
assert client.get("/api/local-workspace/page-content/999999").status_code == 404
with get_conn() as conn:
conn.execute(
"UPDATE users SET avatar_url=? WHERE id=?",
("https://exemple.dev/a.png", uid),
)
conn.commit()
# follow_redirects=False : surtout pas de requête RÉELLE vers l'URL
# externe pointée par l'avatar (règle « 0 réseau » de l'audit)
red = client.get(f"/api/avatar/{uid}", follow_redirects=False)
assert red.status_code == 302
assert red.headers["location"] == "https://exemple.dev/a.png"
assert client.get("/api/avatar/999999").status_code == 404
finally:
with get_conn() as conn:
conn.execute(
"DELETE FROM pages WHERE id=?", (pid,)
)
conn.execute("UPDATE users SET avatar_url=? WHERE id=?", (prev, uid))
conn.commit()
def test_dashboard_collection_table_data_and_row_create(client):
from app.db import get_conn
cid = client.post("/db/api", json={"name": "Coll A32"}).json()["id"]
try:
r = client.get(f"/api/collections/{cid}/table-data")
assert r.status_code == 200
d = r.json()
assert d["collection"]["name"] == "Coll A32"
assert d["pages"] == [] and isinstance(d["properties"], list)
assert client.get("/api/collections/999999/table-data").status_code == 404
created = client.post(
f"/api/collections/{cid}/pages", json={"title": "Ligne A32"}
)
assert created.status_code in (200, 201), created.text
back = client.get(f"/api/collections/{cid}/table-data").json()["pages"]
assert any(p.get("title") == "Ligne A32" for p in back)
finally:
with get_conn() as conn:
conn.execute("DELETE FROM collection_pages WHERE collection_id=?", (cid,))
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
def test_dashboard_workspace_select_and_breadcrumb(client):
# select : cookie positionné + shape
r = client.post("/api/workspaces/1/select")
assert r.status_code == 200
assert r.json() == {"status": "ok", "workspace_id": 1}
assert "flowdeck_workspace" in r.headers.get("set-cookie", "")
# breadcrumb d'un dossier inexistant → liste (shape)
b = client.get("/api/local-workspace/breadcrumb", params={"folder": 999999})
assert b.status_code == 200
assert isinstance(b.json()["breadcrumb"], list)