- detach_page_references : nettoyage des références NO ACTION avant toute suppression définitive (unitaire, Empty Trash, purge 30 j) - MCP : list_workspaces / get_workspace ; descriptions collections clarifiées (bases de données, pas des workspaces) - 96 tests verts sur le périmètre (MCP, trash, cohérence)
150 lines
5.7 KiB
Python
150 lines
5.7 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_permanent_delete_page_referenced_by_favorites_and_recents(client):
|
|
"""Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page
|
|
réelle — ouverte (recents) et/ou favorite — ne pouvait plus être
|
|
supprimée définitivement : favorites.page_id et recents.page_id
|
|
référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON →
|
|
IntegrityError → 500 « Delete failed », et Empty Trash (une seule
|
|
transaction) échouait en bloc. Les pages synthétiques des anciens
|
|
tests n'avaient ni favori ni récent : elles passaient."""
|
|
from app.db import get_conn
|
|
|
|
login_test_client(client)
|
|
pid = _mk_page(title="Page réelle de Bruno")
|
|
with get_conn() as conn:
|
|
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,))
|
|
conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,))
|
|
conn.commit()
|
|
r = client.post(f"/api/pages/{pid}/trash")
|
|
assert r.status_code == 200
|
|
r = client.delete(f"/board/api/trash/{pid}")
|
|
assert r.status_code == 200, r.text
|
|
assert _row(pid) is None
|
|
with get_conn() as conn:
|
|
assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0
|
|
assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0
|
|
|
|
|
|
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"
|