Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24229316c7 | ||
|
|
7d70e0fb75 | ||
|
|
d70ecd0968 | ||
|
|
36a4030c09 | ||
|
|
330462e7a5 | ||
|
|
922dfa2e79 | ||
|
|
34fce932cb |
@@ -13,6 +13,9 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# OBSIGATE_ALLOW_INSECURE=false
|
||||
|
||||
# Sécurité des cookies (activer si derrière HTTPS)
|
||||
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
|
||||
# ce qui casserait les logins en local. En production (TLS + bind réseau),
|
||||
# posez true — un avertissement est loggé au démarrage sinon (#87).
|
||||
# OBSIGATE_SECURE_COOKIES=false
|
||||
|
||||
# Tokens TTL en secondes
|
||||
|
||||
@@ -44,13 +44,12 @@ jobs:
|
||||
node tests/frontend/config-mobile.test.mjs
|
||||
node tests/frontend/settings-order-avatar.test.mjs
|
||||
node tests/frontend/mobile-toolbar.test.mjs
|
||||
node tests/frontend/upload.test.mjs
|
||||
node tests/frontend/pretty.test.mjs
|
||||
node tests/frontend/media-viewer.test.mjs
|
||||
node tests/frontend/mfa-settings.test.mjs
|
||||
node tests/frontend/config-ai-keys.test.mjs
|
||||
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
|
||||
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload)
|
||||
run: |
|
||||
cd tests/frontend
|
||||
if [ -d node_modules ]; then
|
||||
@@ -68,6 +67,7 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -85,6 +85,7 @@ jobs:
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
node upload.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
+141
-45
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.28.2**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.28.9**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,91 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.9] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-082 — CI `lint` rouge : `upload.test.mjs` exige `jsdom`.**
|
||||
`tests/frontend/upload.test.mjs` (import statique `jsdom`, introduit par
|
||||
`#89`) était exécuté dans l'étape frontend racine où `jsdom` n'est jamais
|
||||
installé (`ERR_MODULE_NOT_FOUND`, rouge depuis `7bee4a2`). Déplacé dans
|
||||
l'étape JSDOM (les deux branches, après install si besoin) ; seul fichier
|
||||
de l'étape racine avec import statique `jsdom`, les autres suites racine
|
||||
n'en ont pas besoin.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.8] — 2026-09-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
|
||||
`run-e2e-local.ps1/.sh` : `npx --yes` (jamais de prompt interactif),
|
||||
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
|
||||
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
|
||||
défaut 900 s / 600 s, exit 124 au dépassement) ; `playwright.config.ts` :
|
||||
`globalTimeout` (15 min en local, 30 min en CI, `E2E_GLOBAL_TIMEOUT_MS`
|
||||
pour surcharger) ; `e2e-server.ps1` : pidfile resynchronisé sur le vrai
|
||||
propriétaire du port et `stop` qui tue l'arbre complet (fini les serveurs
|
||||
orphelins qui squattent le port 2029). Garde-fou : `tests/test_e2e_harness.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.7] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T5b) — nonces CSP prêts pour la bascule (sans changement).**
|
||||
Nonce frais par réponse dans `script-src` (`backend/csp.py`), injecté
|
||||
dans les 6 pages HTML servies (dont la nouvelle route
|
||||
`/excalidraw-editor.html`, utilisée par l'iframe du viewer au lieu de
|
||||
`/static/`) et la page de partage ; `tests/test_csp_nonce.py` (unicité,
|
||||
concordance en-tête/HTML). `unsafe-inline` conservé jusqu'en T5c.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.6] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#87 (T5a) — 16 handlers inline convertis en listeners (CSP inchangée).**
|
||||
`onclick`/`onerror` de `index.html` et des vues JS (`config`, `plugins`,
|
||||
`sync`, `viewer`, `auth`) remplacés par `addEventListener` ; suites
|
||||
frontend vertes (unit, ai, config-mobile, pdf-viewer, mfa-settings,
|
||||
sidebar-filters).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.5] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.28.4] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T4) — E2E XSS et serveur E2E piloté.**
|
||||
`tests/e2e/xss.spec.js` : page publique `/s/{token}` (titre/frontmatter
|
||||
hostile échappé, JSON neutralisé, aucun JS exécuté) et lecteur markdown
|
||||
(sanitizer, aucun `on*`/`javascript:` vivant) — 2/2 verts en local.
|
||||
`scripts/e2e-server.ps1` : `start|stop|status|logs` avec progression
|
||||
visible et fichier PID (fini les serveurs orphelins sur le port 2029).
|
||||
|
||||
---
|
||||
|
||||
## [2.28.3] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#87 (T3) — cookies `Secure` et CORS explicites.**
|
||||
Helper `is_secure_cookies()` centralisé (défaut `false` conservé pour ne
|
||||
pas casser les logins HTTP locaux) + avertissement au démarrage sur bind
|
||||
non-loopback sans `Secure` ; `tests/test_security_headers.py` atteste
|
||||
l'absence de CORS permissif (same-origin par défaut du navigateur) et les
|
||||
en-têtes de durcissement.
|
||||
|
||||
---
|
||||
|
||||
## [2.28.2] — 2026-09-26
|
||||
|
||||
### Ajouté
|
||||
@@ -43,50 +128,6 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.28.0] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.12] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.11] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.10] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.9] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.8] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.7] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.6] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.5] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.4] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.3] — 2026-09-26
|
||||
|
||||
---
|
||||
|
||||
## [2.27.2] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T10) — persistance d'état et clôture de la refonte architecturale.**
|
||||
@@ -99,6 +140,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
câblés, rien à coder). Index non persisté : rebuild différentiel #86
|
||||
suffisant (décision documentée). Fiche `docs/features/archi-refonte-85.md`,
|
||||
#85 sorti du backlog (index roadmap).
|
||||
|
||||
## [2.27.12] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T9) — extraction realtime + render hors du monolithe `backend/main.py`.**
|
||||
Le stream SSE `/api/events` et le WebSocket `/ws/collab/*` sont servis par
|
||||
`backend/routers/realtime.py`, le pipeline markdown (mistune, wikilinks,
|
||||
@@ -106,30 +152,55 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
couplage différé). `main.py` (4 827 → ~760 lignes) ne contient plus que
|
||||
l'assemblage : lifespan, middlewares, montage des 16 routers, racine
|
||||
`/api`, statique/SPA et cales de compatibilité testées.
|
||||
|
||||
## [2.27.11] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T8) — extraction vaults/history/conflicts hors du monolithe `backend/main.py`.**
|
||||
13 routes servies par `backend/routers/vaults.py`, `history.py` et
|
||||
`conflicts.py` ; `VaultInfo`/`BookmarkToggleRequest` dans `schemas.py`,
|
||||
handle watcher partagé dans `backend/watcher_state.py`.
|
||||
`tests/test_api_main.py` importe `humanize_mtime` depuis son module
|
||||
canonique (`services.recent`).
|
||||
|
||||
## [2.27.10] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T7) — extraction du domaine `config` hors du monolithe `backend/main.py`.**
|
||||
`/api/config`, ai-keys (get/post/delete/test), tool-keys (×3), ai-models,
|
||||
diagnostics et dashboard sont servis par `backend/routers/config.py`
|
||||
(`_FALLBACK_MODELS`, store clés et config déplacés ; `main` réimporte
|
||||
`_load_config` pour son lifespan, les fixtures de tests inchangées).
|
||||
`tests/test_ai_models.py` patch désormais la référence du router.
|
||||
|
||||
## [2.27.9] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6c) — extraction media/pdf/export/guide hors du monolithe `backend/main.py`.**
|
||||
file/pdf, exports (html/md-bundle/epub), guide/download, pdf/stream|info,
|
||||
image, media+thumb, attachments (rescan/stats), vault settings (get/post/all)
|
||||
et vault files sont servis par `backend/routers/files_media.py` ; le helper
|
||||
Range partagé vit dans `backend/routers/helpers.py` (tags OpenAPI inchangés,
|
||||
tests statiques frontend `media-viewer`/`image-viewer` réalignés).
|
||||
|
||||
## [2.27.8] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6b) — extraction mutations fichiers/dossiers hors du monolithe `backend/main.py`.**
|
||||
`PUT .../save|xlsx/save`, `DELETE/POST/PATCH /api/file`, `POST/PATCH/DELETE
|
||||
/api/directory`, `POST /api/move`, `POST .../batch-upload` sont servis par
|
||||
le nouveau `backend/routers/files_write.py` (effets de bord inchangés :
|
||||
audit, index, SSE, webhooks, plugins, historique) ; 15 modèles dans
|
||||
`schemas.py`.
|
||||
|
||||
## [2.27.7] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
|
||||
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
|
||||
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
|
||||
@@ -138,6 +209,11 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
|
||||
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
|
||||
double-enregistrement de `/api/conflicts` supprimés.
|
||||
|
||||
## [2.27.6] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
|
||||
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
|
||||
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
|
||||
@@ -145,22 +221,42 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
par le nouveau `backend/routers/search.py` ; les modèles search dans
|
||||
`schemas.py` et le pool de threads dans `backend/search_executor.py`
|
||||
(même dimensionnement, même cycle de vie) — comportement inchangé.
|
||||
|
||||
## [2.27.5] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
|
||||
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
|
||||
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
|
||||
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
|
||||
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
|
||||
`main`) — comportement inchangé, aucun impact utilisateur.
|
||||
|
||||
## [2.27.4] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
|
||||
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
|
||||
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
|
||||
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
|
||||
réponses, tags OpenAPI et authentification inchangés (aucun impact
|
||||
utilisateur).
|
||||
|
||||
## [2.27.3] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
|
||||
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
|
||||
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
|
||||
authentification inchangés (aucun impact utilisateur).
|
||||
|
||||
## [2.27.2] — 2026-09-26
|
||||
|
||||
### Modifié
|
||||
|
||||
- **#85 (T1) — extraction du domaine `health` hors du monolithe `backend/main.py`.**
|
||||
`GET /api/health` et `GET /api/health/detailed` (admin) sont servis par le
|
||||
nouveau `backend/routers/health.py` (monté dans `main.py`) et le modèle
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.2).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.9).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.28.2 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.28.9 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.2).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.9).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.28.2 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.28.9 | Last updated: September 2026*
|
||||
|
||||
+14
-5
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
@@ -56,6 +57,17 @@ logger = logging.getLogger("obsigate.auth.router")
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def is_secure_cookies() -> bool:
|
||||
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
|
||||
|
||||
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
|
||||
Default stays ``false`` so logins keep working over plain HTTP on
|
||||
trusted loopback deployments — browsers drop ``Secure`` cookies sent
|
||||
over HTTP, which would silently break localhost logins.
|
||||
"""
|
||||
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
|
||||
|
||||
# ── Pydantic request models ──────────────────────────────────────────
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -229,9 +241,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
access_token = create_access_token(user)
|
||||
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||
|
||||
import os
|
||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies()
|
||||
response.set_cookie(
|
||||
key="refresh_token",
|
||||
value=refresh_token,
|
||||
@@ -300,9 +311,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
if stale:
|
||||
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||
|
||||
import os
|
||||
|
||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||
secure = is_secure_cookies()
|
||||
remember_me = bool(payload.get("remember", False))
|
||||
|
||||
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Content-Security-Policy nonces (ROADMAP #87, tranche 5b).
|
||||
|
||||
Chaque réponse HTTP reçoit un nonce frais (``request.state.csp_nonce``)
|
||||
injecté dans ``script-src``. Les routes servant du HTML avec des scripts
|
||||
inline (index, popout, admin, editor-poc, excalidraw, page de partage)
|
||||
l'injectent dans le balisage via :func:`inject_csp_nonce` — mêmes
|
||||
emplacements, aucun script déplacé.
|
||||
|
||||
Tant que ``'unsafe-inline'`` reste dans la politique (retrait en T5c),
|
||||
l'injection est inerte : elle prépare la bascule sans changer le
|
||||
comportement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
|
||||
# Balises <script> exécutables sans `src` et sans nonce existant :
|
||||
# `<script>`, `<script type="module">`, `<script type="importmap">`.
|
||||
# Les blocs non-JS (ex. `type="text/plain"`) et les scripts externes
|
||||
# (`src=…`, couverts par 'self'/hôtes CDN) sont laissés intacts.
|
||||
_SCRIPT_TAG_RE = re.compile(
|
||||
r"<script(?=>|\s+type=\"(?:module|importmap)\"\s*>)",
|
||||
)
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
"""Generate a fresh per-response CSP nonce."""
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def inject_csp_nonce(html: str, nonce: str) -> str:
|
||||
"""Add ``nonce="…"`` to bare executable inline ``<script>`` tags."""
|
||||
return _SCRIPT_TAG_RE.sub(f'<script nonce="{nonce}"', html)
|
||||
+49
-9
@@ -167,6 +167,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all HTTP responses."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
from backend.csp import new_nonce
|
||||
|
||||
# Nonce CSP frais par réponse (#87 T5b) : injecté dans script-src et
|
||||
# dans le balisage HTML par les routes (backend.csp.inject_csp_nonce).
|
||||
# 'unsafe-inline' est conservé jusqu'en T5c (bascule avec validation E2E).
|
||||
nonce = new_nonce()
|
||||
request.state.csp_nonce = nonce
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
@@ -177,7 +184,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
if "Content-Security-Policy" not in response.headers:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
@@ -249,6 +256,17 @@ async def lifespan(app: FastAPI):
|
||||
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
|
||||
_guard_insecure_auth()
|
||||
|
||||
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
|
||||
# sur un bind non-loopback (transactions observables en clair).
|
||||
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
|
||||
logger.warning(
|
||||
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
|
||||
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
|
||||
)
|
||||
|
||||
# Bootstrap admin account if needed
|
||||
bootstrap_admin()
|
||||
|
||||
@@ -689,6 +707,15 @@ def _get_backup_dir(vault_name: str, relative_path: str) -> Path:
|
||||
# Static files & SPA fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _html_with_nonce(request: Request, name: str) -> str:
|
||||
"""Read a frontend HTML file and inject the per-response CSP nonce (#87 T5b)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
return inject_csp_nonce(
|
||||
(FRONTEND_DIR / name).read_text(encoding="utf-8"),
|
||||
request.state.csp_nonce,
|
||||
)
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
@@ -721,23 +748,36 @@ if FRONTEND_DIR.exists():
|
||||
raise HTTPException(status_code=404, detail="Manifest not found")
|
||||
|
||||
@app.get("/popout/{vault_name}/{path:path}")
|
||||
async def serve_popout(vault_name: str, path: str):
|
||||
async def serve_popout(request: Request, vault_name: str, path: str):
|
||||
"""Serve the minimalist popout page for a specific file."""
|
||||
popout_file = FRONTEND_DIR / "popout.html"
|
||||
if popout_file.exists():
|
||||
return HTMLResponse(content=popout_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_html_with_nonce(request, "popout.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Popout template not found")
|
||||
|
||||
@app.get("/editor-poc")
|
||||
async def serve_editor_poc():
|
||||
async def serve_editor_poc(request: Request):
|
||||
"""Serve the standalone Editor POC page (multi-zone toolbar demo)."""
|
||||
poc_file = FRONTEND_DIR / "editor-poc.html"
|
||||
if poc_file.exists():
|
||||
return HTMLResponse(content=poc_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_html_with_nonce(request, "editor-poc.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Editor POC not found")
|
||||
|
||||
@app.get("/excalidraw-editor.html", include_in_schema=False)
|
||||
async def serve_excalidraw_editor(request: Request):
|
||||
"""Serve the Excalidraw editor with CSP nonce (#87 T5b).
|
||||
|
||||
Remplace l'accès direct via ``/static/`` (utilisé par l'iframe du
|
||||
viewer) : sans injection, les scripts inline seraient bloqués dès
|
||||
le retrait de ``'unsafe-inline'`` (T5c).
|
||||
"""
|
||||
exca_file = FRONTEND_DIR / "excalidraw-editor.html"
|
||||
if exca_file.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "excalidraw-editor.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Excalidraw editor not found")
|
||||
|
||||
@app.get("/admin.html", response_class=HTMLResponse)
|
||||
async def serve_admin_page(_current_user=Depends(require_admin)):
|
||||
async def serve_admin_page(request: Request, _current_user=Depends(require_admin)):
|
||||
"""Serve the admin dashboard page (ROADMAP #71) — admin-gated.
|
||||
|
||||
Must be declared BEFORE the SPA catch-all ``/{full_path:path}`` or the
|
||||
@@ -746,13 +786,13 @@ if FRONTEND_DIR.exists():
|
||||
"""
|
||||
admin_file = FRONTEND_DIR / "admin.html"
|
||||
if admin_file.exists():
|
||||
return HTMLResponse(content=admin_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
index_file = FRONTEND_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=index_file.read_text(encoding="utf-8"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
@@ -164,8 +164,10 @@ async def public_share_raw(token: str):
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(token: str):
|
||||
async def public_share_view(request: Request, token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
@@ -230,7 +232,9 @@ async def public_share_view(token: str):
|
||||
if fm_items:
|
||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
return HTMLResponse(
|
||||
inject_csp_nonce(
|
||||
f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title_esc} — ObsiGate Share</title>
|
||||
<style>
|
||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||
@@ -274,15 +278,15 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">{title_esc}</span>
|
||||
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
||||
<button class="toolbar-btn" data-share-theme title="Thème clair/sombre">
|
||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
|
||||
<button class="toolbar-btn" data-share-md title="Télécharger en Markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
.md
|
||||
</button>
|
||||
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
|
||||
<button class="toolbar-btn" data-share-pdf title="Télécharger en PDF">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
@@ -293,4 +297,10 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
|
||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||
</script></body></html>""")
|
||||
document.querySelector("[data-share-theme]").addEventListener("click",toggleTheme);
|
||||
document.querySelector("[data-share-md]").addEventListener("click",exportMD);
|
||||
document.querySelector("[data-share-pdf]").addEventListener("click",function(){{location.href=location.pathname+"/pdf"}});
|
||||
</script></body></html>""",
|
||||
request.state.csp_nonce,
|
||||
),
|
||||
)
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.2"
|
||||
version = "2.28.9"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.28.2"
|
||||
version = "2.28.9"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.28.2",
|
||||
"version": "2.28.9",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -188,6 +188,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
|
||||
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
|
||||
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
|
||||
| *BUG-082* | CI `lint` rouge : `tests/frontend/upload.test.mjs` (import statique `jsdom`) exécuté dans l'étape racine où `jsdom` n'est jamais installé | 🟢 corrigé | P0 | 🧩 tests | IA | `.gitea/workflows/ci.yml`, `tests/frontend/upload.test.mjs` | CI job `lint` → `ERR_MODULE_NOT_FOUND: jsdom` (introduit par `7bee4a2`, jamais vert depuis) | `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches) ; vérifié : étape racine verte + `upload` vert depuis `tests/frontend/` | Seul fichier de l'étape racine avec import statique jsdom ; `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer `upload.test.mjs` dans l'étape JSDOM |
|
||||
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status` (l.821-831) | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27, `e2e-server.err.log` l.116-183) | — | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
|
||||
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 🟢 corrigé | P0 | 🧩 tests | IA | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau) | `npm run test:e2e:ps` | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
|
||||
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
|
||||
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
@@ -273,6 +276,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
|
||||
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom` sur `upload.test.mjs`, rouge depuis `7bee4a2`) — seul fichier de l'étape frontend racine avec import statique `jsdom`, alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). `upload.test.mjs` déplacé dans l'étape JSDOM (les deux branches). Vérifié : étape racine verte (validate-imports, unit, pretty, media-viewer, mfa-settings, config-ai-keys) + `upload` vert depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.28.2 | **Dernière mise à jour :** 2026-09-26
|
||||
> **Version :** 2.28.9 | **Dernière mise à jour :** 2026-09-27
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -70,9 +70,9 @@
|
||||
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
|
||||
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
|
||||
- **Sous-tâches :**
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
|
||||
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
|
||||
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte)
|
||||
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||
|
||||
---
|
||||
|
||||
+1
-31
@@ -1576,17 +1576,6 @@
|
||||
class="help-search-clear"
|
||||
id="config-search-clear"
|
||||
title="Effacer"
|
||||
onclick="
|
||||
var s =
|
||||
document.getElementById(
|
||||
'config-nav-search',
|
||||
);
|
||||
if (s) {
|
||||
s.value = '';
|
||||
s.dispatchEvent(new Event('input'));
|
||||
s.focus();
|
||||
}
|
||||
"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
@@ -1698,7 +1687,7 @@
|
||||
<input type="text" id="profile-name" class="config-input" placeholder="Votre nom" maxlength="60">
|
||||
</div>
|
||||
<button class="config-save-btn" id="profile-save" data-i18n="config.save">Enregistrer</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout" onclick="if(window.handleLogout)window.handleLogout();else{doLogoutFallback()}">Déconnexion</button>
|
||||
<button class="config-save-btn" id="profile-logout" style="background:var(--danger-bg);color:var(--danger);border-color:var(--danger);margin-left:8px" data-i18n="config.logout">Déconnexion</button>
|
||||
<span class="profile-saved" id="profile-saved" style="display:none" data-i18n="config.saved">✓ Sauvegardé</span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -3005,14 +2994,6 @@
|
||||
id="help-hamburger"
|
||||
title="Sommaire"
|
||||
aria-label="Afficher le sommaire"
|
||||
onclick="
|
||||
var n = document.getElementById('help-nav');
|
||||
if (n) {
|
||||
var d = n.style.display;
|
||||
n.style.display =
|
||||
d === 'none' || d === '' ? 'flex' : 'none';
|
||||
}
|
||||
"
|
||||
>
|
||||
<i
|
||||
data-lucide="menu"
|
||||
@@ -3077,17 +3058,6 @@
|
||||
id="help-search-clear"
|
||||
title="Effacer la recherche"
|
||||
aria-label="Effacer"
|
||||
onclick="
|
||||
var s =
|
||||
document.getElementById(
|
||||
'help-nav-search',
|
||||
);
|
||||
if (s) {
|
||||
s.value = '';
|
||||
s.dispatchEvent(new Event('input'));
|
||||
s.focus();
|
||||
}
|
||||
"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
+12
-2
@@ -1305,8 +1305,7 @@ async function _startMfaSetup() {
|
||||
// secret when the backend has no QR generator available.
|
||||
const qrImg = data.qr_data_url
|
||||
? `<img id="mfa-qr-img" alt="QR Code" class="mfa-qr-code-img"
|
||||
src="${data.qr_data_url}"
|
||||
onerror="this.style.display='none';document.getElementById('mfa-qr-fallback').style.display='block';">`
|
||||
src="${data.qr_data_url}">`
|
||||
: "";
|
||||
const fallbackStyle = data.qr_data_url ? "display:none" : "";
|
||||
flowArea.innerHTML = `
|
||||
@@ -1335,6 +1334,17 @@ async function _startMfaSetup() {
|
||||
codeInput.value = codeInput.value.replace(/[^0-9]/g, "");
|
||||
});
|
||||
|
||||
// QR fallback (#87, ex-onerror inline) : si l'image ne charge pas,
|
||||
// afficher la saisie manuelle du secret.
|
||||
const qrImgEl = document.getElementById("mfa-qr-img");
|
||||
if (qrImgEl) {
|
||||
qrImgEl.addEventListener("error", () => {
|
||||
qrImgEl.style.display = "none";
|
||||
const fallback = document.getElementById("mfa-qr-fallback");
|
||||
if (fallback) fallback.style.display = "block";
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("mfa-confirm-btn").addEventListener("click", async () => {
|
||||
const code = codeInput.value.trim();
|
||||
if (code.length !== 6) return;
|
||||
|
||||
+39
-3
@@ -363,6 +363,27 @@ function initHelpModal() {
|
||||
}
|
||||
});
|
||||
|
||||
// Help TOC hamburger + search clear (#87, ex-onclick inline in index.html).
|
||||
var helpHamburger = document.getElementById("help-hamburger");
|
||||
if (helpHamburger) {
|
||||
helpHamburger.addEventListener("click", function() {
|
||||
var n = document.getElementById("help-nav");
|
||||
if (n) {
|
||||
var d = n.style.display;
|
||||
n.style.display = d === "none" || d === "" ? "flex" : "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
var helpSearch = document.getElementById("help-nav-search");
|
||||
var helpSearchClear = document.getElementById("help-search-clear");
|
||||
if (helpSearchClear && helpSearch) {
|
||||
helpSearchClear.addEventListener("click", function() {
|
||||
helpSearch.value = "";
|
||||
helpSearch.dispatchEvent(new Event("input"));
|
||||
helpSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.classList.contains("active")) {
|
||||
closeHelpModal();
|
||||
@@ -883,7 +904,7 @@ function initConfigModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
// Logout button — wired here with addEventListener (#87, no inline onclick)
|
||||
|
||||
// Config nav search
|
||||
var cfgSearch = document.getElementById("config-nav-search");
|
||||
@@ -901,6 +922,16 @@ function initConfigModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Config search clear button (#87, ex-onclick inline).
|
||||
var cfgSearchClear = document.getElementById("config-search-clear");
|
||||
if (cfgSearchClear && cfgSearch) {
|
||||
cfgSearchClear.addEventListener("click", function() {
|
||||
cfgSearch.value = "";
|
||||
cfgSearch.dispatchEvent(new Event("input"));
|
||||
cfgSearch.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
|
||||
// rule that hides it below 768px, but — unlike the help modal — the config
|
||||
// modal had no toggle to reveal it, leaving mobile users with no way to
|
||||
@@ -1575,13 +1606,15 @@ export async function openShareDialog(vault, path) {
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
${expiresInfo}
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
|
||||
<input type="text" class="share-url-input" value="${url}" readonly onclick="this.select()">
|
||||
<input type="text" class="share-url-input" value="${url}" readonly>
|
||||
<div class="share-dialog-actions">
|
||||
<button class="share-copy-btn">📋 Copier le lien</button>
|
||||
<button class="share-revoke-btn">🗑 Révoquer</button>
|
||||
<button class="share-close-btn">Fermer</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const shareUrlInput = div.querySelector(".share-url-input");
|
||||
if (shareUrlInput) shareUrlInput.addEventListener("click", function() { shareUrlInput.select(); });
|
||||
div.querySelector(".share-copy-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
@@ -2525,7 +2558,10 @@ function initProfile() {
|
||||
} catch(e) {}
|
||||
});
|
||||
|
||||
// Logout button — handled inline in index.html (onclick)
|
||||
// Logout button (#87, ex-onclick inline in index.html).
|
||||
if (logoutBtn && window.handleLogout) {
|
||||
logoutBtn.addEventListener('click', function() { window.handleLogout(); });
|
||||
}
|
||||
|
||||
// ── Avatar (#113) ────────────────────────────────────────────────
|
||||
var avatarField = document.getElementById('profile-avatar-field');
|
||||
|
||||
@@ -35,7 +35,7 @@ export function renderExcalidraw(container, data, vaultName, filePath, opts = {}
|
||||
// Build the iframe
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.id = editorId;
|
||||
iframe.src = '/static/excalidraw-editor.html?v=' + Date.now();
|
||||
iframe.src = '/excalidraw-editor.html?v=' + Date.now();
|
||||
iframe.sandbox.add('allow-scripts');
|
||||
iframe.sandbox.add('allow-same-origin');
|
||||
// Let the editor's own Fullscreen button work (native Fullscreen API inside
|
||||
|
||||
@@ -478,8 +478,8 @@ function openTemplateModal() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
|
||||
'<button class="btn btn-secondary" onclick="copyTemplate()">Copy to Clipboard</button>' +
|
||||
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
|
||||
'<button class="btn btn-secondary btn-copy-template">Copy to Clipboard</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
@@ -487,11 +487,11 @@ function openTemplateModal() {
|
||||
|
||||
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
window.copyTemplate = () => {
|
||||
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.querySelector('.btn-copy-template')?.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(template, null, 2));
|
||||
showToast('Template copied to clipboard', 'success');
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -508,12 +508,13 @@ function openCodeModal(name, code) {
|
||||
'<pre class="code-block" style="max-height: 500px; overflow: auto;">' + escapeHtml(code) + '</pre>' +
|
||||
'</div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button class="btn btn-primary" data-i18n="common.close" onclick="this.closest(\'.modal-overlay\').remove()">Close</button>' +
|
||||
'<button class="btn btn-primary btn-modal-close" data-i18n="common.close">Close</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.querySelector('.modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.querySelector('.btn-modal-close')?.addEventListener('click', () => modal.remove());
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -543,10 +543,16 @@ function showUpdateNotification() {
|
||||
message.innerHTML = `
|
||||
<div class="pwa-update-content">
|
||||
<span>Une nouvelle version d'ObsiGate est disponible !</span>
|
||||
<button class="pwa-update-btn" onclick="window.location.reload()">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
<button class="pwa-update-btn">Mettre à jour</button>
|
||||
<button class="pwa-update-dismiss">×</button>
|
||||
</div>
|
||||
`;
|
||||
message.querySelector(".pwa-update-btn").addEventListener("click", function() {
|
||||
window.location.reload();
|
||||
});
|
||||
message.querySelector(".pwa-update-dismiss").addEventListener("click", function() {
|
||||
message.remove();
|
||||
});
|
||||
document.body.appendChild(message);
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
|
||||
@@ -1140,10 +1140,10 @@ export function renderFile(data) {
|
||||
<div class="pdf-viewer-container">
|
||||
<div class="pdf-toolbar">
|
||||
<span class="pdf-info">PDF — ${pages} pages</span>
|
||||
<button class="btn-action" onclick="window.open('${pdfUrl}', '_blank')">
|
||||
<button class="btn-action" data-pdf-url="${pdfUrl}">
|
||||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Plein écran
|
||||
</button>
|
||||
<button class="btn-action" onclick="window.open('/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}', '_blank')">
|
||||
<button class="btn-action" data-download-url="/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
</button>
|
||||
</div>
|
||||
@@ -1152,6 +1152,11 @@ export function renderFile(data) {
|
||||
<iframe src="${pdfUrl}" data-pdf-url="${pdfUrl}" class="pdf-iframe" title="${escapeHtml(data.title)}"></iframe>
|
||||
</div>
|
||||
</div>`;
|
||||
area.querySelectorAll('.pdf-toolbar .btn-action').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(btn.dataset.pdfUrl || btn.dataset.downloadUrl, '_blank');
|
||||
});
|
||||
});
|
||||
area.querySelectorAll('.pdf-toc a[data-page]').forEach((link) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.28.2",
|
||||
"version": "2.28.9",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -9,6 +9,14 @@ export default defineConfig({
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
timeout: 60000,
|
||||
expect: { timeout: 10000 },
|
||||
// BUG-080 : la suite (~130 tests, workers: 1) ne doit jamais pendre toute
|
||||
// la nuit. Au-delà du timeout global, Playwright abandonne avec un échec
|
||||
// explicite au lieu de bloquer. Surchargable : E2E_GLOBAL_TIMEOUT_MS.
|
||||
globalTimeout: Number(
|
||||
process.env.E2E_GLOBAL_TIMEOUT_MS ??
|
||||
(process.env.CI ? 30 * 60 * 1000 : 15 * 60 * 1000),
|
||||
),
|
||||
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:2029',
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
ObsiGate — cycle de vie du serveur E2E local, avec progression visible.
|
||||
|
||||
.DESCRIPTION
|
||||
Remplace le one-liner opaque de démarrage : chaque étape affiche sa
|
||||
progression (port, PID, attente du health check seconde par seconde,
|
||||
version servie). Memes conditions que le job CI `e2e` et que
|
||||
`scripts/run-e2e-local.ps1` : uvicorn natif, auth désactivée, fixtures
|
||||
TestVault/TestDir, port 2029.
|
||||
|
||||
Le PID est persisté dans `data/e2e-server.pid` pour un arrêt propre
|
||||
(`stop`) — plus de serveurs orphelins qui squattent le port.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/e2e-server.ps1 start # démarre + attend READY (défaut)
|
||||
./scripts/e2e-server.ps1 status # port, PID, version servie
|
||||
./scripts/e2e-server.ps1 logs # queues des logs serveur
|
||||
./scripts/e2e-server.ps1 stop # arrête le serveur + libère le port
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[ValidateSet("start", "stop", "status", "logs")]
|
||||
[string]$Command = "start",
|
||||
|
||||
[string]$Port = $(if ($env:E2E_PORT) { $env:E2E_PORT } else { "2029" })
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $Root
|
||||
|
||||
$BaseUrl = "http://127.0.0.1:$Port"
|
||||
$Python = ".\.venv\Scripts\python.exe"
|
||||
$PidFile = "data/e2e-server.pid"
|
||||
$OutLog = "data/e2e-server.log"
|
||||
$ErrLog = "data/e2e-server.err.log"
|
||||
|
||||
function Get-PortOwner {
|
||||
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if (-not $conn) { return $null }
|
||||
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue
|
||||
return @{ Pid = $conn.OwningProcess; Name = $(if ($proc) { $proc.ProcessName } else { "?" }) }
|
||||
}
|
||||
|
||||
function Stop-Server {
|
||||
param([string]$Why = "")
|
||||
$killed = @()
|
||||
if (Test-Path -LiteralPath $PidFile) {
|
||||
$srvPid = (Get-Content -LiteralPath $PidFile -TotalCount 1).Trim()
|
||||
if ($srvPid -match '^\d+$') {
|
||||
# BUG-080 : le PID enregistré peut avoir ré-exécuté uvicorn dans un
|
||||
# processus enfant (constaté : parent .venv + enfant uv-python sur
|
||||
# le port) — tuer l'arbre complet, pas seulement la racine.
|
||||
Get-CimInstance Win32_Process -Filter "ParentProcessId=$srvPid" -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $killed += $_.ProcessId }
|
||||
Stop-Process -Id $srvPid -Force -ErrorAction SilentlyContinue
|
||||
$killed += $srvPid
|
||||
}
|
||||
Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) {
|
||||
Stop-Process -Id $owner.Pid -Force -ErrorAction SilentlyContinue
|
||||
$killed += $owner.Pid
|
||||
}
|
||||
if ($killed.Count) { Write-Host "[OK] Serveur arrêté (PID $($killed -join ', ')). $Why" }
|
||||
else { Write-Host "[OK] Aucun serveur en cours (port $Port libre)." }
|
||||
}
|
||||
|
||||
switch ($Command) {
|
||||
"stop" {
|
||||
Write-Host "[1/1] Arrêt du serveur E2E (port $Port)..."
|
||||
Stop-Server
|
||||
}
|
||||
|
||||
"status" {
|
||||
$owner = Get-PortOwner
|
||||
if (-not $owner) { Write-Host "[INFO] Port $Port libre, aucun serveur."; break }
|
||||
Write-Host "[INFO] Port $Port occupé par PID $($owner.Pid) ($($owner.Name))."
|
||||
try {
|
||||
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
|
||||
Select-Object -ExpandProperty Content | ConvertFrom-Json
|
||||
Write-Host "[OK] Health 200 — version $($health.version), $($health.total_files) fichiers indexés."
|
||||
} catch {
|
||||
Write-Host "[WARN] Processus présent mais health injoignable : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
"logs" {
|
||||
Write-Host "===== $OutLog (stdout) ====="
|
||||
Get-Content -LiteralPath $OutLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
Write-Host "===== $ErrLog (stderr) ====="
|
||||
Get-Content -LiteralPath $ErrLog -Tail 25 -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
"start" {
|
||||
Write-Host "[1/4] Port $Port..."
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) {
|
||||
Write-Host "[ERR] Port $Port déjà occupé par PID $($owner.Pid) ($($owner.Name))."
|
||||
Write-Host " Lancez d'abord : ./scripts/e2e-server.ps1 stop"
|
||||
exit 1
|
||||
}
|
||||
Write-Host " libre."
|
||||
|
||||
Write-Host "[2/4] Interpréteur $Python..."
|
||||
if (-not (Test-Path -LiteralPath $Python)) {
|
||||
Write-Host "[ERR] $Python introuvable. Créez le venv (voir AGENTS.md)."
|
||||
exit 1
|
||||
}
|
||||
Write-Host " présent."
|
||||
New-Item -ItemType Directory -Force -Path "data" | Out-Null
|
||||
|
||||
Write-Host "[3/4] Démarrage uvicorn (auth désactivée, TestVault/TestDir)..."
|
||||
$env:OBSIGATE_AUTH_ENABLED = "false"
|
||||
$env:VAULT_1_NAME = "TestVault"
|
||||
$env:VAULT_1_PATH = (Resolve-Path -LiteralPath "test_vault").Path
|
||||
$env:DIR_1_NAME = "TestDir"
|
||||
$env:DIR_1_PATH = (Resolve-Path -LiteralPath "test_dir").Path
|
||||
$server = Start-Process -FilePath $Python `
|
||||
-ArgumentList "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", "--port", $Port `
|
||||
-RedirectStandardOutput $OutLog -RedirectStandardError $ErrLog `
|
||||
-PassThru -WindowStyle Hidden
|
||||
$server.Id | Set-Content -LiteralPath $PidFile
|
||||
Write-Host " PID $($server.Id) (logs : $OutLog / $ErrLog)."
|
||||
|
||||
Write-Host "[4/4] Attente du health check (30 s max)..."
|
||||
$ready = $false
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 2 -UseBasicParsing | Out-Null
|
||||
$ready = $true
|
||||
break
|
||||
} catch {
|
||||
if ($server.HasExited) {
|
||||
Write-Host "[ERR] Le serveur a quitté (code $($server.ExitCode)). Fin du log :"
|
||||
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
if ($i % 5 -eq 0) { Write-Host " ... $i/30 s (indexation en cours, voir $ErrLog)" }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
if (-not $ready) {
|
||||
Write-Host "[ERR] Injoignable après 30 s. Fin du log :"
|
||||
Get-Content -LiteralPath $ErrLog -Tail 15 -ErrorAction SilentlyContinue
|
||||
exit 1
|
||||
}
|
||||
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
|
||||
Select-Object -ExpandProperty Content | ConvertFrom-Json
|
||||
# BUG-080 : le PID `Start-Process` peut ne pas être celui qui écoute
|
||||
# (ré-exécution enfant constatée) — persister le vrai propriétaire du
|
||||
# port pour un `stop` fiable, sans serveurs orphelins.
|
||||
$owner = Get-PortOwner
|
||||
if ($owner) { $owner.Pid | Set-Content -LiteralPath $PidFile }
|
||||
Write-Host "[OK] READY — ObsiGate v$($health.version) sur $BaseUrl ($($health.total_files) fichiers)."
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,35 @@ $BaseUrl = "http://127.0.0.1:$Port"
|
||||
$ServerLog = "data/e2e-server.log"
|
||||
$ServerErrLog = "data/e2e-server.err.log"
|
||||
|
||||
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
|
||||
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 900 }
|
||||
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
|
||||
|
||||
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Args) {
|
||||
# Lance un processus natif en gardant la sortie console en direct, et le
|
||||
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
|
||||
$stamp = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
|
||||
$proc = Start-Process -FilePath $Exe -ArgumentList $Args -NoNewWindow -PassThru
|
||||
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
|
||||
if (-not $proc.HasExited) {
|
||||
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
return 124
|
||||
}
|
||||
return $proc.ExitCode
|
||||
}
|
||||
|
||||
function Test-ChromiumInstalled {
|
||||
$base = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright"
|
||||
if (-not (Test-Path -LiteralPath $base)) { return $false }
|
||||
$hit = Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "chromium-*" } |
|
||||
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "chrome-win\chrome.exe") } |
|
||||
Select-Object -First 1
|
||||
return ($null -ne $hit)
|
||||
}
|
||||
|
||||
function Assert-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
throw "[ERR] $Name introuvable. $Hint"
|
||||
@@ -102,13 +131,20 @@ try {
|
||||
Write-Host "[OK] Serveur prêt."
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
npx playwright install chromium
|
||||
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
|
||||
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
|
||||
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
|
||||
$code = Invoke-NativeWithTimeout "npx playwright install chromium" $BrowserTimeoutSec "npx" @("--yes", "playwright", "install", "chromium")
|
||||
if ($code -ne 0) { exit $code }
|
||||
} else {
|
||||
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
|
||||
}
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
|
||||
$env:BASE_URL = $BaseUrl
|
||||
& npx playwright test --project=chromium-desktop @PlaywrightArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
$testArgs = @("--yes", "playwright", "test", "--project=chromium-desktop") + @($PlaywrightArgs)
|
||||
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "npx" $testArgs
|
||||
} finally {
|
||||
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
|
||||
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
@@ -23,6 +23,21 @@ cd "$(dirname "$0")/.."
|
||||
PORT="${E2E_PORT:-2029}"
|
||||
BASE_URL="http://127.0.0.1:$PORT"
|
||||
SERVER_LOG="data/e2e-server.log"
|
||||
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
|
||||
E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-900}"
|
||||
E2E_BROWSER_INSTALL_TIMEOUT_SEC="${E2E_BROWSER_INSTALL_TIMEOUT_SEC:-600}"
|
||||
|
||||
# Exécute "$@" avec un timeout dur (exit 124 comme `timeout`), sans timeout si
|
||||
# la commande `timeout` est absente (ex. macOS sans coreutils).
|
||||
run_with_timeout() {
|
||||
local limit="$1"; shift
|
||||
if command -v timeout &>/dev/null; then
|
||||
timeout "$limit" "$@"
|
||||
else
|
||||
echo "[WARN] commande 'timeout' absente : $1 sans limite de ${limit}s" >&2
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----- Prérequis -----
|
||||
if ! command -v uv &>/dev/null; then
|
||||
@@ -102,8 +117,14 @@ curl -sf "$BASE_URL/api/health" >/dev/null || {
|
||||
}
|
||||
|
||||
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
|
||||
npx playwright install chromium
|
||||
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
|
||||
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
|
||||
if [[ "${E2E_INSTALL_BROWSERS:-0}" == "1" ]] || ! ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome &>/dev/null; then
|
||||
run_with_timeout "$E2E_BROWSER_INSTALL_TIMEOUT_SEC" npx --yes playwright install chromium
|
||||
else
|
||||
echo "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
|
||||
fi
|
||||
|
||||
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
|
||||
echo "[INFO] BASE_URL=$BASE_URL npx playwright test --project=chromium-desktop $*"
|
||||
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop "$@"
|
||||
BASE_URL="$BASE_URL" run_with_timeout "$E2E_TIMEOUT_SEC" npx --yes playwright test --project=chromium-desktop "$@"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* E2E tests — XSS stocké : page publique de partage + lecteur markdown (#87 T4).
|
||||
*
|
||||
* Non-régression BUG-021 (sanitizer serveur du rendu markdown) et BUG-022
|
||||
* (échappement `title`/frontmatter + neutralisation `</script>` sur `/s/{token}`).
|
||||
* Chaque test arme un guetteur de dialogues `alert` : le moindre JS exécuté
|
||||
* fait échouer le test, en plus des assertions DOM (contenu échappé, aucun
|
||||
* attribut `on*` vivant).
|
||||
*
|
||||
* Run (local, instance de test port 2029, auth désactivée — cf. scripts/run-e2e-local.ps1) :
|
||||
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/xss.spec.js --project=chromium-desktop
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
const VAULT = 'TestVault';
|
||||
const XSS_FILE = 'e2e-xss-probe.md';
|
||||
const XSS_TITLE = '<img src=x onerror="window.__xss_title=1">';
|
||||
const XSS_BODY = [
|
||||
'# Sonde XSS',
|
||||
'',
|
||||
'<img src=x onerror="window.__xss_body=1">',
|
||||
'',
|
||||
'<script>window.__xss_script=1</script>',
|
||||
'',
|
||||
'[xss](javascript:window.__xss_js=1)',
|
||||
].join('\n');
|
||||
|
||||
async function api(request, method, path, data) {
|
||||
const resp = await request.fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!resp.ok()) {
|
||||
throw new Error(`${method} ${path} → ${resp.status()} ${await resp.text()}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function precleanProbeFile(request) {
|
||||
// Idempotence : un run précédent interrompu a pu laisser le fichier sonde.
|
||||
await request.fetch(`${BASE}/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function armAlertTrap(page) {
|
||||
const dialogs = [];
|
||||
page.on('dialog', async (d) => {
|
||||
dialogs.push(d.message());
|
||||
await d.dismiss();
|
||||
});
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
|
||||
if (!(await treeItem.count())) {
|
||||
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
|
||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test.describe('XSS — page publique de partage (/s/{token}, BUG-022)', () => {
|
||||
test('un titre/frontmatter hostile est échappé et aucun script ne tourne', async ({ page, request }) => {
|
||||
const dialogs = await armAlertTrap(page);
|
||||
|
||||
await precleanProbeFile(request);
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, {
|
||||
path: XSS_FILE,
|
||||
// Titre entre quotes simples YAML (les doubles quotes internes restent
|
||||
// des caractères ordinaires et arrivent intactes au backend).
|
||||
content: `---\ntitle: '${XSS_TITLE}'\n---\n\n${XSS_BODY}\n`,
|
||||
});
|
||||
const share = await api(request, 'POST', `/api/share/${VAULT}`, { path: XSS_FILE });
|
||||
|
||||
await page.goto(`${BASE}/s/${share.token}`);
|
||||
await expect(page.locator('.share-banner')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Le titre affiché est le texte brut (balise neutralisée), pas un <img> vivant.
|
||||
await expect(page.locator('.toolbar-title')).toContainText('<img src=x', { timeout: 5000 });
|
||||
expect(await page.locator('.toolbar-title img').count()).toBe(0);
|
||||
expect(await page.locator('img[onerror]').count()).toBe(0);
|
||||
// Les 2 <script> de la page sont son code statique : le JSON embarqué
|
||||
// (`#raw-content`) doit être neutralisé (aucun `</script>` littéral).
|
||||
const rawEmbedded = await page.evaluate(() => {
|
||||
const el = document.getElementById('raw-content');
|
||||
return { text: el ? el.textContent : null };
|
||||
});
|
||||
expect(rawEmbedded.text).not.toBeNull();
|
||||
expect(rawEmbedded.text).not.toContain('</script');
|
||||
expect(rawEmbedded.text).toContain('\\u003c');
|
||||
|
||||
// Aucun payload n'a tourné (titre, corps, bloc script, lien javascript:).
|
||||
const flags = await page.evaluate(() => ({
|
||||
title: window.__xss_title,
|
||||
body: window.__xss_body,
|
||||
script: window.__xss_script,
|
||||
js: window.__xss_js,
|
||||
}));
|
||||
expect(flags).toEqual({ title: undefined, body: undefined, script: undefined, js: undefined });
|
||||
expect(dialogs).toEqual([]);
|
||||
|
||||
await api(request, 'DELETE', `/api/share/${share.id}`);
|
||||
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('XSS — lecteur markdown (BUG-021)', () => {
|
||||
test('le HTML injecté dans une note est neutralisé à l\'affichage', async ({ page, request }) => {
|
||||
const dialogs = await armAlertTrap(page);
|
||||
|
||||
await precleanProbeFile(request);
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
|
||||
|
||||
await page.goto(BASE);
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await openFile(page, VAULT, XSS_FILE);
|
||||
|
||||
const content = page.locator('#content-area');
|
||||
await expect(content).toContainText('Sonde XSS', { timeout: 10000 });
|
||||
|
||||
// Le sanitizer serveur a retiré les vecteurs : pas d'onerror, pas de script,
|
||||
// pas de lien javascript: exécutable dans la zone de lecture.
|
||||
expect(await content.locator('img[onerror]').count()).toBe(0);
|
||||
expect(await content.locator('script').count()).toBe(0);
|
||||
expect(await content.locator('a[href^="javascript:"]').count()).toBe(0);
|
||||
|
||||
const flags = await page.evaluate(() => ({
|
||||
body: window.__xss_body,
|
||||
script: window.__xss_script,
|
||||
js: window.__xss_js,
|
||||
}));
|
||||
expect(flags).toEqual({ body: undefined, script: undefined, js: undefined });
|
||||
expect(dialogs).toEqual([]);
|
||||
|
||||
await api(request, 'DELETE', `/api/file/${VAULT}?path=${encodeURIComponent(XSS_FILE)}`);
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ await test("module exports renderExcalidraw + helpers", () => {
|
||||
assert.equal(typeof destroyExcalidrawEditor, "function");
|
||||
});
|
||||
|
||||
await test("renderExcalidraw creates an iframe with sandbox + static src", () => {
|
||||
await test("renderExcalidraw creates an iframe with sandbox + routed src", () => {
|
||||
const container = document.getElementById("content-area");
|
||||
const data = {
|
||||
is_excalidraw: true,
|
||||
@@ -111,7 +111,8 @@ await test("renderExcalidraw creates an iframe with sandbox + static src", () =>
|
||||
renderExcalidraw(container, data, "TestVault", "diagram.excalidraw");
|
||||
const iframe = container.querySelector("iframe");
|
||||
assert.ok(iframe, "iframe should be created");
|
||||
assert.ok(iframe.src.includes("/static/excalidraw-editor.html"), `src: ${iframe.src}`);
|
||||
assert.ok(iframe.src.includes("/excalidraw-editor.html"), `src: ${iframe.src}`);
|
||||
assert.ok(!iframe.src.includes("/static/excalidraw-editor.html"), `route avec nonce CSP: ${iframe.src}`);
|
||||
assert.ok(iframe.sandbox.contains("allow-scripts"), "sandbox allow-scripts");
|
||||
assert.ok(iframe.sandbox.contains("allow-same-origin"), "sandbox allow-same-origin");
|
||||
assert.match(iframe.style.cssText, /100%/);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests — nonces CSP (ROADMAP #87 T5b).
|
||||
|
||||
- `inject_csp_nonce` ne touche que les scripts inline exécutables
|
||||
(`<script>`, `type="module"` / `type="importmap"` sans `src`), jamais les
|
||||
blocs de données (`type="text/plain"`) ni les scripts externes.
|
||||
- Chaque page HTML servie avec des scripts inline les porte tous avec un
|
||||
nonce après injection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NONCE = "TESTNONCE1234567890"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (ROOT / "frontend" / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_inject_only_bare_executable_scripts():
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
html = (
|
||||
"<script>var a = 1;</script>"
|
||||
'<script type="module">import x from "y";</script>'
|
||||
'<script type="importmap">{"imports": {}}</script>'
|
||||
'<script type="module" src="/static/js/app.js"></script>'
|
||||
'<script src="https://cdnjs.cloudflare.com/x.js"></script>'
|
||||
'<script id="raw-content" type="text/plain">hello</script>'
|
||||
'<script nonce="OLD">var b = 2;</script>'
|
||||
)
|
||||
out = inject_csp_nonce(html, NONCE)
|
||||
assert out.count(f'nonce="{NONCE}"') == 3
|
||||
assert '<script src="https://cdnjs.cloudflare.com/x.js">' in out
|
||||
assert '<script id="raw-content" type="text/plain">' in out
|
||||
assert '<script nonce="OLD">' in out
|
||||
|
||||
|
||||
def test_new_nonce_unique_per_call():
|
||||
from backend.csp import new_nonce
|
||||
|
||||
assert new_nonce() != new_nonce()
|
||||
|
||||
|
||||
def test_all_pages_fully_nonced():
|
||||
"""Aucun script inline exécutable sans nonce après injection (sauf src=)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
for name in ("index.html", "popout.html", "admin.html", "editor-poc.html", "excalidraw-editor.html"):
|
||||
out = inject_csp_nonce(_read(name), NONCE)
|
||||
bare = re.findall(r"<script>", out)
|
||||
assert not bare, f"{name} : scripts sans nonce restants"
|
||||
inline_mods = [m for m in re.findall(r'<script type="(?:module|importmap)">', out)]
|
||||
assert not inline_mods, f"{name} : modules/importmap sans nonce restants"
|
||||
|
||||
|
||||
def _nonce_of(csp: str) -> str | None:
|
||||
m = re.search(r"'nonce-([^']+)'", csp or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def test_nonce_header_fresh_per_response(client):
|
||||
"""Chaque réponse porte un nonce frais dans `script-src`."""
|
||||
r1 = client.get("/")
|
||||
r2 = client.get("/")
|
||||
n1, n2 = _nonce_of(r1.headers.get("content-security-policy")), _nonce_of(
|
||||
r2.headers.get("content-security-policy")
|
||||
)
|
||||
assert n1 and n2 and n1 != n2
|
||||
|
||||
|
||||
def test_nonce_matches_injected_html(client):
|
||||
"""Le nonce de l'en-tête est celui injecté dans le HTML (`/`, excalidraw)."""
|
||||
for path in ("/", "/excalidraw-editor.html"):
|
||||
resp = client.get(path)
|
||||
assert resp.status_code == 200, path
|
||||
nonce = _nonce_of(resp.headers.get("content-security-policy"))
|
||||
assert nonce, path
|
||||
assert f'nonce="{nonce}"' in resp.text, path
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Garde-fous anti-blocage du harnais E2E local (BUG-080).
|
||||
|
||||
Contexte : un run `npm run test:e2e:ps` est resté pendu toute la nuit —
|
||||
serveurs orphelins sur le port 2029, `npx` sans `--yes` (prompt interactif
|
||||
qui attend indéfiniment), installation des navigateurs systématique et suite
|
||||
Playwright (~130 tests, workers: 1) sans aucun timeout global.
|
||||
|
||||
Ces tests statiques vérifient que chaque couche du harnais possède son
|
||||
garde-fou, afin qu'un run E2E échoue vite au lieu de bloquer indéfiniment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestE2ELocalPs:
|
||||
SCRIPT = "scripts/run-e2e-local.ps1"
|
||||
|
||||
def test_npx_never_prompts(self):
|
||||
"""`npx --yes` partout : aucun prompt « Ok to proceed? » qui pend."""
|
||||
content = _read(self.SCRIPT)
|
||||
# Aucune invocation nue `npx ...` / `& npx ...` (toujours via le helper
|
||||
# avec `--yes`) ; les mentions dans commentaires/Write-Host sont OK.
|
||||
bare = [
|
||||
line.strip()
|
||||
for line in content.splitlines()
|
||||
if re.match(r"^\s*(?:&\s*)?npx\s", line)
|
||||
]
|
||||
assert not bare, f"invocations npx nues (sans --yes) : {bare}"
|
||||
assert content.count("--yes") >= 2, "au moins install + test en --yes"
|
||||
|
||||
def test_browser_install_skippable(self):
|
||||
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "Test-ChromiumInstalled" in content
|
||||
assert "E2E_INSTALL_BROWSERS" in content
|
||||
|
||||
def test_test_step_has_timeout(self):
|
||||
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 900)."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "E2E_TIMEOUT_SEC" in content
|
||||
assert "Wait-Process -Timeout" in content
|
||||
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*900\s*\}", content), (
|
||||
"défaut E2E_TIMEOUT_SEC=900 attendu"
|
||||
)
|
||||
|
||||
|
||||
class TestE2ELocalSh:
|
||||
SCRIPT = "scripts/run-e2e-local.sh"
|
||||
|
||||
def test_npx_never_prompts(self):
|
||||
content = _read(self.SCRIPT)
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#") or stripped.startswith("echo") or "npx" not in stripped:
|
||||
continue
|
||||
if "playwright" in stripped:
|
||||
assert "--yes" in stripped, f"appel npx sans --yes : {stripped}"
|
||||
|
||||
def test_test_step_has_timeout(self):
|
||||
content = _read(self.SCRIPT)
|
||||
assert "E2E_TIMEOUT_SEC" in content
|
||||
assert "run_with_timeout" in content
|
||||
|
||||
|
||||
class TestE2EServerPs:
|
||||
SCRIPT = "scripts/e2e-server.ps1"
|
||||
|
||||
def test_pidfile_refreshed_with_port_owner(self):
|
||||
"""Le pidfile est resynchronisé sur le vrai PID d'écoute après READY."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "Get-PortOwner" in content
|
||||
ready_pos = content.find("[OK] READY")
|
||||
assert ready_pos != -1
|
||||
assert "Set-Content -LiteralPath $PidFile" in content[ready_pos - 600:ready_pos]
|
||||
|
||||
def test_stop_kills_process_tree(self):
|
||||
"""`stop` tue aussi les enfants du PID enregistré (pas d'orphelins)."""
|
||||
content = _read(self.SCRIPT)
|
||||
assert "ParentProcessId=$srvPid" in content
|
||||
|
||||
|
||||
class TestPlaywrightConfig:
|
||||
CONFIG = "playwright.config.ts"
|
||||
|
||||
def test_global_timeout_set(self):
|
||||
"""Timeout global : la suite abandonne au lieu de pendre toute la nuit."""
|
||||
content = _read(self.CONFIG)
|
||||
assert "globalTimeout" in content
|
||||
assert "E2E_GLOBAL_TIMEOUT_MS" in content
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
|
||||
|
||||
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
|
||||
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
|
||||
`Secure` en clair).
|
||||
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
|
||||
navigateurs appliquent le same-origin par défaut (politique explicite par
|
||||
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_secure_cookies_default_false(monkeypatch):
|
||||
"""Défaut `false` (logins HTTP locaux préservés)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
|
||||
assert is_secure_cookies() is False
|
||||
|
||||
|
||||
def test_secure_cookies_opt_in(monkeypatch):
|
||||
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
|
||||
from backend.auth.router import is_secure_cookies
|
||||
|
||||
for value in ("true", "True", "TRUE", "1", "yes"):
|
||||
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
|
||||
assert is_secure_cookies() is (value.lower() == "true")
|
||||
|
||||
|
||||
def test_no_cors_headers_on_api(client):
|
||||
"""Pas de `Access-Control-Allow-Origin` : same-origin imposé par le navigateur."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_no_cors_headers_on_public_share(client):
|
||||
"""Idem sur la page publique de partage."""
|
||||
resp = client.get("/s/jeton-inexistant")
|
||||
assert resp.status_code == 404
|
||||
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
|
||||
|
||||
|
||||
def test_security_headers_present(client):
|
||||
"""En-têtes de durcissement posés par le middleware (non-régression)."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||
assert resp.headers.get("x-frame-options") == "SAMEORIGIN"
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert "object-src 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
Reference in New Issue
Block a user