- Scan de tous les appels `fetch` non-GET du front par préfixe : 12 préfixes n'ont AUCUN call site sans `X-CSRF-Token` → sortis de `EXCLUDED_PATHS` (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) - `welcome.html` : les 2 POST `/api/onboarding/*` reçoivent le header (`adminFetch` prouve que `/api/admin` était déjà couvert) - reste A19 (front à équiper) : `/api/workspace` (+`/api/workspaces`), `/api/local-workspace`, `/api/settings`, `/api/gitea`, `/api/agent` — 49 fetch - tests : helper `anon_csrf()` (anonyme + CSRF valide) pour isoler les 401 de route du 403 middleware — 4 tests d'anonymat ajustés - suite **1026/1026** · `ruff check app tests` OK
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""FlowDeck — pytest fixtures and configuration.
|
||
|
||
Each test gets a fresh, isolated SQLite database and backup directory so the
|
||
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
|
||
|
||
|
||
def anon_csrf(client):
|
||
"""Anonyme MAIS CSRF valide — comme un navigateur qui a déjà chargé une page.
|
||
|
||
Sert aux tests d'"isolation auth" : on veut le 401 de la route, pas le 403
|
||
du middleware CSRF qui passerait avant.
|
||
"""
|
||
anon(client)
|
||
client.cookies.set("csrf_token", "csrf-anon")
|
||
client.headers["X-CSRF-Token"] = "csrf-anon"
|
||
return client
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
"""FastAPI TestClient with a fresh temporary SQLite database."""
|
||
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||
db_path = db_file.name
|
||
db_file.close()
|
||
|
||
backup_dir = tempfile.mkdtemp(prefix="fd_backups_")
|
||
data_dir = tempfile.mkdtemp(prefix="fd_data_")
|
||
|
||
# Set env BEFORE importing app modules (config reads at import time).
|
||
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
||
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
||
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
||
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
||
os.environ["BACKUP_ENABLED"] = "true"
|
||
os.environ["BACKUP_DIR"] = backup_dir
|
||
os.environ["PROJECT_SYNC_ENABLED"] = "false"
|
||
# Point data-root (uploads/, emoji/, covers/) at a writable temp dir so tests
|
||
# don't depend on the container's /data path existing on a dev host.
|
||
os.environ["FLOWDECK_DATA_DIR"] = data_dir
|
||
|
||
# IMPORTANT: mutate the existing Settings singleton in place — do NOT rebind
|
||
# `app.config.settings`. Modules such as `app.services.backup` and
|
||
# `app.routers.auth` hold a direct reference imported at load time, so
|
||
# rebinding would leave them pointing at the stale defaults (this was the
|
||
# cause of the previously-skipped flaky backup tests).
|
||
import app.config
|
||
s = app.config.settings
|
||
s.database_url = f"sqlite:///{db_path}"
|
||
s.app_secret_key = "test-secret-for-tests"
|
||
s.rate_limit_enabled = False
|
||
s.public_api_insecure_ok = True
|
||
s.backup_enabled = True
|
||
s.backup_dir = backup_dir
|
||
s.backup_interval_hours = 24
|
||
s.backup_keep = 30
|
||
s.project_sync_enabled = False
|
||
|
||
from app.db import init_db
|
||
from app.main import app
|
||
init_db()
|
||
|
||
yield login_test_client(TestClient(app))
|
||
|
||
# Cleanup
|
||
try:
|
||
os.unlink(db_path)
|
||
except PermissionError:
|
||
pass # Windows: file may still be open in another thread
|
||
for p in Path(backup_dir).glob("*.db"):
|
||
try:
|
||
p.unlink()
|
||
except PermissionError:
|
||
pass
|
||
try:
|
||
Path(backup_dir).rmdir()
|
||
except OSError:
|
||
pass
|