test: A32 phase 2f — dashboard +7 routes, garde-fous A16 (v7.17.0)
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
This commit is contained in:
@@ -1,5 +1,26 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## 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)
|
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.16.0 (audit — A32 phase 2e : dashboard +9 routes, comptes A2/A3) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.17.0 (audit — A32 phase 2f : dashboard +7, garde-fous A16) | **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`
|
> **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
|
## Avancement Global
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.16.0",
|
version="7.17.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "FlowDeck",
|
"title": "FlowDeck",
|
||||||
"version": "7.16.0"
|
"version": "7.17.0"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/auth/register": {
|
"/auth/register": {
|
||||||
|
|||||||
@@ -596,6 +596,115 @@ def test_dashboard_settings_account_update(client):
|
|||||||
conn.commit()
|
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):
|
def test_dashboard_workspace_select_and_breadcrumb(client):
|
||||||
# select : cookie positionné + shape
|
# select : cookie positionné + shape
|
||||||
r = client.post("/api/workspaces/1/select")
|
r = client.post("/api/workspaces/1/select")
|
||||||
|
|||||||
Reference in New Issue
Block a user