Page /trash alignée sur ce que fait ce type de section :
- sélection multiple (Select all porté au filtre courant) + barre d'actions
groupées Restore / Delete / Clear ;
- Empty Trash via POST /board/api/trash/empty (purge en masse, 1 requête) ;
- tri Recently deleted / Oldest first / Name et filtre emplacement RÉELS —
remplacent les deux boutons décoratifs « Last edited by ▾ » et « In ▾ » qui
ne faisaient rien ;
- date de suppression + jours restants par élément (rétention 30 j alignée sur
app/services/trash.py, horodatages UTC/ISO gérés) ;
- compteur de résultats, états vides distincts (vide vs filtre sans résultat +
Clear filters), toasts sur chaque action.
Bugs trouvés en route et corrigés :
1. Restore/Delete de la page ne marchaient JAMAIS : getCsrf() renvoie la chaîne
du jeton mais le code faisait csrf?.[1] → 2e caractère → 403 CSRF silencieux
avalé par if (r.ok).
2. « Move to Trash » de l'éditeur = 404 permanent : route appelée
/board/api/pages/{id}/trash (inexistante) alors que la route réelle est
/api/pages/{id}/trash, et le .then() naviguait quand même → la page partait
à l'accueil SANS être mise à la poubelle. URL corrigée + r.ok vérifié.
3. Page restaurée invisible en Library/Recents/Private : la suppression éditeur
réécrivait parent_section='Trash' et la restauration ne le remettait pas,
alors que tous les listings filtrent parent_section != 'Trash'. Écriture
retirée (deleted_at = source de vérité unique) + réparation idempotente au
boot dans db.init_db + requête sidebar /trash alignée sur deleted_at.
4. Routes trash sans aucune authentification (le CSRF ne protège pas : cookie
lisible + en-tête forgé) : 401 ajouté sur list/restore/delete/empty et sur
POST /api/pages/{id}/trash — vérifié anonyme → 401.
Gates : tests/test_trash_api.py (5) · e2e/trash_ui.spec.js (1, avec garde « on
ne vide jamais la poubelle d'autrui ») · pytest 1099 passed · ruff OK ·
e2e probe_nav_perf + partial_nav + editor_mount + logout_dnd + smoke = 10 passed ·
OpenAPI 510 chemins / 7.45.5.
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Trash — v7.45.5.
|
|
|
|
Trois régressions couvertes :
|
|
1. les routes trash n'avaient AUCUNE vérification de session (le CSRF seul ne
|
|
protège pas : cookie lisible + en-tête forgé) ;
|
|
2. la suppression depuis l'éditeur réécrivait ``parent_section='Trash'`` et la
|
|
restauration ne le remettait pas → page invisible en Library/Recents/Private ;
|
|
3. ``POST /board/api/trash/empty`` (purge totale) n'existait pas.
|
|
"""
|
|
from tests.conftest import anon_csrf, login_test_client
|
|
|
|
|
|
def _mk_page(title="To trash", workspace="bruno/flowdeck", section="Private", parent_id=None):
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format, parent_section, parent_id) "
|
|
"VALUES (?, ?, '', 'blocks', ?, ?)",
|
|
(workspace, title, section, parent_id),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
def _row(page_id):
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
return conn.execute(
|
|
"SELECT parent_section, deleted_at FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
|
|
|
|
def test_trash_requires_session(client):
|
|
pid = _mk_page()
|
|
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
|
|
|
|
assert client.get("/board/api/trash").status_code == 401
|
|
assert client.post(f"/api/pages/{pid}/trash").status_code == 401
|
|
assert client.post(f"/board/api/trash/{pid}/restore").status_code == 401
|
|
assert client.delete(f"/board/api/trash/{pid}").status_code == 401
|
|
assert client.post("/board/api/trash/empty").status_code == 401
|
|
# rien n'a été touché
|
|
row = _row(pid)
|
|
assert row["deleted_at"] is None
|
|
|
|
|
|
def test_soft_delete_then_restore_keeps_section(client):
|
|
login_test_client(client)
|
|
pid = _mk_page(title="Restore me")
|
|
|
|
r = client.post(f"/api/pages/{pid}/trash")
|
|
assert r.status_code == 200
|
|
row = _row(pid)
|
|
assert row["deleted_at"], "la page doit être marquée supprimée"
|
|
assert row["parent_section"] != "Trash", "parent_section ne doit plus être réécrit"
|
|
|
|
items = client.get("/board/api/trash").json()
|
|
hit = next((i for i in items if i["id"] == pid), None)
|
|
assert hit, "la page doit apparaître dans la poubelle"
|
|
assert hit["deleted_at"] and hit["path"] and hit["icon"]
|
|
|
|
r = client.post(f"/board/api/trash/{pid}/restore")
|
|
assert r.status_code == 200
|
|
row = _row(pid)
|
|
assert row["deleted_at"] is None
|
|
assert row["parent_section"] != "Trash"
|
|
assert not any(i["id"] == pid for i in client.get("/board/api/trash").json())
|
|
|
|
|
|
def test_permanent_delete(client):
|
|
login_test_client(client)
|
|
pid = _mk_page(title="Gone for good")
|
|
client.post(f"/api/pages/{pid}/trash")
|
|
|
|
assert client.delete(f"/board/api/trash/{pid}").status_code == 200
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (pid,)).fetchone()[0] == 0
|
|
|
|
|
|
def test_empty_trash_purges_only_trashed(client):
|
|
login_test_client(client)
|
|
ids = [_mk_page(title=f"purge-{i}") for i in range(3)]
|
|
keep = _mk_page(title="keep-me")
|
|
for pid in ids:
|
|
client.post(f"/api/pages/{pid}/trash")
|
|
|
|
r = client.post("/board/api/trash/empty")
|
|
assert r.status_code == 200
|
|
assert r.json()["deleted"] == 3
|
|
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
placeholders = ",".join("?" * len(ids))
|
|
assert conn.execute(
|
|
f"SELECT COUNT(*) FROM pages WHERE id IN ({placeholders})", ids
|
|
).fetchone()[0] == 0
|
|
assert conn.execute("SELECT COUNT(*) FROM pages WHERE id=?", (keep,)).fetchone()[0] == 1
|
|
|
|
|
|
def test_boot_repairs_restored_rows_marked_trash(client):
|
|
"""Le correctif de db.init_db() remet en 'Private' les pages restaurées que
|
|
l'ancien soft-delete laissait marquées 'Trash' (donc invisibles partout)."""
|
|
login_test_client(client)
|
|
from app.db import get_conn, init_db
|
|
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO pages (workspace, title, content, content_format, parent_section) "
|
|
"VALUES ('', 'ghost', '', 'blocks', 'Trash')"
|
|
)
|
|
conn.commit()
|
|
pid = cur.lastrowid
|
|
|
|
init_db()
|
|
|
|
with get_conn() as conn:
|
|
row = conn.execute("SELECT parent_section FROM pages WHERE id=?", (pid,)).fetchone()
|
|
assert row["parent_section"] == "Private"
|