Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69a0aceba6 | ||
|
|
d76d7943fc | ||
|
|
d125eb399e | ||
|
|
e6c1f7dbb3 | ||
|
|
465853ac59 |
@@ -1,5 +1,41 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.3.1 (2026-09-30) — Audit sécurité P0 : A1–A10
|
||||
|
||||
> Corrections du bloc critique de l'audit du 2026-09-30 (ROADMAP) : plus aucune
|
||||
> route cookie-auth n'accepte un anonymous, et Jinja2 échappe enfin sa sortie.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A1/A2** — deps `pyotp`/`webauthn`/`cbor2` installées, rebinding de
|
||||
`app.config.settings` supprimé dans `test_v54.py` (isolation rétablie) ;
|
||||
cycle v6.8→v7.3 committé + tag `v7.3.0`
|
||||
- **A3** — `PUT /api/user/password` : 401 sans session + `current_password`
|
||||
exigé ; helper `_require_user_id()` sur profile/password/token/forge ; `/api/user`
|
||||
sorti de la liste CSRF exemptée
|
||||
- **A4** — `POST /api/v1/token` et `POST /api/user/token` : 401 sans session,
|
||||
chemin legacy `user_id=0` supprimé
|
||||
- **A5** — CRUD membres d'espace : session + rôle admin de l'espace (ou admin
|
||||
global), placeholder user créé en `is_admin=0`
|
||||
- **A6** — `_require_view` → 404 / `_require_edit` → 401 sans session (fin du
|
||||
legacy single-user sur les collections)
|
||||
- **A7** — création ET lecture de page → 401 sans session (`PermissionManager`),
|
||||
`/board/api/pages` sorti du CSRF exempt (+ header manquant côté local workspace)
|
||||
- **A8** — seed admin sans mot de passe codé en dur : aléatoire au premier boot
|
||||
(loggé une fois) ou `FLOWDECK_ADMIN_PASSWORD`
|
||||
- **A9** — `.db`/fichiers de test désindexés + `.gitignore`/`.dockerignore`,
|
||||
rotation de `APP_SECRET_KEY`
|
||||
- **A10** — `app/templating.py` : un seul `ENV` avec
|
||||
`autoescape=select_autoescape(["html"])`, 29 instantiations remplacées ;
|
||||
re-tri des `|safe` (corps d'issue + commentaires échappés, `sidebar_config`
|
||||
en `|tojson`)
|
||||
|
||||
### Tests
|
||||
|
||||
- Client de test connecté par défaut (`_TestSessionAuth` : session + CSRF
|
||||
injectés hors cookie jar) + helper `anon()` sur les 40 tests d'anonymat
|
||||
- Suite complète : **1016 passed / 0 failed** · `ruff check app tests` OK
|
||||
|
||||
## v7.3.0 (2026-09-29) — Wiki / Teamspaces + Polish (dernière version du cycle v7)
|
||||
|
||||
> Connaissance vérifiée et finition collaborative : teamspaces, badge ✅ avec
|
||||
|
||||
+11
-10
@@ -1119,16 +1119,16 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
|
||||
### 🔴 P0 — Critique (avant toute exposition réseau)
|
||||
|
||||
- [ ] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [ ] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [ ] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [ ] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [ ] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [ ] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [ ] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [ ] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [ ] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [ ] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
|
||||
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
|
||||
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
|
||||
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
|
||||
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
|
||||
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
|
||||
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
|
||||
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
|
||||
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
|
||||
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
|
||||
- [ ] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
|
||||
|
||||
### 🟠 P1 — Hautes
|
||||
@@ -1185,3 +1185,4 @@ Quality DB views, Agent IA Palette → Realtime + E
|
||||
→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.
|
||||
|
||||
*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
|
||||
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1.**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.0 (cycle v7 — versions v6.8→v7.3 livrées) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.3.1 (audit sécurité P0 A1–A10) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+19
-7
@@ -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
|
||||
@@ -141,7 +153,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.3.0",
|
||||
version="7.3.1",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -440,8 +440,8 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
"comments": comments,
|
||||
"checklists": checklists,
|
||||
}
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("card_detail.html")
|
||||
return HTMLResponse(template.render(**ctx))
|
||||
|
||||
|
||||
+19
-16
@@ -804,7 +804,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
"has_active_workspace": has_active_workspace,
|
||||
"app_version": _get_app_version(),
|
||||
"local_workspaces": _local_workspaces_for_user(user),
|
||||
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
|
||||
"sidebar_config": get_sidebar_config_sync(uid)}
|
||||
|
||||
|
||||
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
|
||||
@@ -908,8 +908,8 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
# Load all pages for the workspace from DB
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -1084,8 +1084,8 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
@@ -1213,8 +1213,8 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
@@ -1240,8 +1240,8 @@ async def board_view(
|
||||
logger.error("Board view error: %s", e)
|
||||
cards = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
|
||||
# Dynamic groups from Gitea labels (fallback to hardcoded)
|
||||
group_names = ["Design", "Engineering", "No Team"]
|
||||
@@ -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:
|
||||
@@ -2044,8 +2047,8 @@ async def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
|
||||
@@ -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")
|
||||
|
||||
+52
-42
@@ -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
|
||||
@@ -252,10 +252,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
with get_conn() as conn:
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -283,10 +282,9 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = board_sidebar(request, owner, repo)
|
||||
# Pass active workspace for breadcrumb nav menu
|
||||
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
|
||||
@@ -312,10 +310,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -437,8 +434,8 @@ async def view_page_root(request: Request, page_id: int):
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -451,8 +448,8 @@ async def accounts_page(request: Request):
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
@@ -656,8 +653,8 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -689,11 +686,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 +708,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 +728,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 +741,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)
|
||||
@@ -755,14 +766,14 @@ async def dashboard(
|
||||
with get_conn() as conn:
|
||||
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
except Exception:
|
||||
pass
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("landing.html")
|
||||
return template.render()
|
||||
|
||||
@@ -807,8 +818,8 @@ async def dashboard(
|
||||
logger.error("Dashboard error: %s", e)
|
||||
repos = []
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, repos)
|
||||
template = env.get_template("dashboard.html")
|
||||
return template.render(request=request, repos=repos, search=search,
|
||||
@@ -820,8 +831,8 @@ async def dashboard(
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -839,8 +850,8 @@ async def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1016,8 +1027,8 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1721,8 +1732,8 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [], include_workspace=False)
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1824,8 +1835,8 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2142,9 +2153,8 @@ async def sidebar_workspace_tree(request: Request):
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
in the main content area to keep the sidebar in sync.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.board import _load_workspace_pages
|
||||
from app.templating import ENV
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -2180,7 +2190,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
)
|
||||
|
||||
# Render the tree using the extracted macro
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
@@ -2201,7 +2211,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2221,7 +2231,7 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
)
|
||||
|
||||
page = dict(row)
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
|
||||
# Convert blocks to HTML for rendering
|
||||
content_html = ""
|
||||
|
||||
@@ -44,9 +44,9 @@ async def import_page(request: Request):
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.templating import ENV
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||
@@ -102,7 +102,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
</div>"""
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
|
||||
@@ -21,10 +21,9 @@ async def get_notes(request: Request, owner: str, repo: str):
|
||||
).fetchone()
|
||||
content = row["content"] if row else ""
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
@@ -45,10 +44,9 @@ async def save_notes(request: Request, owner: str, repo: str):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
template = env.get_template("notes.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, content=content, user=user)
|
||||
|
||||
@@ -41,8 +41,8 @@ async def onboarding_page(request: Request):
|
||||
if ws_count > 0:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("welcome.html")
|
||||
return HTMLResponse(content=template.render(
|
||||
user=user,
|
||||
|
||||
@@ -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 <token>"}
|
||||
|
||||
|
||||
@@ -228,11 +228,10 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
|
||||
+2
-2
@@ -9,13 +9,13 @@ import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import wiki
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.notifications import create_notification
|
||||
from app.templating import ENV
|
||||
|
||||
router = APIRouter(tags=["wiki"])
|
||||
|
||||
@@ -156,7 +156,7 @@ async def teamspace_page(teamspace_id: int, request: Request):
|
||||
.ts-row:hover{{background:var(--bg-hover);}}
|
||||
</style>"""
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1370,7 +1370,7 @@
|
||||
loadSidebarConfig() {
|
||||
var self = this;
|
||||
try {
|
||||
var raw = {{ sidebar_config|default('{}')|safe }};
|
||||
var raw = {{ sidebar_config|default({})|tojson }};
|
||||
if (raw && raw.config) {
|
||||
self.sidebarConfig = raw.config;
|
||||
} else if (typeof raw === 'object') {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<div style="margin-bottom:16px; padding:12px; background:var(--bg-secondary); border-radius:6px; min-height:60px;"
|
||||
contenteditable="true"
|
||||
@blur="updateField('body', $event.target.innerHTML)">
|
||||
{{ issue.body|safe if issue.body else '<span style="color:var(--text-dim);">Add description...</span>' }}
|
||||
{% if issue.body %}{{ issue.body }}{% else %}<span style="color:var(--text-dim);">Add description...</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Checklists -->
|
||||
@@ -82,7 +82,7 @@
|
||||
<span class="text-dim" style="font-weight:400;">· {{ comment.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div style="font-size:13px; color:var(--text-primary); line-height:1.5;">
|
||||
{{ comment.body|safe }}
|
||||
{{ comment.body }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Environment Jinja2 partagé (A10 : autoescape activé partout).
|
||||
|
||||
Une seule instance au lieu de 29 `Environment(loader=FileSystemLoader(...))`
|
||||
sans autoescape — 326 interpolations `{{ … }}` étaient servies crues et tous
|
||||
les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
+61
-1
@@ -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:
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "[email protected]", "permission": "view"})
|
||||
assert r.status_code == 401
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (?, ?))",
|
||||
("[email protected]", "[email protected]"),
|
||||
).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
|
||||
|
||||
+8
-1
@@ -205,7 +205,14 @@ class TestV54TrashPurge:
|
||||
os.environ["PROJECT_SYNC_ENABLED"] = "false"
|
||||
|
||||
import app.config
|
||||
app.config.settings = app.config.Settings()
|
||||
# In-place mutation, jamais de rebinding : les modules importés plus tôt
|
||||
# (sso_provisioning, trash, …) gardent une référence à l'objet courant.
|
||||
_s = app.config.settings
|
||||
_s.database_url = f"sqlite:///{tmp.name}"
|
||||
_s.app_secret_key = "test-secret"
|
||||
_s.rate_limit_enabled = False
|
||||
_s.backup_enabled = False
|
||||
_s.project_sync_enabled = False
|
||||
init_db()
|
||||
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "<p>via bearer</p>", "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
|
||||
|
||||
Reference in New Issue
Block a user