Les 6 dernières routes d'A32 (api.py, gitea) avec stub de transport — zéro
réseau réel :
- _stub_gitea() : stubs manuels sur gitea_client.gitea (create_issue,
update_issue, update_issue_labels, get_issue, get_issue_comments) avec
ÉTAT MUTABLE PARTAGÉ — le handler PATCH re-fetch l'issue via get_issue,
un canevas figé aurait masqué la mise à jour.
- POST /issues : carte INSÉRÉE sur le board (board seedé par endpoint) ;
PATCH : colonne recalculée sans perdre la carte.
- GET /issues JSON + HTML : ?format=html requis (le segment /html ne fixe pas
le paramètre, le handler le lit dans la query) ; stub qui lève → 404.
- POST /checklists + POST /checklist-items : lignes vérifiées en base,
404 sans board ; cleanup (items → checklists).
BUG PROD corrigé (trouvé par le smoke HTML) : card_detail.html utilisait la
macro fd_icon SANS l'importer → UndefinedError → 500 systématique sur
GET /api/issues/...?format=html (seul rendu du template dans le code).
Fix : {% from '_icons.html' import fd_icon %}.
A32 COMPLET : plus aucun router « 0 test » (webhooks, notes, sidebar_config,
github_routes, library, api, dashboard, api_v2 tous couverts).
test_smoke_uncovered.py : 52 tests. suite **1089/1089** · ruff OK · docs à jour
1132 lines
45 KiB
Python
1132 lines
45 KiB
Python
"""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 = "<b>&co</b>"
|
|
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"
|
|
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 _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": "[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)
|