Compare commits

...
4 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
7 changed files with 529 additions and 8 deletions
+80
View File
@@ -1,5 +1,85 @@
# 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
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.14.0
7.18.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.14.0 (audit — A32 phase 2c : api_v2 +5 routes à 0 ref) | **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.14.0",
version="7.18.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.14.0"
"version": "7.18.0"
},
"paths": {
"/auth/register": {
+443 -2
View File
@@ -116,9 +116,11 @@ def _seed_page(title: str, **cols) -> int:
"""Insert minimale (title/content/format + colonnes surnuméraires), id renvoyé."""
from app.db import get_conn
columns = ["workspace", "title", "content", "content_format", *cols]
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 = ["", title, "", "markdown", *cols.values()]
values = list(base.values())
with get_conn() as conn:
cur = conn.execute(
f"INSERT INTO pages ({', '.join(columns)}) VALUES ({placeholders})", values
@@ -402,3 +404,442 @@ def test_v2_webhook_verify_signature(client):
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)