fix: A3–A8 — bloc « fallback admin » de l'audit sécurité (401 sans session, ACL, CSRF)
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 21m47s
FlowDeck CI / docker (push) Successful in 1m45s

- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
This commit is contained in:
2026-09-30 22:04:13 -04:00
parent e6c1f7dbb3
commit d125eb399e
36 changed files with 260 additions and 67 deletions
+15 -3
View File
@@ -75,15 +75,27 @@ logger = logging.getLogger(__name__)
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
init_db() init_db()
init_webhook_tables() init_webhook_tables()
import os
import secrets
from app.db import get_conn from app.db import get_conn
from app.password_utils import hash_password 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: with get_conn() as conn:
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( conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)", "INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
(admin_hash,) (hash_password(admin_pw),),
) )
conn.commit() 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 ── # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler from app.routers.agent import agent_scheduler
+1 -1
View File
@@ -18,7 +18,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not # 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. # 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): async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt # Webhook receiver, OAuth callback, and internal API are exempt
+5 -2
View File
@@ -1385,6 +1385,9 @@ async def create_page(request: Request, title: str = Query(default=""),
parent_id: int = Query(default=0)): parent_id: int = Query(default=0)):
"""Create a new Markdown page, optionally as a sub-page.""" """Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) 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") ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
page_title = title.strip() if title else "" page_title = title.strip() if title else ""
try: try:
@@ -1417,8 +1420,8 @@ async def create_page(request: Request, title: str = Query(default=""),
async def get_page(request: Request, page_id: int): async def get_page(request: Request, page_id: int):
"""Get a Markdown page.""" """Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
# No session → legacy single-user behaviour (matches collections `_require_view`). if not user or not user.get("id"):
if user and user.get("id"): raise HTTPException(401, "Authentication required")
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. # 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): if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found") raise HTTPException(404, "Page not found")
+6 -7
View File
@@ -43,23 +43,22 @@ def _session_user(request: Request) -> dict | None:
def _require_view(collection_id: int, user: dict | None) -> 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 A6 : plus de session = accès refusé — l'absence de user ne vaut plus
un-workspaced collections); explicit ``restricted`` / ``private`` collections « legacy single-user » ( lecture anonyme de n'importe quelle collection ).
are hidden for non-owners unless granted.
""" """
if not user: if not user:
return raise HTTPException(status_code=404, detail="Collection not found")
pm = PermissionManager(user["id"]) pm = PermissionManager(user["id"])
if not pm.can_view_collection(collection_id): if not pm.can_view_collection(collection_id):
raise HTTPException(status_code=404, detail="Collection not found") raise HTTPException(status_code=404, detail="Collection not found")
def _require_edit(collection_id: int, user: dict | None) -> None: 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: if not user:
return raise HTTPException(status_code=401, detail="Authentication required")
pm = PermissionManager(user["id"]) pm = PermissionManager(user["id"])
if not pm.can_edit_collection(collection_id): if not pm.can_edit_collection(collection_id):
raise HTTPException(status_code=403, detail="You don't have edit access to this collection") raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
+20 -6
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
from fastapi import APIRouter, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager 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 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") @router.put("/api/user/profile")
async def update_profile(request: Request): async def update_profile(request: Request):
body = await request.json() body = await request.json()
full_name = body.get("full_name", "").strip() full_name = body.get("full_name", "").strip()
uid = _get_user_id(request) uid = _require_user_id(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid)) conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit() conn.commit()
@@ -702,13 +711,18 @@ async def update_profile(request: Request):
@router.put("/api/user/password") @router.put("/api/user/password")
async def update_password(request: Request): 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() body = await request.json()
password = body.get("password", "").strip() password = body.get("password", "").strip()
if len(password) < 6: if len(password) < 6:
return {"error": "Password must be at least 6 characters"} 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: 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.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit() conn.commit()
return {"status": "ok"} return {"status": "ok"}
@@ -717,7 +731,7 @@ async def update_password(request: Request):
@router.post("/api/user/token") @router.post("/api/user/token")
async def generate_token(request: Request): async def generate_token(request: Request):
import secrets import secrets
uid = _get_user_id(request) uid = _require_user_id(request)
token = secrets.token_hex(32) token = secrets.token_hex(32)
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
@@ -730,7 +744,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}") @router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str): async def disconnect_forge(request: Request, provider: str):
uid = _get_user_id(request) uid = _require_user_id(request)
with get_conn() as conn: with get_conn() as conn:
conn.execute( conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider) "DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
+4 -12
View File
@@ -56,25 +56,17 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token") @router.post("/token")
async def generate_token(request: Request): async def generate_token(request: Request):
"""Generate a public API access token. """Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous)."""
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.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) 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)}" token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn: with get_conn() as conn:
if user and user.get("id"):
conn.execute( conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)", "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]), (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.commit() conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"} return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
+24 -1
View File
@@ -25,6 +25,24 @@ def _current_user(request: Request) -> dict:
return SessionManager.decode_session(s) or {"login": "admin", "id": 1} 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 ── # ── Workspaces ──
@router.get("") @router.get("")
@@ -58,6 +76,8 @@ async def create_workspace(request: Request):
@router.get("/{ws_id}/members") @router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int): 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: with get_conn() as conn:
rows = conn.execute( 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=?", "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") @router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int): 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 {} body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id") user_id = body.get("user_id")
role = body.get("role", "editor") role = body.get("role", "editor")
if role not in ROLES: if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}") raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn: 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}")) (user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)", conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role)) (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}") @router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int): 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 {} body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor") role = body.get("role", "editor")
if role not in ROLES: 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}") @router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int): async def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id)) conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
conn.commit() conn.commit()
+2 -1
View File
@@ -1124,7 +1124,8 @@ window._wsInitData = (function() {
try { try {
var r = await fetch('/board/api/pages/' + node.id + '/icon', { var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST', 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}) body: JSON.stringify({icon: icon})
}); });
if (!r.ok) throw new Error('icon update failed'); if (!r.ok) throw new Error('icon update failed');
+61 -1
View File
@@ -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. database file, and no state leaks between tests.
""" """
import os import os
import re
import tempfile import tempfile
from pathlib import Path from pathlib import Path
import httpx
import pytest import pytest
from fastapi.testclient import TestClient 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 @pytest.fixture
def client(): def client():
"""FastAPI TestClient with a fresh temporary SQLite database.""" """FastAPI TestClient with a fresh temporary SQLite database."""
@@ -55,7 +115,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
# Cleanup # Cleanup
try: try:
+2 -1
View File
@@ -10,6 +10,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -45,7 +46,7 @@ def client():
) )
conn.commit() conn.commit()
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
+2 -1
View File
@@ -10,6 +10,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -41,7 +42,7 @@ def client():
) )
conn.commit() conn.commit()
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
+14 -1
View File
@@ -4,6 +4,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -40,7 +41,7 @@ def client():
) )
conn.commit() conn.commit()
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
@@ -88,6 +89,7 @@ def test_board_404(client):
def test_csrf_rejected(client): def test_csrf_rejected(client):
anon(client)
resp = client.post("/api/move?owner=x&repo=y&issue_id=1&column=Test") resp = client.post("/api/move?owner=x&repo=y&issue_id=1&column=Test")
assert resp.status_code == 403 assert resp.status_code == 403
@@ -134,6 +136,7 @@ def test_card_detail_html(client):
def test_create_issue_api(client): def test_create_issue_api(client):
anon(client)
resp = client.post("/api/issues/test/test?title=Test%20Issue&body=Test%20body") resp = client.post("/api/issues/test/test?title=Test%20Issue&body=Test%20body")
# 403 CSRF or 500 if Gitea down # 403 CSRF or 500 if Gitea down
assert resp.status_code in (403, 500) assert resp.status_code in (403, 500)
@@ -201,11 +204,13 @@ def test_get_ai_keywords(client):
def test_csrf_protects_properties_post(client): def test_csrf_protects_properties_post(client):
anon(client)
resp = client.post("/board/api/properties/test/test?name=Priority&prop_type=select") resp = client.post("/board/api/properties/test/test?name=Priority&prop_type=select")
assert resp.status_code == 403 # CSRF assert resp.status_code == 403 # CSRF
def test_csrf_protects_sync(client): def test_csrf_protects_sync(client):
anon(client)
resp = client.post("/board/api/sync/test/test") resp = client.post("/board/api/sync/test/test")
assert resp.status_code == 403 # CSRF assert resp.status_code == 403 # CSRF
@@ -1354,6 +1359,7 @@ def _create_regular_session():
def test_admin_list_users_unauthorized(client): def test_admin_list_users_unauthorized(client):
anon(client)
"""GET /api/admin/users — 403 without admin session.""" """GET /api/admin/users — 403 without admin session."""
resp = client.get("/api/admin/users") resp = client.get("/api/admin/users")
assert resp.status_code == 403 assert resp.status_code == 403
@@ -1588,6 +1594,7 @@ def test_admin_stats(client):
def test_admin_stats_unauthorized(client): def test_admin_stats_unauthorized(client):
anon(client)
"""GET /api/admin/stats — 403 for non-admin.""" """GET /api/admin/stats — 403 for non-admin."""
resp = client.get("/api/admin/stats") resp = client.get("/api/admin/stats")
assert resp.status_code == 403 assert resp.status_code == 403
@@ -1663,6 +1670,7 @@ def test_gitea_status_with_expired_token(client):
def test_gitea_disconnect_no_auth(client): def test_gitea_disconnect_no_auth(client):
anon(client)
"""DELETE /api/gitea/disconnect — 401 without session.""" """DELETE /api/gitea/disconnect — 401 without session."""
resp = client.delete("/api/gitea/disconnect") resp = client.delete("/api/gitea/disconnect")
assert resp.status_code == 401 assert resp.status_code == 401
@@ -1883,6 +1891,7 @@ def test_auth_user_authenticated(client):
def test_auth_user_unauthenticated(client): def test_auth_user_unauthenticated(client):
anon(client)
"""GET /auth/user — returns authenticated=false without session.""" """GET /auth/user — returns authenticated=false without session."""
resp = client.get("/auth/user") resp = client.get("/auth/user")
assert resp.status_code == 200 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): def test_gitea_private_pages_create_no_auth(client):
anon(client)
"""POST private-pages — 401 without session.""" """POST private-pages — 401 without session."""
resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"}) resp = client.post("/api/gitea/projects/owner/repo/private-pages", json={"title": "Test"})
assert resp.status_code == 401 assert resp.status_code == 401
@@ -2019,6 +2029,7 @@ def test_page_renders_breadcrumb_data(client):
def test_landing_page_no_auth(client): def test_landing_page_no_auth(client):
anon(client)
"""Visiting / without auth shows the landing page.""" """Visiting / without auth shows the landing page."""
resp = client.get("/", follow_redirects=False) resp = client.get("/", follow_redirects=False)
assert resp.status_code == 200 assert resp.status_code == 200
@@ -2095,6 +2106,7 @@ def test_register_duplicate_rejected(client):
def test_session_expired_redirect(client): def test_session_expired_redirect(client):
anon(client)
"""Unauthenticated access to protected page redirects with expired param.""" """Unauthenticated access to protected page redirects with expired param."""
resp = client.get("/workspaces", follow_redirects=False) resp = client.get("/workspaces", follow_redirects=False)
assert resp.status_code == 302 assert resp.status_code == 302
@@ -3464,6 +3476,7 @@ def test_v490_page_mentions_endpoint(client):
def test_v490_notifications_require_auth(client): def test_v490_notifications_require_auth(client):
anon(client)
r = client.get("/api/notifications") r = client.get("/api/notifications")
assert r.status_code == 401 assert r.status_code == 401
+2 -1
View File
@@ -8,6 +8,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -31,7 +32,7 @@ def client():
conn.commit() conn.commit()
tc = TestClient(app) tc = TestClient(app)
yield tc yield login_test_client(tc)
os.unlink(db_path) os.unlink(db_path)
+2 -1
View File
@@ -10,6 +10,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -34,7 +35,7 @@ def client():
conn.commit() conn.commit()
tc = TestClient(app, raise_server_exceptions=False) tc = TestClient(app, raise_server_exceptions=False)
yield tc yield login_test_client(tc)
os.unlink(db_path) os.unlink(db_path)
+2 -1
View File
@@ -4,6 +4,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -28,7 +29,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
os.unlink(db_path) os.unlink(db_path)
+4 -1
View File
@@ -10,6 +10,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
@@ -38,7 +39,7 @@ def client():
manager._rooms = {} manager._rooms = {}
tc = TestClient(app, raise_server_exceptions=False) tc = TestClient(app, raise_server_exceptions=False)
yield tc yield login_test_client(tc)
os.unlink(db_path) os.unlink(db_path)
@@ -113,6 +114,7 @@ def test_merge_ops_sequential():
# ── Auth & présence de page ── # ── Auth & présence de page ──
def test_ws_requires_auth(client): def test_ws_requires_auth(client):
anon(client)
_make_page() _make_page()
with pytest.raises(WebSocketDisconnect) as exc: with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/pages/1") as ws: 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): def test_ws_auth_rejected(client):
anon(client)
_make_page() _make_page()
with pytest.raises(WebSocketDisconnect) as exc: with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/pages/1") as ws: with client.websocket_connect("/ws/pages/1") as ws:
+4 -1
View File
@@ -16,6 +16,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
@@ -49,7 +50,7 @@ def client():
manager.stat_connections_total = 0 manager.stat_connections_total = 0
tc = TestClient(app, raise_server_exceptions=False) tc = TestClient(app, raise_server_exceptions=False)
yield tc yield login_test_client(tc)
os.unlink(db_path) os.unlink(db_path)
@@ -193,6 +194,7 @@ def test_apply_op_still_lww_without_base():
# ── protocole WS ───────────────────────────────────────────────────────── # ── protocole WS ─────────────────────────────────────────────────────────
def test_ws_requires_auth(client): def test_ws_requires_auth(client):
anon(client)
_make_page() _make_page()
with pytest.raises(WebSocketDisconnect) as exc: with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect("/ws/pages/1") as ws: 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): def test_ws_stats_requires_auth(client):
anon(client)
r = client.get("/api/realtime/stats") r = client.get("/api/realtime/stats")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"error": "unauthorized"} assert r.json() == {"error": "unauthorized"}
+2 -1
View File
@@ -7,6 +7,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -31,7 +32,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
os.unlink(db_path) os.unlink(db_path)
+4
View File
@@ -7,6 +7,8 @@ import json
import re import re
from pathlib import Path from pathlib import Path
from conftest import anon
ROOT = Path(__file__).resolve().parent.parent 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): def test_sw_registration_present_on_landing(client):
anon(client)
"""Anonymous entry point (/) registers the SW (assets are public).""" """Anonymous entry point (/) registers the SW (assets are public)."""
resp = client.get("/") resp = client.get("/")
assert resp.status_code in (200, 302) 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): def test_base_has_pwa_meta_tags(client):
anon(client)
resp = client.get("/") resp = client.get("/")
body = resp.text body = resp.text
assert 'rel="manifest"' in body assert 'rel="manifest"' in body
+3 -1
View File
@@ -8,6 +8,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -41,7 +42,7 @@ def client():
conn.commit() conn.commit()
tc = TestClient(app, raise_server_exceptions=False) tc = TestClient(app, raise_server_exceptions=False)
yield tc yield login_test_client(tc)
os.unlink(db_path) os.unlink(db_path)
@@ -109,6 +110,7 @@ def test_share_invalid_permission_rejected(client):
def test_share_requires_auth(client): def test_share_requires_auth(client):
anon(client)
pid = _make_page(client) pid = _make_page(client)
r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"}) r = client.post(f"/api/pages/{pid}/share", json={"email": "[email protected]", "permission": "view"})
assert r.status_code == 401 assert r.status_code == 401
+3
View File
@@ -1,6 +1,8 @@
"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints.""" """FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints."""
import time import time
from conftest import anon
# ── helpers ──────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────
@@ -58,6 +60,7 @@ def _page_updated_epoch(pid) -> float:
def test_sync_requires_auth(client): def test_sync_requires_auth(client):
anon(client)
assert client.get("/api/v2/sync/status").status_code == 401 assert client.get("/api/v2/sync/status").status_code == 401
assert client.post("/api/v2/sync/batch", json={"mutations": []}).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 assert client.get("/api/v2/sync/delta").status_code == 401
+11 -2
View File
@@ -13,6 +13,7 @@ import secrets
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -36,7 +37,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
@@ -58,6 +59,10 @@ def _login(client):
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit() conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0}) session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 0})
# 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"] csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid return session, csrf, uid
@@ -76,13 +81,17 @@ def _login_admin(client):
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"] uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
conn.commit() conn.commit()
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1}) session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
# 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"] csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid return session, csrf, uid
def _mk_page(client, session, title, blocks=None): def _mk_page(client, session, title, blocks=None):
r = client.post("/board/api/pages", params={"title": title, "section": "Private"}, 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 assert r.status_code == 200
pid = r.json()["id"] pid = r.json()["id"]
if blocks is not None: if blocks is not None:
+8 -1
View File
@@ -8,6 +8,7 @@ import asyncio
import os import os
import pytest import pytest
from conftest import anon
from fastapi import HTTPException from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -66,6 +67,7 @@ def test_api_token_lifecycle(client):
def test_api_tokens_require_authentication(client): def test_api_tokens_require_authentication(client):
anon(client)
r1 = client.get("/api/settings/tokens") r1 = client.get("/api/settings/tokens")
assert r1.status_code == 401 assert r1.status_code == 401
r2 = client.post("/api/settings/tokens", json={"name": "x"}) 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 from app.db import get_conn
with get_conn() as 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 (?, ?))",
("[email protected]", "[email protected]"),
).fetchone()[0]
assert count == 2 assert count == 2
# Revoke alice's session using alice's cookie (the test client now has bob's cookie). # 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): def test_backup_admin_api(client):
anon(client)
"""The backup admin API is admin-only and snapshots on demand.""" """The backup admin API is admin-only and snapshots on demand."""
# Unauthenticated → forbidden. # Unauthenticated → forbidden.
assert client.post("/api/settings/backups/run").status_code == 403 assert client.post("/api/settings/backups/run").status_code == 403
+3
View File
@@ -14,6 +14,8 @@ import secrets
import time import time
import zipfile import zipfile
from conftest import anon
from app.db import get_conn from app.db import get_conn
@@ -382,6 +384,7 @@ class TestMappingAndWizard:
assert props["Code"] == "007" assert props["Code"] == "007"
def test_wizard_page_requires_auth(self, client): def test_wizard_page_requires_auth(self, client):
anon(client)
r = client.get("/import", follow_redirects=False) r = client.get("/import", follow_redirects=False)
assert r.status_code in (302, 307) assert r.status_code in (302, 307)
+4 -3
View File
@@ -12,6 +12,7 @@ import secrets
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -35,7 +36,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
os.unlink(db_path) 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"]) pv = json.loads(client.get(f"/db/pages/{page_id}/api").json()["property_values_json"])
assert pv[str(ct)] assert pv[str(ct)]
assert pv[str(lt)] assert pv[str(lt)]
assert pv[str(cb)]["login"] == "admin" assert pv[str(cb)]["login"] == "tester"
assert pv[str(lb)]["login"] == "admin" assert pv[str(lb)]["login"] == "tester"
created = pv[str(ct)] created = pv[str(ct)]
# Partial update keeps created_time and refreshes last_edited_time. # Partial update keeps created_time and refreshes last_edited_time.
+3 -1
View File
@@ -14,6 +14,7 @@ import secrets
import tempfile import tempfile
import pytest import pytest
from conftest import anon, login_test_client
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -37,7 +38,7 @@ def client():
from app.main import app from app.main import app
init_db() init_db()
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
@@ -265,6 +266,7 @@ def test_user_timezone_endpoints(client):
def test_notifications_unauth(client): def test_notifications_unauth(client):
anon(client)
assert client.get("/api/notifications/timezone").status_code == 401 assert client.get("/api/notifications/timezone").status_code == 401
+3
View File
@@ -5,6 +5,8 @@ restricted, private), explicit grants (user + group), inheritance, property
visibility, user groups and the audit log. visibility, user groups and the audit log.
""" """
from conftest import anon
from app.auth.session import SessionManager from app.auth.session import SessionManager
# ═══════════════ helpers ═══════════════ # ═══════════════ helpers ═══════════════
@@ -516,6 +518,7 @@ def test_audit_log_records_changes(client):
def test_permissions_endpoints_require_auth(client): def test_permissions_endpoints_require_auth(client):
anon(client)
assert client.get("/api/v2/pages/1/permissions").status_code == 401 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.post("/api/v2/pages/1/permissions", json={"user_id": 2, "role": "viewer"}).status_code == 401
assert client.get("/api/v2/groups").status_code == 401 assert client.get("/api/v2/groups").status_code == 401
+2 -1
View File
@@ -11,6 +11,7 @@ import os
import tempfile import tempfile
import pytest import pytest
from conftest import login_test_client
from app.services import skill_gallery from app.services import skill_gallery
from app.services.webhook_outbound import EVENTS from app.services.webhook_outbound import EVENTS
@@ -50,7 +51,7 @@ def client():
conn.commit() conn.commit()
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
yield TestClient(app) yield login_test_client(TestClient(app))
try: try:
os.unlink(db_path) os.unlink(db_path)
+3 -1
View File
@@ -17,6 +17,7 @@ import secrets as pysecrets
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import pytest import pytest
from conftest import anon
# ── Mock IdP constants ───────────────────────────────────────────────────── # ── Mock IdP constants ─────────────────────────────────────────────────────
IDP_ENTITY = "https://idp.corp.test/saml/metadata" 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): def test_config_requires_admin(client):
anon(client)
assert client.get("/api/v2/sso/config").status_code == 401 assert client.get("/api/v2/sso/config").status_code == 401
_admin_session(client) _admin_session(client)
# demote to plain user → 403 # 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"] assert failures[0]["error_message"]
# anonymous → 401 # anonymous → 401
client.cookies.delete("flowdeck_session") anon(client)
assert client.get("/api/v2/sso/history").status_code == 401 assert client.get("/api/v2/sso/history").status_code == 401
+3
View File
@@ -9,6 +9,8 @@ from __future__ import annotations
import json import json
import secrets import secrets
from conftest import anon
from app.db import get_conn from app.db import get_conn
@@ -126,6 +128,7 @@ def test_site_slug_validation_and_conflict(client):
def test_site_requires_auth(client): def test_site_requires_auth(client):
anon(client)
pid = _make_page("Root") pid = _make_page("Root")
r = client.post("/api/v2/sites", json={"root_page_id": pid}) r = client.post("/api/v2/sites", json={"root_page_id": pid})
assert r.status_code == 401 assert r.status_code == 401
+4
View File
@@ -12,6 +12,8 @@ import math
import secrets import secrets
import struct import struct
from conftest import anon
from app.db import get_conn from app.db import get_conn
from app.services import semantic_search as sem 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): def test_hybrid_requires_auth(client):
anon(client)
r = client.get("/api/v2/search/hybrid?q=test") r = client.get("/api/v2/search/hybrid?q=test")
assert r.status_code == 401 assert r.status_code == 401
@@ -265,6 +268,7 @@ def test_ask_offline_with_citations(client):
def test_ask_requires_auth(client): def test_ask_requires_auth(client):
anon(client)
r = client.post("/api/v2/search/ask", json={"question": "hi"}) r = client.post("/api/v2/search/ask", json={"question": "hi"})
assert r.status_code == 401 assert r.status_code == 401
+3
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import secrets import secrets
import pytest import pytest
from conftest import anon
from app.db import get_conn from app.db import get_conn
from app.services import automations as auto_svc 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): def test_steps_validation_and_auth(client):
anon(client)
session, _ = _login(client) session, _ = _login(client)
aid = _mkauto(client, session) aid = _mkauto(client, session)
r = client.post(f"/workspace/automations/{aid}/steps", r = client.post(f"/workspace/automations/{aid}/steps",
@@ -423,6 +425,7 @@ def _mkworker(client, session, **kw):
def test_workers_crud_and_auth(client): def test_workers_crud_and_auth(client):
anon(client)
session, _ = _login(client) session, _ = _login(client)
w = _mkworker(client, session, name="Hello") w = _mkworker(client, session, name="Hello")
assert w["slug"].startswith("hello") or w["slug"] assert w["slug"].startswith("hello") or w["slug"]
+3
View File
@@ -11,6 +11,7 @@ import json
import secrets import secrets
import pytest import pytest
from conftest import anon
from app.db import get_conn from app.db import get_conn
from app.services import calendar_sync as cal 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): def test_link_validation_and_auth(client):
anon(client)
session, _ = _login(client) session, _ = _login(client)
cid = _mkcollection(client) cid = _mkcollection(client)
r = client.post("/api/v2/calendar-links", r = client.post("/api/v2/calendar-links",
@@ -279,6 +281,7 @@ def test_freebusy_basic(client):
def test_freebusy_validation(client): def test_freebusy_validation(client):
anon(client)
session, _ = _login(client) session, _ = _login(client)
cid = _mkcollection(client) cid = _mkcollection(client)
r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01", r = client.get(f"/db/{cid}/calendar/freebusy?from=2026-10-07&to=2026-10-01",
+6
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import json import json
import secrets import secrets
from conftest import anon
from app.db import get_conn from app.db import get_conn
# ── helpers ──────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────
@@ -289,6 +291,7 @@ def test_2fa_disable(client):
def test_2fa_routes_require_session(client): def test_2fa_routes_require_session(client):
anon(client)
assert client.get("/auth/2fa/status").status_code == 401 assert client.get("/auth/2fa/status").status_code == 401
assert client.post("/auth/2fa/setup").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): def test_webauthn_register_begin_requires_session(client):
anon(client)
assert client.post("/auth/webauthn/register/begin").status_code == 401 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 ────────────────────────────────────────────────────── # ── unified audit log ──────────────────────────────────────────────────────
def test_audit_requires_admin(client): def test_audit_requires_admin(client):
anon(client)
uid, login = _make_user() uid, login = _make_user()
c = {"flowdeck_session": _session(uid, login)} c = {"flowdeck_session": _session(uid, login)}
assert client.get("/api/v2/audit/logs", cookies=c).status_code == 403 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): 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-policies").status_code == 401
assert client.get("/api/v2/agent-approvals").status_code == 401 assert client.get("/api/v2/agent-approvals").status_code == 401
+5
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import datetime import datetime
import secrets import secrets
from conftest import anon
from app.db import get_conn from app.db import get_conn
# ── helpers ──────────────────────────────────────────────────────────────── # ── helpers ────────────────────────────────────────────────────────────────
@@ -133,6 +135,7 @@ def test_teamspace_requires_name(client):
def test_teamspace_requires_auth(client): def test_teamspace_requires_auth(client):
anon(client)
assert client.get("/api/v2/wiki/teamspaces?workspace_id=1").status_code == 401 assert client.get("/api/v2/wiki/teamspaces?workspace_id=1").status_code == 401
assert client.post("/api/v2/wiki/teamspaces", json={}).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): def test_guest_share_requires_session(client):
anon(client)
assert client.post("/api/v2/wiki/pages/1/guests", json={}).status_code == 401 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): def test_blocks_preview_validation(client):
anon(client)
_, _, c = _user() _, _, 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, json={}).status_code == 400
assert client.post("/api/v2/wiki/blocks/preview", cookies=c, assert client.post("/api/v2/wiki/blocks/preview", cookies=c,
+5 -2
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import json import json
from conftest import anon
from app.auth.session import SessionManager from app.auth.session import SessionManager
@@ -81,6 +83,7 @@ def test_extract_article(client):
# ── API tests ── # ── API tests ──
def test_clip_requires_auth(client): def test_clip_requires_auth(client):
anon(client)
r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"}) r = client.post("/api/v2/web-clipper/clip", json={"url": "https://example.com", "title": "T"})
assert r.status_code == 401 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.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?,?,?,?)", (uid, "test", th, token[:12]))
conn.commit() conn.commit()
# No session cookie # No session cookie
client.cookies.clear() anon(client)
r = client.post( r = client.post(
"/api/v2/web-clipper/clip", "/api/v2/web-clipper/clip",
json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "<p>via bearer</p>", "device_id": "bearer-dev"}, json={"url": "https://example.com/bearer", "title": "BearerClip", "content": "<p>via bearer</p>", "device_id": "bearer-dev"},
@@ -241,7 +244,7 @@ def test_status_and_devices_flow(client):
uid = _insert_user("clip_status") uid = _insert_user("clip_status")
_insert_workspace(uid) _insert_workspace(uid)
# unauth status # unauth status
client.cookies.clear() anon(client)
r = client.get("/api/v2/web-clipper/status") r = client.get("/api/v2/web-clipper/status")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["authenticated"] is False assert r.json()["authenticated"] is False