diff --git a/app/main.py b/app/main.py index bf53576..4c8238c 100644 --- a/app/main.py +++ b/app/main.py @@ -75,15 +75,27 @@ logger = logging.getLogger(__name__) async def lifespan(_app: FastAPI): init_db() init_webhook_tables() + import os + import secrets + from app.db import get_conn from app.password_utils import hash_password - admin_hash = hash_password("FlowDeck2026!") + + # A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au + # premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent. with get_conn() as conn: - conn.execute( - "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)", - (admin_hash,) - ) - conn.commit() + if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone(): + admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12) + conn.execute( + "INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)", + (hash_password(admin_pw),), + ) + conn.commit() + logger.warning( + "Premier démarrage : compte admin créé, mot de passe = %s " + "(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)", + admin_pw, + ) # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── from app.routers.agent import agent_scheduler diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 184dd5f..98af6e7 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -18,7 +18,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} # NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not # apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token. - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify", "/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding", "/s/", "/f/"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/board.py b/app/routers/board.py index b9c1e15..addc860 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -1385,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""), parent_id: int = Query(default=0)): """Create a new Markdown page, optionally as a sub-page.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + # A7 : la création de page exige une session (route sortue de la liste CSRF). + raise HTTPException(401, "Authentication required") ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno") page_title = title.strip() if title else "" try: @@ -1417,11 +1420,11 @@ async def create_page(request: Request, title: str = Query(default=""), async def get_page(request: Request, page_id: int): """Get a Markdown page.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - # No session → legacy single-user behaviour (matches collections `_require_view`). - if user and user.get("id"): - # v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. - if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): - raise HTTPException(404, "Page not found") + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + # v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. + if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): + raise HTTPException(404, "Page not found") with get_conn() as conn: row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() if not row: diff --git a/app/routers/collections.py b/app/routers/collections.py index 9d3eb2f..4d25c72 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -43,23 +43,22 @@ def _session_user(request: Request) -> dict | None: def _require_view(collection_id: int, user: dict | None) -> None: - """Return None when a user may view the collection, else raise 404. + """Raise 404 when the user may not view the collection (404 hides it). - A missing/userless session keeps the legacy single-user behaviour (owner on - un-workspaced collections); explicit ``restricted`` / ``private`` collections - are hidden for non-owners unless granted. + A6 : plus de session = accès refusé — l'absence de user ne vaut plus + « legacy single-user » ( lecture anonyme de n'importe quelle collection ). """ if not user: - return + raise HTTPException(status_code=404, detail="Collection not found") pm = PermissionManager(user["id"]) if not pm.can_view_collection(collection_id): raise HTTPException(status_code=404, detail="Collection not found") def _require_edit(collection_id: int, user: dict | None) -> None: - """Raise 403 when the user may not edit pages in the collection.""" + """Raise 401/403 when the user may not edit pages in the collection.""" if not user: - return + raise HTTPException(status_code=401, detail="Authentication required") pm = PermissionManager(user["id"]) if not pm.can_edit_collection(collection_id): raise HTTPException(status_code=403, detail="You don't have edit access to this collection") diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index a69b2bf..a9d6f3c 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, Query, Request +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager @@ -689,11 +689,20 @@ def _get_user_id(request: Request) -> int: return user["id"] if user and user.get("id") else 1 +def _require_user_id(request: Request) -> int: + """A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent + plus le fallback « legacy single-user » → id 1 = l'admin seedé).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + return user["id"] + + @router.put("/api/user/profile") async def update_profile(request: Request): body = await request.json() full_name = body.get("full_name", "").strip() - uid = _get_user_id(request) + uid = _require_user_id(request) with get_conn() as conn: conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) conn.commit() @@ -702,13 +711,18 @@ async def update_profile(request: Request): @router.put("/api/user/password") async def update_password(request: Request): - from app.password_utils import hash_password + from app.password_utils import hash_password, verify_password body = await request.json() password = body.get("password", "").strip() if len(password) < 6: return {"error": "Password must be at least 6 characters"} - uid = _get_user_id(request) + uid = _require_user_id(request) + # A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp). + current = body.get("current_password", "") with get_conn() as conn: + row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone() + if not row or not verify_password(current, row["password_hash"]): + raise HTTPException(403, "Current password is incorrect") conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid)) conn.commit() return {"status": "ok"} @@ -717,7 +731,7 @@ async def update_password(request: Request): @router.post("/api/user/token") async def generate_token(request: Request): import secrets - uid = _get_user_id(request) + uid = _require_user_id(request) token = secrets.token_hex(32) with get_conn() as conn: conn.execute( @@ -730,7 +744,7 @@ async def generate_token(request: Request): @router.delete("/api/user/forge/{provider}") async def disconnect_forge(request: Request, provider: str): - uid = _get_user_id(request) + uid = _require_user_id(request) with get_conn() as conn: conn.execute( "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) diff --git a/app/routers/public_api.py b/app/routers/public_api.py index e4a61ea..50a21b0 100644 --- a/app/routers/public_api.py +++ b/app/routers/public_api.py @@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)): @router.post("/token") async def generate_token(request: Request): - """Generate a public API access token. - - When an authenticated session is present the token is bound to that user - (revocable from Settings → API tokens); otherwise a legacy shared token is - created for backward compatibility. - """ + """Generate a public API access token (A4 : session obligatoire — plus de + « legacy shared token » `user_id=0` créable par un anonymous).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") token = f"fd_{token_urlsafe(24)}" with get_conn() as conn: - if user and user.get("id"): - conn.execute( - "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)", - (user["id"], "API token", _hash_token(token), token[:12]), - ) - else: - conn.execute( - "INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", - (0, token), - ) + conn.execute( + "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)", + (user["id"], "API token", _hash_token(token), token[:12]), + ) conn.commit() return {"token": token, "note": "Use as: Authorization: Bearer "} diff --git a/app/routers/workspace.py b/app/routers/workspace.py index ee9f198..ba8b951 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -25,6 +25,24 @@ def _current_user(request: Request) -> dict: return SessionManager.decode_session(s) or {"login": "admin", "id": 1} +def _require_ws_admin(request: Request, ws_id: int) -> None: + """A5 — CRUD des membres : session obligatoire + rôle admin de l'espace + (ou admin global). Un anonymous ne peut plus s'ajouter lui-même ni se + promouvoir admin.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + with get_conn() as conn: + if conn.execute("SELECT 1 FROM users WHERE id=? AND is_admin=1", (user["id"],)).fetchone(): + return + row = conn.execute( + "SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", + (ws_id, user["id"]), + ).fetchone() + if not row or row["role"] != "admin": + raise HTTPException(403, "Workspace admin role required") + + # ── Workspaces ── @router.get("") @@ -58,6 +76,8 @@ async def create_workspace(request: Request): @router.get("/{ws_id}/members") async def list_members(request: Request, ws_id: int): + if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): + raise HTTPException(401, "Authentication required") with get_conn() as conn: rows = conn.execute( "SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?", @@ -68,13 +88,14 @@ async def list_members(request: Request, ws_id: int): @router.post("/{ws_id}/members") async def add_member(request: Request, ws_id: int): + _require_ws_admin(request, ws_id) body = await request.json() if request.headers.get("content-type") else {} user_id = body.get("user_id") role = body.get("role", "editor") if role not in ROLES: raise HTTPException(400, f"Invalid role: {role}") with get_conn() as conn: - conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)", + conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,0)", (user_id, f"user_{user_id}", f"User {user_id}")) conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", (ws_id, user_id, role)) @@ -84,6 +105,7 @@ async def add_member(request: Request, ws_id: int): @router.put("/{ws_id}/members/{user_id}") async def update_member_role(request: Request, ws_id: int, user_id: int): + _require_ws_admin(request, ws_id) body = await request.json() if request.headers.get("content-type") else {} role = body.get("role", "editor") if role not in ROLES: @@ -97,6 +119,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int): @router.delete("/{ws_id}/members/{user_id}") async def remove_member(request: Request, ws_id: int, user_id: int): + _require_ws_admin(request, ws_id) with get_conn() as conn: conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id)) conn.commit() diff --git a/app/templates/local_workspace.html b/app/templates/local_workspace.html index 71d2d71..3c8a0a8 100644 --- a/app/templates/local_workspace.html +++ b/app/templates/local_workspace.html @@ -1124,7 +1124,8 @@ window._wsInitData = (function() { try { var r = await fetch('/board/api/pages/' + node.id + '/icon', { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: {'Content-Type': 'application/json', + 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''}, body: JSON.stringify({icon: icon}) }); if (!r.ok) throw new Error('icon update failed'); diff --git a/tests/conftest.py b/tests/conftest.py index 62abac0..e8bdabf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,73 @@ suite is safe to run in parallel (``pytest -n auto``): workers never share a database file, and no state leaks between tests. """ import os +import re import tempfile from pathlib import Path +import httpx import pytest from fastapi.testclient import TestClient +class _TestSessionAuth(httpx.Auth): + """Session + CSRF injectés à la volée (jamais dans le cookie jar du client). + + - `flowdeck_session` ajouté seulement s'il est absent de la requête (un test + peut fournir la sienne via `cookies=`) ; + - `csrf_token` idem, et l'en-tête `X-CSRF-Token` suit TOUJOURS le cookie + courant (le token tourne quand `/api/csrf-token` est appelé) ; + - un test qui veut l'anonymat fait `anon(client)` → `client.auth = None`. + """ + + CSRF_FALLBACK = "csrf-test-token" + + def __init__(self, session_token: str): + self.session_token = session_token + + def auth_flow(self, request): + ch = request.headers.get("cookie", "") + add = [] + if "flowdeck_session=" not in ch: + add.append(f"flowdeck_session={self.session_token}") + if "csrf_token=" not in ch: + add.append(f"csrf_token={self.CSRF_FALLBACK}") + if add: + request.headers["cookie"] = "; ".join(([ch] if ch else []) + add) + if "X-CSRF-Token" not in request.headers: + m = re.search(r"csrf_token=([^;]+)", request.headers.get("cookie", "")) + if m: + request.headers["X-CSRF-Token"] = m.group(1) + yield request + + +def login_test_client(tc, user_id: int = 1, login: str = "tester", is_admin: int = 1): + """Connecte un TestClient (A3–A7 : les routes testées exigent une session).""" + from app.auth.session import SessionManager + from app.db import get_conn + + with get_conn() as conn: + conn.execute( + "INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,?)", + (user_id, login, login.title(), is_admin), + ) + conn.commit() + tc.auth = _TestSessionAuth( + SessionManager.create_session( + {"id": user_id, "login": login, "full_name": login.title(), "is_admin": is_admin} + ) + ) + return tc + + +def anon(client): + """Test d'anonymat : plus de session, plus de CSRF par défaut.""" + client.cookies.clear() + client.headers.pop("X-CSRF-Token", None) + client.auth = None + return client + + @pytest.fixture def client(): """FastAPI TestClient with a fresh temporary SQLite database.""" @@ -55,7 +115,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) # Cleanup try: diff --git a/tests/test_agent.py b/tests/test_agent.py index 9aa218a..1733dd0 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -10,6 +10,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -45,7 +46,7 @@ def client(): ) conn.commit() - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) diff --git a/tests/test_ai_writing.py b/tests/test_ai_writing.py index b69d8c0..64c5e48 100644 --- a/tests/test_ai_writing.py +++ b/tests/test_ai_writing.py @@ -10,6 +10,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -41,7 +42,7 @@ def client(): ) conn.commit() - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) diff --git a/tests/test_app.py b/tests/test_app.py index 9ef8638..1c63821 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,6 +4,7 @@ import os import tempfile import pytest +from conftest import anon, login_test_client from fastapi.testclient import TestClient @@ -40,7 +41,7 @@ def client(): ) conn.commit() - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) @@ -88,6 +89,7 @@ def test_board_404(client): def test_csrf_rejected(client): + anon(client) resp = client.post("/api/move?owner=x&repo=y&issue_id=1&column=Test") assert resp.status_code == 403 @@ -134,6 +136,7 @@ def test_card_detail_html(client): def test_create_issue_api(client): + anon(client) resp = client.post("/api/issues/test/test?title=Test%20Issue&body=Test%20body") # 403 CSRF or 500 if Gitea down assert resp.status_code in (403, 500) @@ -201,11 +204,13 @@ def test_get_ai_keywords(client): def test_csrf_protects_properties_post(client): + anon(client) resp = client.post("/board/api/properties/test/test?name=Priority&prop_type=select") assert resp.status_code == 403 # CSRF def test_csrf_protects_sync(client): + anon(client) resp = client.post("/board/api/sync/test/test") assert resp.status_code == 403 # CSRF @@ -1354,6 +1359,7 @@ def _create_regular_session(): def test_admin_list_users_unauthorized(client): + anon(client) """GET /api/admin/users — 403 without admin session.""" resp = client.get("/api/admin/users") assert resp.status_code == 403 @@ -1588,6 +1594,7 @@ def test_admin_stats(client): def test_admin_stats_unauthorized(client): + anon(client) """GET /api/admin/stats — 403 for non-admin.""" resp = client.get("/api/admin/stats") assert resp.status_code == 403 @@ -1663,6 +1670,7 @@ def test_gitea_status_with_expired_token(client): def test_gitea_disconnect_no_auth(client): + anon(client) """DELETE /api/gitea/disconnect — 401 without session.""" resp = client.delete("/api/gitea/disconnect") assert resp.status_code == 401 @@ -1883,6 +1891,7 @@ def test_auth_user_authenticated(client): def test_auth_user_unauthenticated(client): + anon(client) """GET /auth/user — returns authenticated=false without session.""" resp = client.get("/auth/user") assert resp.status_code == 200 @@ -1943,6 +1952,7 @@ def test_gitea_private_pages_list_no_auth(client): def test_gitea_private_pages_create_no_auth(client): + anon(client) """POST private-pages — 401 without session.""" resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"}) assert resp.status_code == 401 @@ -2019,6 +2029,7 @@ def test_page_renders_breadcrumb_data(client): def test_landing_page_no_auth(client): + anon(client) """Visiting / without auth shows the landing page.""" resp = client.get("/", follow_redirects=False) assert resp.status_code == 200 @@ -2095,6 +2106,7 @@ def test_register_duplicate_rejected(client): def test_session_expired_redirect(client): + anon(client) """Unauthenticated access to protected page redirects with expired param.""" resp = client.get("/workspaces", follow_redirects=False) assert resp.status_code == 302 @@ -3464,6 +3476,7 @@ def test_v490_page_mentions_endpoint(client): def test_v490_notifications_require_auth(client): + anon(client) r = client.get("/api/notifications") assert r.status_code == 401 diff --git a/tests/test_automations.py b/tests/test_automations.py index de09600..3f33c81 100644 --- a/tests/test_automations.py +++ b/tests/test_automations.py @@ -8,6 +8,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -31,7 +32,7 @@ def client(): conn.commit() tc = TestClient(app) - yield tc + yield login_test_client(tc) os.unlink(db_path) diff --git a/tests/test_block_interactions.py b/tests/test_block_interactions.py index 62303b6..a9cd201 100644 --- a/tests/test_block_interactions.py +++ b/tests/test_block_interactions.py @@ -10,6 +10,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -34,7 +35,7 @@ def client(): conn.commit() tc = TestClient(app, raise_server_exceptions=False) - yield tc + yield login_test_client(tc) os.unlink(db_path) diff --git a/tests/test_db_advanced.py b/tests/test_db_advanced.py index 1a44844..3d8a47f 100644 --- a/tests/test_db_advanced.py +++ b/tests/test_db_advanced.py @@ -4,6 +4,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -28,7 +29,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) os.unlink(db_path) diff --git a/tests/test_realtime.py b/tests/test_realtime.py index 11c1981..71ce087 100644 --- a/tests/test_realtime.py +++ b/tests/test_realtime.py @@ -10,6 +10,7 @@ import os import tempfile import pytest +from conftest import anon, login_test_client from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect @@ -38,7 +39,7 @@ def client(): manager._rooms = {} tc = TestClient(app, raise_server_exceptions=False) - yield tc + yield login_test_client(tc) os.unlink(db_path) @@ -113,6 +114,7 @@ def test_merge_ops_sequential(): # ── Auth & présence de page ── def test_ws_requires_auth(client): + anon(client) _make_page() with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect("/ws/pages/1") as ws: @@ -121,6 +123,7 @@ def test_ws_requires_auth(client): def test_ws_auth_rejected(client): + anon(client) _make_page() with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect("/ws/pages/1") as ws: diff --git a/tests/test_realtime_v64.py b/tests/test_realtime_v64.py index 11401c1..c306960 100644 --- a/tests/test_realtime_v64.py +++ b/tests/test_realtime_v64.py @@ -16,6 +16,7 @@ import os import tempfile import pytest +from conftest import anon, login_test_client from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect @@ -49,7 +50,7 @@ def client(): manager.stat_connections_total = 0 tc = TestClient(app, raise_server_exceptions=False) - yield tc + yield login_test_client(tc) os.unlink(db_path) @@ -193,6 +194,7 @@ def test_apply_op_still_lww_without_base(): # ── protocole WS ───────────────────────────────────────────────────────── def test_ws_requires_auth(client): + anon(client) _make_page() with pytest.raises(WebSocketDisconnect) as exc: with client.websocket_connect("/ws/pages/1") as ws: @@ -392,6 +394,7 @@ def test_ws_stats_endpoint(client): def test_ws_stats_requires_auth(client): + anon(client) r = client.get("/api/realtime/stats") assert r.status_code == 200 assert r.json() == {"error": "unauthorized"} diff --git a/tests/test_search_migrations.py b/tests/test_search_migrations.py index 5807292..30c87c5 100644 --- a/tests/test_search_migrations.py +++ b/tests/test_search_migrations.py @@ -7,6 +7,7 @@ import os import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -31,7 +32,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) os.unlink(db_path) diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 2dae327..7c38b10 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -7,6 +7,8 @@ import json import re from pathlib import Path +from conftest import anon + ROOT = Path(__file__).resolve().parent.parent @@ -47,6 +49,7 @@ def test_sw_served_via_static_mount(client): def test_sw_registration_present_on_landing(client): + anon(client) """Anonymous entry point (/) registers the SW (assets are public).""" resp = client.get("/") assert resp.status_code in (200, 302) @@ -67,6 +70,7 @@ def test_sw_registration_with_background_sync_in_base(client): def test_base_has_pwa_meta_tags(client): + anon(client) resp = client.get("/") body = resp.text assert 'rel="manifest"' in body diff --git a/tests/test_sharing.py b/tests/test_sharing.py index a4f9c2c..b192109 100644 --- a/tests/test_sharing.py +++ b/tests/test_sharing.py @@ -8,6 +8,7 @@ import os import tempfile import pytest +from conftest import anon, login_test_client from fastapi.testclient import TestClient @@ -41,7 +42,7 @@ def client(): conn.commit() tc = TestClient(app, raise_server_exceptions=False) - yield tc + yield login_test_client(tc) os.unlink(db_path) @@ -109,6 +110,7 @@ def test_share_invalid_permission_rejected(client): def test_share_requires_auth(client): + anon(client) pid = _make_page(client) r = client.post(f"/api/pages/{pid}/share", json={"email": "x@test.dev", "permission": "view"}) assert r.status_code == 401 diff --git a/tests/test_sync.py b/tests/test_sync.py index 7bc8bf4..31d60a9 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,6 +1,8 @@ """FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints.""" import time +from conftest import anon + # ── helpers ──────────────────────────────────────────────────────────────── @@ -58,6 +60,7 @@ def _page_updated_epoch(pid) -> float: def test_sync_requires_auth(client): + anon(client) assert client.get("/api/v2/sync/status").status_code == 401 assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401 assert client.get("/api/v2/sync/delta").status_code == 401 diff --git a/tests/test_v511_v512_wiki_templates.py b/tests/test_v511_v512_wiki_templates.py index 1a1ebfc..da87fed 100644 --- a/tests/test_v511_v512_wiki_templates.py +++ b/tests/test_v511_v512_wiki_templates.py @@ -13,6 +13,7 @@ import secrets import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -36,7 +37,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) @@ -58,7 +59,11 @@ def _login(client): uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0}) - csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] + # Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque + # appel, un token rendu par un login précédent deviendrait invalide (403). + csrf = client.cookies.get("csrf_token") + if not csrf: + csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] return session, csrf, uid @@ -76,13 +81,17 @@ def _login_admin(client): uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] conn.commit() session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1}) - csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] + # Réutilise le CSRF du jar : /api/csrf-token fait tourner le token à chaque + # appel, un token rendu par un login précédent deviendrait invalide (403). + csrf = client.cookies.get("csrf_token") + if not csrf: + csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"] return session, csrf, uid def _mk_page(client, session, title, blocks=None): r = client.post("/board/api/pages", params={"title": title, "section": "Private"}, - cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": "x"}) + cookies={"flowdeck_session": session}) assert r.status_code == 200 pid = r.json()["id"] if blocks is not None: diff --git a/tests/test_v52_infra.py b/tests/test_v52_infra.py index c4fecff..4d745ee 100644 --- a/tests/test_v52_infra.py +++ b/tests/test_v52_infra.py @@ -8,6 +8,7 @@ import asyncio import os import pytest +from conftest import anon from fastapi import HTTPException from fastapi.testclient import TestClient @@ -66,6 +67,7 @@ def test_api_token_lifecycle(client): def test_api_tokens_require_authentication(client): + anon(client) r1 = client.get("/api/settings/tokens") assert r1.status_code == 401 r2 = client.post("/api/settings/tokens", json={"name": "x"}) @@ -88,7 +90,11 @@ def test_sessions_listed_and_revocable(client): from app.db import get_conn with get_conn() as conn: - count = conn.execute("SELECT COUNT(*) FROM user_sessions").fetchone()[0] + count = conn.execute( + "SELECT COUNT(*) FROM user_sessions WHERE user_id IN " + "(SELECT id FROM users WHERE login IN (?, ?))", + ("alice@test.dev", "bob@test.dev"), + ).fetchone()[0] assert count == 2 # Revoke alice's session using alice's cookie (the test client now has bob's cookie). @@ -192,6 +198,7 @@ def test_backup_disabled_returns_none(client): def test_backup_admin_api(client): + anon(client) """The backup admin API is admin-only and snapshots on demand.""" # Unauthenticated → forbidden. assert client.post("/api/settings/backups/run").status_code == 403 diff --git a/tests/test_v56_import.py b/tests/test_v56_import.py index b5a23d6..1624a39 100644 --- a/tests/test_v56_import.py +++ b/tests/test_v56_import.py @@ -14,6 +14,8 @@ import secrets import time import zipfile +from conftest import anon + from app.db import get_conn @@ -382,6 +384,7 @@ class TestMappingAndWizard: assert props["Code"] == "007" def test_wizard_page_requires_auth(self, client): + anon(client) r = client.get("/import", follow_redirects=False) assert r.status_code in (302, 307) diff --git a/tests/test_v57_db_advanced.py b/tests/test_v57_db_advanced.py index 123270d..6e1d072 100644 --- a/tests/test_v57_db_advanced.py +++ b/tests/test_v57_db_advanced.py @@ -12,6 +12,7 @@ import secrets import tempfile import pytest +from conftest import login_test_client from fastapi.testclient import TestClient @@ -35,7 +36,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) os.unlink(db_path) @@ -142,8 +143,8 @@ def test_auto_properties_filled_on_create_and_update(client): pv = json.loads(client.get(f"/db/pages/{page_id}/api").json()["property_values_json"]) assert pv[str(ct)] assert pv[str(lt)] - assert pv[str(cb)]["login"] == "admin" - assert pv[str(lb)]["login"] == "admin" + assert pv[str(cb)]["login"] == "tester" + assert pv[str(lb)]["login"] == "tester" created = pv[str(ct)] # Partial update keeps created_time and refreshes last_edited_time. diff --git a/tests/test_v58_calendar_reminders.py b/tests/test_v58_calendar_reminders.py index 1c59439..aedc4a2 100644 --- a/tests/test_v58_calendar_reminders.py +++ b/tests/test_v58_calendar_reminders.py @@ -14,6 +14,7 @@ import secrets import tempfile import pytest +from conftest import anon, login_test_client from fastapi.testclient import TestClient @@ -37,7 +38,7 @@ def client(): from app.main import app init_db() - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) @@ -265,6 +266,7 @@ def test_user_timezone_endpoints(client): def test_notifications_unauth(client): + anon(client) assert client.get("/api/notifications/timezone").status_code == 401 diff --git a/tests/test_v60_granular_permissions.py b/tests/test_v60_granular_permissions.py index 05af4f6..82e3ffa 100644 --- a/tests/test_v60_granular_permissions.py +++ b/tests/test_v60_granular_permissions.py @@ -5,6 +5,8 @@ restricted, private), explicit grants (user + group), inheritance, property visibility, user groups and the audit log. """ +from conftest import anon + from app.auth.session import SessionManager # ═══════════════ helpers ═══════════════ @@ -516,6 +518,7 @@ def test_audit_log_records_changes(client): def test_permissions_endpoints_require_auth(client): + anon(client) assert client.get("/api/v2/pages/1/permissions").status_code == 401 assert client.post("/api/v2/pages/1/permissions", json={"user_id": 2, "role": "viewer"}).status_code == 401 assert client.get("/api/v2/groups").status_code == 401 diff --git a/tests/test_v66_agent_api.py b/tests/test_v66_agent_api.py index af6a97c..f7f11d8 100644 --- a/tests/test_v66_agent_api.py +++ b/tests/test_v66_agent_api.py @@ -11,6 +11,7 @@ import os import tempfile import pytest +from conftest import login_test_client from app.services import skill_gallery from app.services.webhook_outbound import EVENTS @@ -50,7 +51,7 @@ def client(): conn.commit() from fastapi.testclient import TestClient - yield TestClient(app) + yield login_test_client(TestClient(app)) try: os.unlink(db_path) diff --git a/tests/test_v67_sso.py b/tests/test_v67_sso.py index 4c00ac5..af897f5 100644 --- a/tests/test_v67_sso.py +++ b/tests/test_v67_sso.py @@ -17,6 +17,7 @@ import secrets as pysecrets from urllib.parse import parse_qs, urlparse import pytest +from conftest import anon # ── Mock IdP constants ───────────────────────────────────────────────────── IDP_ENTITY = "https://idp.corp.test/saml/metadata" @@ -289,6 +290,7 @@ def test_providers_endpoint_empty_without_config(client): def test_config_requires_admin(client): + anon(client) assert client.get("/api/v2/sso/config").status_code == 401 _admin_session(client) # demote to plain user → 403 @@ -1284,7 +1286,7 @@ def test_history_endpoint_requires_admin_and_lists_attempts(client, idp_keypair) assert failures[0]["error_message"] # anonymous → 401 - client.cookies.delete("flowdeck_session") + anon(client) assert client.get("/api/v2/sso/history").status_code == 401 diff --git a/tests/test_v68_sites_forms.py b/tests/test_v68_sites_forms.py index a374966..7492a79 100644 --- a/tests/test_v68_sites_forms.py +++ b/tests/test_v68_sites_forms.py @@ -9,6 +9,8 @@ from __future__ import annotations import json import secrets +from conftest import anon + from app.db import get_conn @@ -126,6 +128,7 @@ def test_site_slug_validation_and_conflict(client): def test_site_requires_auth(client): + anon(client) pid = _make_page("Root") r = client.post("/api/v2/sites", json={"root_page_id": pid}) assert r.status_code == 401 diff --git a/tests/test_v69_search_ask.py b/tests/test_v69_search_ask.py index b7a0e85..db7d769 100644 --- a/tests/test_v69_search_ask.py +++ b/tests/test_v69_search_ask.py @@ -12,6 +12,8 @@ import math import secrets import struct +from conftest import anon + from app.db import get_conn from app.services import semantic_search as sem @@ -188,6 +190,7 @@ def test_hybrid_finds_by_keyword(client): def test_hybrid_requires_auth(client): + anon(client) r = client.get("/api/v2/search/hybrid?q=test") assert r.status_code == 401 @@ -265,6 +268,7 @@ def test_ask_offline_with_citations(client): def test_ask_requires_auth(client): + anon(client) r = client.post("/api/v2/search/ask", json={"question": "hi"}) assert r.status_code == 401 diff --git a/tests/test_v70_automations_workers.py b/tests/test_v70_automations_workers.py index ad8b423..ab1aaf1 100644 --- a/tests/test_v70_automations_workers.py +++ b/tests/test_v70_automations_workers.py @@ -10,6 +10,7 @@ from __future__ import annotations import secrets import pytest +from conftest import anon from app.db import get_conn from app.services import automations as auto_svc @@ -113,6 +114,7 @@ def test_steps_crud_and_order(client): def test_steps_validation_and_auth(client): + anon(client) session, _ = _login(client) aid = _mkauto(client, session) r = client.post(f"/workspace/automations/{aid}/steps", @@ -423,6 +425,7 @@ def _mkworker(client, session, **kw): def test_workers_crud_and_auth(client): + anon(client) session, _ = _login(client) w = _mkworker(client, session, name="Hello") assert w["slug"].startswith("hello") or w["slug"] diff --git a/tests/test_v71_calendar_meetings.py b/tests/test_v71_calendar_meetings.py index 5de94d6..3bac792 100644 --- a/tests/test_v71_calendar_meetings.py +++ b/tests/test_v71_calendar_meetings.py @@ -11,6 +11,7 @@ import json import secrets import pytest +from conftest import anon from app.db import get_conn from app.services import calendar_sync as cal @@ -117,6 +118,7 @@ def test_link_crud_and_encryption(client): def test_link_validation_and_auth(client): + anon(client) session, _ = _login(client) cid = _mkcollection(client) r = client.post("/api/v2/calendar-links", @@ -279,6 +281,7 @@ def test_freebusy_basic(client): def test_freebusy_validation(client): + anon(client) session, _ = _login(client) cid = _mkcollection(client) r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01", diff --git a/tests/test_v72_enterprise.py b/tests/test_v72_enterprise.py index 7b8f44f..ea441c8 100644 --- a/tests/test_v72_enterprise.py +++ b/tests/test_v72_enterprise.py @@ -10,6 +10,8 @@ from __future__ import annotations import json import secrets +from conftest import anon + from app.db import get_conn # ── helpers ──────────────────────────────────────────────────────────────── @@ -289,6 +291,7 @@ def test_2fa_disable(client): def test_2fa_routes_require_session(client): + anon(client) assert client.get("/auth/2fa/status").status_code == 401 assert client.post("/auth/2fa/setup").status_code == 401 @@ -362,6 +365,7 @@ def test_webauthn_register_begin(client): def test_webauthn_register_begin_requires_session(client): + anon(client) assert client.post("/auth/webauthn/register/begin").status_code == 401 @@ -411,6 +415,7 @@ def test_webauthn_keys_empty_and_delete_404(client): # ── unified audit log ────────────────────────────────────────────────────── def test_audit_requires_admin(client): + anon(client) uid, login = _make_user() c = {"flowdeck_session": _session(uid, login)} assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403 @@ -608,5 +613,6 @@ def test_approval_rejection(client): def test_governance_routes_require_auth(client): + anon(client) assert client.get("/api/v2/agent-policies").status_code == 401 assert client.get("/api/v2/agent-approvals").status_code == 401 diff --git a/tests/test_v73_wiki_polish.py b/tests/test_v73_wiki_polish.py index d4a7088..9b65c94 100644 --- a/tests/test_v73_wiki_polish.py +++ b/tests/test_v73_wiki_polish.py @@ -10,6 +10,8 @@ from __future__ import annotations import datetime import secrets +from conftest import anon + from app.db import get_conn # ── helpers ──────────────────────────────────────────────────────────────── @@ -133,6 +135,7 @@ def test_teamspace_requires_name(client): def test_teamspace_requires_auth(client): + anon(client) assert client.get("/api/v2/wiki/teamspaces?workspace_id=1").status_code == 401 assert client.post("/api/v2/wiki/teamspaces", json={}).status_code == 401 @@ -548,6 +551,7 @@ def test_guest_share_bad_role(client): def test_guest_share_requires_session(client): + anon(client) assert client.post("/api/v2/wiki/pages/1/guests", json={}).status_code == 401 @@ -749,6 +753,7 @@ def test_blocks_preview_endpoint(client): def test_blocks_preview_validation(client): + anon(client) _, _, c = _user() assert client.post("/api/v2/wiki/blocks/preview", cookies=c, json={}).status_code == 400 assert client.post("/api/v2/wiki/blocks/preview", cookies=c, diff --git a/tests/test_web_clipper.py b/tests/test_web_clipper.py index d16c636..727d862 100644 --- a/tests/test_web_clipper.py +++ b/tests/test_web_clipper.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +from conftest import anon + from app.auth.session import SessionManager @@ -81,6 +83,7 @@ def test_extract_article(client): # ── API tests ── def test_clip_requires_auth(client): + anon(client) r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"}) assert r.status_code == 401 @@ -225,7 +228,7 @@ def test_clip_bearer_token_auth(client): conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?,?,?,?)", (uid, "test", th, token[:12])) conn.commit() # No session cookie - client.cookies.clear() + anon(client) r = client.post( "/api/v2/web-clipper/clip", json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "

via bearer

", "device_id": "bearer-dev"}, @@ -241,7 +244,7 @@ def test_status_and_devices_flow(client): uid = _insert_user("clip_status") _insert_workspace(uid) # unauth status - client.cookies.clear() + anon(client) r = client.get("/api/v2/web-clipper/status") assert r.status_code == 200 assert r.json()["authenticated"] is False