"""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 = "&co" 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 "<b>&co</b>" 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" # v7.45.5 : parent_section n'est PLUS réécrit en 'Trash' (deleted_at est # la source de vérité) — l'ancienne assertion codait le bug : la page # restaurée restait 'Trash' et disparaissait de Library/Recents/Private. 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": "a32@exemple.dev"}, ) 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"] == "a32@exemple.dev" # 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 _own_workspace(client) -> int: """Workspace appartenant à l'utilisateur de la SESSION (fallback actif).""" from app.db import get_conn uid = client.get("/api/users/me").json()["id"] with get_conn() as conn: cur = conn.execute( "INSERT INTO workspaces (name, owner_id) VALUES ('Ws propre A32', ?)", (uid,) ) ws_id = cur.lastrowid conn.commit() return ws_id def test_dashboard_members_invite_role_remove(client): """POST invite → PUT role → DELETE : cycle complet vérifié en base.""" from app.db import get_conn ws_id = _own_workspace(client) me = client.get("/api/users/me").json() try: r = client.post( f"/api/workspace/{ws_id}/members", json={"email": me["login"], "role": "editor"}, ) assert r.status_code == 200 and r.json()["status"] == "ok" assert r.json()["role"] == "editor" # rôle invalide → quirk assumé (retour tuple FastAPI : tableau + 200) inval = client.post( f"/api/workspace/{ws_id}/members", json={"email": me["login"], "role": "hacker"} ) assert inval.status_code == 200 assert inval.json()[0]["error"] == "Invalid role" up = client.put( f"/api/workspace/{ws_id}/members/{me['id']}", json={"role": "admin"} ) assert up.status_code == 200 and up.json() == {"status": "ok"} with get_conn() as conn: row = conn.execute( "SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, me["id"]), ).fetchone() assert row["role"] == "admin" rm = client.delete(f"/api/workspace/{ws_id}/members/{me['id']}") assert rm.json() == {"status": "ok"} with get_conn() as conn: left = conn.execute( "SELECT COUNT(*) AS n FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, me["id"]), ).fetchone()["n"] assert left == 0 finally: with get_conn() as conn: conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,)) conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) conn.commit() def test_dashboard_upload_folder_validation(client): """A22 : validations du dépôt de dossier SANS écrire de fichier.""" ws_id = _own_workspace(client) try: # structure absente → 400 r = client.post( "/api/local-workspace/upload-folder", data={"parent_id": ""} ) assert r.status_code == 400 assert r.json()["error"] == "No structure provided" # structure JSON cassée → 400 bad = client.post( "/api/local-workspace/upload-folder", data={"structure": "{pas du json"} ) assert bad.status_code == 400 assert bad.json()["error"] == "Invalid structure JSON" finally: from app.db import get_conn with get_conn() as conn: conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)) conn.commit() def test_dashboard_convert_to_database(client): """Convertit une page en base : collection + propriété Name + vue Table.""" from app.db import get_conn pid = _seed_page("À convertir A32") collection_id = None try: r = client.post(f"/api/pages/{pid}/convert-to-database", json={}) assert r.status_code == 200, r.text d = r.json() assert d["status"] == "converted" and d["view_url"] == f"/pages/{pid}" collection_id = d["collection_id"] with get_conn() as conn: page = conn.execute( "SELECT content_format, collection_id FROM pages WHERE id=?", (pid,) ).fetchone() assert page["content_format"] == "collection" assert page["collection_id"] == collection_id coll = conn.execute( "SELECT name FROM collections WHERE id=?", (collection_id,) ).fetchone() assert coll["name"] == d["name"] prop = conn.execute( "SELECT prop_type FROM collection_properties WHERE collection_id=? AND prop_type='title'", (collection_id,), ).fetchone() assert prop is not None view = conn.execute( "SELECT view_type FROM collection_views WHERE collection_id=?", (collection_id,), ).fetchone() assert view["view_type"] == "table" # page inconnue → 404 assert client.post("/api/pages/999999/convert-to-database", json={}).status_code == 404 finally: with get_conn() as conn: # ordre FK : la page référence la collection (pages.collection_id) conn.execute("DELETE FROM pages WHERE id=?", (pid,)) if collection_id is not None: conn.execute("DELETE FROM collection_views WHERE collection_id=?", (collection_id,)) conn.execute("DELETE FROM collection_properties WHERE collection_id=?", (collection_id,)) conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) conn.commit() # ── api.py : les 6 routes Gitea (stub de transport — zéro réseau) ──────────── _CANNED_ISSUE = { "number": 42, "title": "Issue A32", "body": "description a32", "state": "open", "labels": [], "assignee": None, "due_date": "", "created_at": "2026-10-01T00:00:00Z", "user": {"login": "giteux"}, } def _stub_gitea(monkeypatch): from app.services import gitea_client # état mutable partagé : le PATCH est re-fetché par le handler via # get_issue, donc update doit modifier ce que get renverra. state = dict(_CANNED_ISSUE) async def _create(owner, repo, title, body, **_k): state.update({"title": title, "body": body}) return dict(state) async def _update(owner, repo, issue_id, **kwargs): state.update(kwargs) return dict(state) async def _labels(owner, repo, issue_id, label_ids): return [{"id": i} for i in label_ids] async def _get(owner, repo, issue_id): return dict(state) async def _comments(owner, repo, issue_id): return [] monkeypatch.setattr(gitea_client.gitea, "create_issue", _create) monkeypatch.setattr(gitea_client.gitea, "update_issue", _update) monkeypatch.setattr(gitea_client.gitea, "update_issue_labels", _labels) monkeypatch.setattr(gitea_client.gitea, "get_issue", _get) monkeypatch.setattr(gitea_client.gitea, "get_issue_comments", _comments) def test_api_gitea_issue_create_update_with_board(client, monkeypatch): """POST create → carte sur le board ; PATCH update → colonne recalculée.""" from app.db import get_conn _stub_gitea(monkeypatch) _seed_board(client) # board (OWNER, REPO) via l'endpoint with get_conn() as conn: board_id = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", (OWNER, REPO), ).fetchone()["id"] conn.execute("DELETE FROM cards WHERE board_id=?", (board_id,)) conn.commit() try: created = client.post( f"/api/issues/{OWNER}/{REPO}", params={"title": "Issue A32"} ) assert created.status_code == 200, created.text assert created.json()["status"] == "ok" assert created.json()["issue"]["number"] == 42 with get_conn() as conn: card = conn.execute( "SELECT column_name FROM cards WHERE board_id=? AND gitea_issue_id=42", (board_id,), ).fetchone() assert card is not None # carte créée localement à partir de l'issue patched = client.patch( f"/api/issues/{OWNER}/{REPO}/42", params={"title": "Renommé A32"} ) assert patched.status_code == 200 assert patched.json()["issue"]["title"] == "Renommé A32" with get_conn() as conn: card2 = conn.execute( "SELECT column_name FROM cards WHERE board_id=? AND gitea_issue_id=42", (board_id,), ).fetchone() assert card2 is not None # colonne recalculée (UPDATE) sans perdre la carte finally: with get_conn() as conn: conn.execute("DELETE FROM cards WHERE board_id=?", (board_id,)) conn.commit() def test_api_gitea_issue_detail_json_and_html(client, monkeypatch): """GET détail (2 decorators, 1 handler) : JSON + rendu HTML.""" _stub_gitea(monkeypatch) r = client.get(f"/api/issues/{OWNER}/{REPO}/42") assert r.status_code == 200 d = r.json() assert d["issue"]["title"] == "Issue A32" assert d["comments"] == [] and d["checklists"] == [] and d["card"] is None # le handler lit le paramètre `format` (le segment /html ne le fixe pas) html = client.get(f"/api/issues/{OWNER}/{REPO}/42/html", params={"format": "html"}) assert html.status_code == 200 assert "text/html" in html.headers["content-type"] assert "Issue A32" in html.text # card_detail.html rendu avec le canevas # inconnu (le stub lève) → 404 propre from app.services import gitea_client async def _boom(*_a, **_k): raise RuntimeError("pas trouvé") monkeypatch.setattr(gitea_client.gitea, "get_issue", _boom) assert client.get(f"/api/issues/{OWNER}/{REPO}/999").status_code == 404 def test_api_gitea_checklists_create_with_board(client): """POST checklist + POST item : création vérifiée en base (DB seul).""" 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"] cl_id = item_id = None try: r = client.post( f"/api/checklists/{OWNER}/{REPO}/42", params={"title": "À faire A32"} ) assert r.status_code == 200 and r.json()["status"] == "ok" cl_id = r.json()["checklist_id"] with get_conn() as conn: row = conn.execute( "SELECT board_id, title FROM checklists WHERE id=?", (cl_id,) ).fetchone() assert row["board_id"] == board_id and row["title"] == "À faire A32" r2 = client.post( f"/api/checklist-items/{OWNER}/{REPO}/42/{cl_id}", params={"content": "étape 1"}, ) assert r2.status_code == 200 and r2.json()["status"] == "ok" item_id = r2.json()["item_id"] with get_conn() as conn: it = conn.execute( "SELECT content FROM checklist_items WHERE id=?", (item_id,) ).fetchone() assert it["content"] == "étape 1" # board absent → 404 (validation côté route) assert client.post("/api/checklists/absent/repo/1").status_code == 404 finally: with get_conn() as conn: if item_id is not None: conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,)) if cl_id is not None: conn.execute("DELETE FROM checklists WHERE id=?", (cl_id,)) 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": "taguser-a32@test.dev", "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): # v7.46.0 : le select exige la session ET un workspace existant # (avant : 200 + cookie sur un id inexistant, et 200 en anonyme). ws_id = client.post("/api/workspaces", json={"name": "Smoke WS"}).json()["id"] r = client.post(f"/api/workspaces/{ws_id}/select") assert r.status_code == 200 assert r.json() == {"status": "ok", "workspace_id": ws_id} assert "flowdeck_workspace" in r.headers.get("set-cookie", "") # un workspace inexistant est refusé (404), pas de cookie fantôme assert client.post("/api/workspaces/999999/select").status_code == 404 # 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)