Compare commits

...
3 Commits
Author SHA1 Message Date
bruno 8ad8eaac71 test: spec E2E mobile pour la page Configurations BUG-071
CI / lint (push) Successful in 1m54s
CI / security (push) Successful in 1m21s
CI / test (push) Successful in 3m58s
CI / build (push) Failing after 1m22s
CI / e2e (push) Skipped
2026-09-22 22:03:42 -04:00
bruno dd9224e685 fix: page Configurations inutilisable en mode mobile BUG-071
CI / lint (push) Successful in 1m57s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m20s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 11m51s
2026-09-22 21:24:56 -04:00
bruno aeb7516445 fix: activation WebAuthn impossible BUG-070 (rp_id/origines derives requete, challenges multiples)
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m7s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m12s
2026-09-22 20:54:01 -04:00
23 changed files with 766 additions and 68 deletions
+4 -1
View File
@@ -51,7 +51,10 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_PDF_MAX_SIZE_MB=50 # PDFs plus volumineux = texte non indexé
# OBSIGATE_PDF_EXTRACT_TIMEOUT=30 # secondes avant abandon de l'extraction
# WebAuthn / MFA (ROADMAP #64) — nécessaire hors localhost
# WebAuthn / MFA (ROADMAP #64) — par défaut rp_id/origines sont dérivés de la
# requête (hôte exact, port inclus) : rien à configurer en accès direct.
# À renseigner uniquement pour un accès via reverse-proxy sous un autre nom
# (avec OBSIGATE_TRUST_PROXY=true pour X-Forwarded-Host/Proto) :
# OBSIGATE_WEBAUTHN_RP_ID=obsigate.example.com
# OBSIGATE_WEBAUTHN_RP_NAME=ObsiGate
# OBSIGATE_WEBAUTHN_ORIGINS=https://obsigate.example.com
+1
View File
@@ -40,6 +40,7 @@ jobs:
node tests/frontend/unit.test.mjs
node tests/frontend/pdf-viewer.test.mjs
node tests/frontend/forge-completion.test.mjs
node tests/frontend/config-mobile.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
run: |
+63 -1
View File
@@ -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.16.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.16.5**.
---
@@ -14,6 +14,68 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.16.5] — 2026-09-22
### Corrigé
- **BUG-071 (complément) - spec E2E mobile de la page Configurations** :
`tests/e2e/config-mobile.spec.js` (nouveau, projet `chromium-mobile`,
ignoré en `chromium-desktop` comme `mobile-editor.spec.js`) : le hamburger
révèle le sommaire, le choix d'une section y défile + lien actif + repli
auto, aucun débordement horizontal à 393px. Vérifié en local contre
l'instance de test (port 2029, auth désactivée) : 3/3.
---
## [2.16.4] — 2026-09-22
### Corrigé
- **BUG-071 - Page « Configurations » inutilisable en mode mobile** : trois
causes. (1) Le sommaire (`#config-nav`) partageait la règle `.help-nav`
qui le masque sous 768px, mais — contrairement au Guide — la modale
n'avait aucun bouton pour l'afficher : aucun moyen d'atteindre une section.
Nouvel hamburger `#config-hamburger` dans l'en-tête (même traitement
`.help-hamburger` que le Guide, libellé traduit `config.toc_toggle`
FR/EN). (2) Les liens du sommaire étaient des ancres brutes sans JS :
`config.js` les intercepte désormais (défilement doux vers la section dans
la modale, lien actif, repli automatique du sommaire sur mobile, réinit à
l'ouverture). (3) Les grilles 2 colonnes (fournisseur/modèle IA, clé/modèle
par fournisseur), les rangées d'ajout à largeurs fixes (jetons, webhooks)
et les lignes webhook/jeton/partage en flex une ligne débordaient en
360px : bloc CSS mobile scopé `#config-modal` (1 colonne, wrap, largeurs
inline neutralisées, cibles tactiles 44px, sommaire plafonné à 46vh).
`data-i18n-attr` accepte désormais plusieurs paires `attr:clé` séparées
par `;` (titre + aria-label traduits). Tests :
`tests/frontend/config-mobile.test.mjs` (nouveau, 11 — hamburger, i18n,
câblage JS, CSS mobile, garde-fou ancres mortes façon BUG-067),
enregistré dans le CI.
---
## [2.16.3] — 2026-09-22
### Corrigé
- **BUG-070 - Activation clé physique WebAuthn impossible (« Validation du
credential WebAuthn échouée »)** : deux causes. (1) Les valeurs par défaut
(`rp_id localhost`, origines `http://localhost` sans port) rejetaient toute
URL réelle — logs : `Unexpected client data origin "http://localhost:2020",
expected one of ['http://localhost']`. `rp_id`/origines sont désormais
dérivés de la requête (hôte exact, port inclus ; `X-Forwarded-Host/Proto`
si `OBSIGATE_TRUST_PROXY=true`), la config explicite restant prioritaire
(`backend/auth/webauthn_mfa.py::resolve_relying_party`, appliqué aux 4
endpoints d'enregistrement et de login). (2) Challenge à usage unique
fragile au double-clic/retry (`challenge was not expected`) : les 5
derniers challenges sont conservés et la vérification accepte le challenge
correspondant à la cérémonie en cours. `.env.example` documente le nouveau
comportement. Vérifié au navigateur avec authentificateur virtuel
(Playwright CDP, instance Docker) : enregistrement 200 + clé listée, puis
clé de test retirée. Tests : `tests/test_webauthn.py` (+8 : résolution RP,
forwarded, retry, roundtrip sans config).
---
## [2.16.2] — 2026-09-22
### Corrigé
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.16.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.16.5-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -927,8 +927,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.16.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.16.5).
---
*Projet : ObsiGate | Version : 2.16.2 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.16.5 | Dernière mise à jour : Juin 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.16.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.16.5-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1096,8 +1096,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.16.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.16.5).
---
*Project: ObsiGate | Version: 2.16.2 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.16.5 | Last updated: May 2026*
+1 -1
View File
@@ -1 +1 @@
2.16.2
2.16.5
+24 -10
View File
@@ -576,18 +576,25 @@ class WebauthnRemoveRequest(BaseModel):
@router.post("/mfa/webauthn/register/options")
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
async def mfa_webauthn_register_options(request: Request,
current_user=Depends(require_auth)):
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
from .webauthn_mfa import begin_registration
from .webauthn_mfa import begin_registration, resolve_relying_party
# BUG-070: rp_id/origins derive from the request (exact host incl. port)
# unless explicitly configured — the old localhost defaults rejected
# every real access URL ("Unexpected client data origin").
rp, _ = resolve_relying_party(request)
options = begin_registration(current_user["username"],
current_user.get("display_name", ""))
current_user.get("display_name", ""),
rp_id_override=rp)
return {"options": options}
@router.post("/mfa/webauthn/register")
async def mfa_webauthn_register(
req: WebauthnRegisterRequest,
request: Request,
current_user=Depends(require_auth),
):
"""Verify the created credential, store it, and enable MFA if not already on.
@@ -597,14 +604,17 @@ async def mfa_webauthn_register(
from datetime import datetime, timezone
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration
from .webauthn_mfa import complete_registration, resolve_relying_party
user = get_user(current_user["username"])
if user is None:
raise HTTPException(404, "Utilisateur introuvable")
rp, origins = resolve_relying_party(request)
try:
record = complete_registration(current_user["username"], req.credential,
label=req.label)
label=req.label,
rp_id_override=rp,
origins_override=origins)
except ValueError as e:
raise HTTPException(400, str(e))
except Exception as e:
@@ -683,7 +693,7 @@ async def mfa_webauthn_remove(
@router.post("/mfa/webauthn/options")
async def mfa_webauthn_login_options(body: dict = Body(...)):
async def mfa_webauthn_login_options(request: Request, body: dict = Body(...)):
"""Unauthenticated: begin the login assertion for a user with registered keys.
Enumeration-safe: always 200 — returns null options (caller falls back to
@@ -695,8 +705,9 @@ async def mfa_webauthn_login_options(body: dict = Body(...)):
if not user or not user.get("mfa_enabled") or not creds:
return {"mfa_method": "totp", "options": None}
from .webauthn_mfa import begin_authentication
options = begin_authentication(username, creds)
from .webauthn_mfa import begin_authentication, resolve_relying_party
rp, _ = resolve_relying_party(request)
options = begin_authentication(username, creds, rp_id_override=rp)
if options is None:
return {"mfa_method": "totp", "options": None}
return {"mfa_method": "webauthn", "options": options}
@@ -710,7 +721,7 @@ async def mfa_webauthn_verify(
):
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
from .user_store import get_user, update_user
from .webauthn_mfa import complete_authentication
from .webauthn_mfa import complete_authentication, resolve_relying_party
client_ip = _enforce_mfa_rate_limit(request, body.username)
@@ -721,13 +732,16 @@ async def mfa_webauthn_verify(
if not user.get("mfa_enabled"):
raise HTTPException(400, "MFA non activé pour cet utilisateur")
rp, origins = resolve_relying_party(request)
creds = user.get("webauthn_credentials", [])
try:
credential_id = body.credential.get("id", "")
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
if stored is None:
raise ValueError("Credential non enregistré")
new_count = complete_authentication(body.username, body.credential, stored)
new_count = complete_authentication(body.username, body.credential, stored,
rp_id_override=rp,
origins_override=origins)
except ValueError as e:
_record_mfa_failure(client_ip, body.username)
raise HTTPException(401, str(e))
+157 -36
View File
@@ -38,8 +38,16 @@ logger = logging.getLogger("obsigate.auth.webauthn")
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
CHALLENGE_TTL_SECONDS = 180
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
_pending: dict[str, tuple[bytes, float]] = {}
# How many outstanding challenges to keep per key. BUG-070: a single slot made
# the flow fragile — a double-click on "add key" (or any retry) overwrote the
# pending challenge and the in-flight ceremony failed with
# "Client data challenge was not expected challenge". The verifier now accepts
# any recent challenge for the key.
MAX_PENDING_PER_KEY = 5
# In-memory pending challenges: key -> [(challenge_bytes, expires_at), ...]
# (newest last)
_pending: dict[str, list[tuple[bytes, float]]] = {}
def rp_id() -> str:
@@ -55,24 +63,100 @@ def expected_origins() -> list[str]:
return [o.strip() for o in raw.split(",") if o.strip()]
def resolve_relying_party(request: Any = None) -> tuple[str, list[str]]:
"""Resolve the WebAuthn (rp_id, expected_origins) for a ceremony.
BUG-070: the previous defaults (rp_id ``localhost``, origins
``http://localhost``) rejected every real-world access URL — any port
(``http://localhost:2020``), ``127.0.0.1``, a LAN host or a public domain
failed verification with "Unexpected client data origin".
Explicit configuration still wins: when ``OBSIGATE_WEBAUTHN_RP_ID`` /
``OBSIGATE_WEBAUTHN_ORIGINS`` are set they are used unchanged. Otherwise
the values are derived from the incoming request (exact ``Host``, port
included, since the browser origin carries non-default ports).
Behind a reverse proxy the external host/proto come from
``X-Forwarded-Host`` / ``X-Forwarded-Proto``, honored only when
``OBSIGATE_TRUST_PROXY=true`` (same rule as ``get_client_ip``).
"""
env_rp = os.environ.get("OBSIGATE_WEBAUTHN_RP_ID")
env_raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS")
if request is None:
return (env_rp or "localhost",
[o.strip() for o in env_raw.split(",") if o.strip()]
if env_raw else ["http://localhost"])
from backend.services.net import is_trusted_proxy
if is_trusted_proxy():
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "")
fwd_proto = request.headers.get("x-forwarded-proto", "")
scheme = fwd_proto.split(",")[0].strip() or request.url.scheme
else:
host = request.headers.get("host", "")
scheme = request.url.scheme
if not host:
url = request.url
host = url.netloc or url.hostname or ""
scheme = scheme or url.scheme or "http"
rp = env_rp or _hostname_only(host) or "localhost"
if env_raw:
origins = [o.strip() for o in env_raw.split(",") if o.strip()]
else:
origins = [f"{scheme or 'http'}://{host}"] if host else ["http://localhost"]
return rp, origins
def _hostname_only(host: str) -> str:
"""Strip the port (and IPv6 brackets) from a Host header value."""
host = host.strip()
if host.startswith("["): # [::1]:8080 or [::1]
end = host.find("]")
return host[1:end] if end > 0 else host
if host.count(":") == 1:
name, _, port = host.partition(":")
return name if port.isdigit() else host
return host
def _prune_expired() -> None:
now = time.time()
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
_pending.pop(key, None)
for key in list(_pending):
remaining = [(c, exp) for c, exp in _pending[key] if exp >= now]
if remaining:
_pending[key] = remaining
else:
_pending.pop(key, None)
def _store_challenge(key: str) -> bytes:
_prune_expired()
challenge = secrets.token_bytes(32)
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
slot = _pending.setdefault(key, [])
slot.append((challenge, time.time() + CHALLENGE_TTL_SECONDS))
del slot[:-MAX_PENDING_PER_KEY] # keep only the most recent ones
return challenge
def _take_challenge(key: str) -> bytes | None:
"""Pop a challenge (single-use). Returns None if missing/expired."""
"""Pop the newest challenge (single-use). Returns None if missing/expired."""
_prune_expired()
entry = _pending.pop(key, None)
return entry[0] if entry else None
slot = _pending.get(key)
if not slot:
return None
challenge, _ = slot.pop()
if not slot:
_pending.pop(key, None)
return challenge
def _take_all_challenges(key: str) -> list[bytes]:
"""Pop every outstanding challenge for *key* (newest last)."""
_prune_expired()
slot = _pending.pop(key, None)
return [c for c, _ in slot] if slot else []
def clear_pending(username: str) -> None:
@@ -83,9 +167,12 @@ def clear_pending(username: str) -> None:
# ── Registration (enrol a key in settings) ─────────────────────────────
def begin_registration(username: str, display_name: str) -> dict:
def begin_registration(username: str, display_name: str,
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
_ = origins_override # origins only matter at verification time
options = generate_registration_options(
rp_id=rp_id(),
rp_id=rp_id_override or rp_id(),
rp_name=rp_name(),
user_name=username,
user_display_name=display_name or username,
@@ -98,19 +185,44 @@ def begin_registration(username: str, display_name: str) -> dict:
return _finalize_options(options)
def complete_registration(username: str, credential_json: dict[str, Any],
label: str = "") -> dict:
challenge = _take_challenge(f"{username}:register")
if challenge is None:
raise ValueError("Session d'enregistrement expirée — recommencez")
def _verify_with_any_challenge(key: str, verify_one: Any, empty_message: str) -> Any:
"""Run *verify_one(challenge)* against every outstanding challenge.
Returns the first success; re-raises the last error when all fail.
BUG-070: lets an in-flight ceremony survive a re-requested options call
(double-click / retry) that stored a newer challenge afterwards.
"""
challenges = _take_all_challenges(key)
if not challenges:
raise ValueError(empty_message)
last_error: Exception | None = None
for challenge in challenges:
try:
return verify_one(challenge)
except Exception as e: # try the next candidate challenge
last_error = e
assert last_error is not None
raise last_error
def complete_registration(username: str, credential_json: dict[str, Any],
label: str = "", rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict:
credential = parse_registration_credential_json(credential_json)
verification = verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
)
effective_rp = rp_id_override or rp_id()
effective_origins = origins_override or expected_origins()
def _verify(challenge: bytes) -> Any:
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=effective_rp,
expected_origin=effective_origins,
)
verification = _verify_with_any_challenge(
f"{username}:register", _verify,
"Session d'enregistrement expirée — recommencez")
transports = credential.response.transports or []
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
@@ -126,9 +238,12 @@ def complete_registration(username: str, credential_json: dict[str, Any],
# ── Authentication (assertion at login) ────────────────────────────────
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
def begin_authentication(username: str, credentials: list[dict],
rp_id_override: str | None = None,
origins_override: list[str] | None = None) -> dict | None:
if not credentials:
return None
_ = origins_override # origins only matter at verification time
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
allow = [
@@ -136,7 +251,7 @@ def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
for c in credentials
]
options = generate_authentication_options(
rp_id=rp_id(),
rp_id=rp_id_override or rp_id(),
challenge=_store_challenge(f"{username}:login"),
allow_credentials=allow,
)
@@ -147,21 +262,27 @@ def complete_authentication(
username: str,
credential_json: dict[str, Any],
stored: dict,
rp_id_override: str | None = None,
origins_override: list[str] | None = None,
) -> int:
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
challenge = _take_challenge(f"{username}:login")
if challenge is None:
raise ValueError("Session expirée — rechargez la page")
"""Verify an assertion. Returns the new sign_count. Raises on failure."""
credential = parse_authentication_credential_json(credential_json)
verification = verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id(),
expected_origin=expected_origins(),
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
effective_rp = rp_id_override or rp_id()
effective_origins = origins_override or expected_origins()
def _verify(challenge: bytes) -> Any:
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=effective_rp,
expected_origin=effective_origins,
credential_public_key=base64url_to_bytes(stored["public_key"]),
credential_current_sign_count=int(stored.get("sign_count", 0)),
)
verification = _verify_with_any_challenge(
f"{username}:login", _verify,
"Session expirée — rechargez la page")
return int(verification.new_sign_count)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.16.2"
version = "2.16.5"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.16.2"
version = "2.16.5"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.16.2",
"version": "2.16.5",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5
View File
@@ -178,6 +178,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-067* | [🔵 MINEUR] Guide d'utilisation : l'entrée « 📱 Mobile » du sommaire ne fait rien (section absente) | 🟢 corrigé | P3 | 📱 frontend | IA | `frontend/index.html` | Ouvrir le Guide → cliquer « 📱 Mobile » dans le sommaire : rien ne se passe | L'ancre `#help-mobile-editor` était présente dans la TOC mais aucune section `id="help-mobile-editor"` n'existait (l'édition mobile n'était qu'un h3 de `help-edition`). Fix #105 : section dédiée créée avec ancre + entrée de nav cohérente. | Vérifié par test statique `tests/test_guide.py::test_nav_anchors_resolve` |
| *BUG-068* | Configuration — section « 🔒 Sécurité du compte » inachevée : boutons hors thème, QR code invisible, fiabilité des fonctions à valider | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/auth.js`, `frontend/style.css`, `backend/auth/router.py` | Configuration → 🔒 Sécurité du compte | `frontend/style.css` (+`config-btn-primary`/`danger` thème), `backend/auth/router.py` (`qr_data_url` segno local), `frontend/js/auth.js` (QR local + fallback, recovery WebAuthn, carte mot de passe, escapeHtml labels), locales FR/EN, `backend/requirements.txt` (+segno) ; tests `tests/test_mfa.py` (+1) + `tests/frontend/mfa-settings.test.mjs` (nouveau, 9) | pytest 1241 passed / 6 skipped, ruff 0, mypy 0, frontend unit + validate-imports verts |
| *BUG-069* | Login 2FA bloqué sans erreur : après user+pwd corrects, la page de login reste affichée et le challenge MFA n'apparaît jamais | 🟢 corrigé | P0 | 📱 frontend | IA | `frontend/js/auth.js`, `frontend/index.html` | Activer 2FA → logout → login (bon user+pwd) | `frontend/js/auth.js` (`showMfaChallenge` → `.login-card` + erreur `mfa.challenge_unavailable` si montage impossible), locales FR/EN ; tests `tests/frontend/mfa-settings.test.mjs` (+2) | Reproduit au navigateur avant correctif (challenge jamais affiché), vérifié après : challenge affiché, code erroné → erreur, code valide (200) → app ; frontend mfa-settings 11/11, unit + validate-imports verts |
| *BUG-070* | Activation clé physique WebAuthn impossible : « Validation du credential WebAuthn échouée » à chaque tentative | 🟢 corrigé | P0 | ⚙️ backend | IA | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py` | Config → Sécurité → Ajouter une clé → cérémonie navigateur → 400 | `resolve_relying_party()` (rp_id/origines dérivés de la requête, config explicite prioritaire, forwarded si TRUST_PROXY) sur les 4 endpoints ; challenges multiples (5 derniers) acceptés ; `.env.example` ; tests `tests/test_webauthn.py` (+8) | Logs : origin `http://localhost:2020` rejetée + challenge mismatch au retry. Vérifié navigateur (authentificateur virtuel CDP) : register 200 + clé listée, clé de test retirée (admin de nouveau TOTP seul) ; pytest 1249 passed, ruff/mypy 0 |
| *BUG-071* | Configuration « Configurations » inutilisable en mode mobile : sommaire masqué sans bouton d'accès, navigation par ancre sans JS, grilles 2 colonnes et rangées d'ajout qui débordent (≤768px) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json` | Mobile (≤768px) : ouvrir Configurations → aucun sommaire ni moyen d'atteindre une section ; champs « Clés IA » / jetons / webhooks débordent | `index.html` (+`#config-hamburger` `.help-hamburger`, `config.toc_toggle` FR/EN) ; `config.js` (toggle, scroll doux + actif + repli auto mobile, reset à l'ouverture) ; `i18n.js` (`data-i18n-attr` multi-paires `;`) ; `style.css` (bloc mobile `#config-modal` : sommaire haut 46vh, grilles 1fr, add-rows wrap + `!important`, items wrap, 44px) ; tests `tests/frontend/config-mobile.test.mjs` (nouveau, 11) + CI ; E2E `tests/e2e/config-mobile.spec.js` (nouveau, 3/3 projet chromium-mobile, ignoré en desktop) | pytest 1249 passed / 6 skipped, ruff 0, mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + sidebar 6/6 + mobile 35/35 + ai-keys 7/7 |
| | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -253,6 +255,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-070 | Correction | `backend/auth/webauthn_mfa.py`, `backend/auth/router.py`, `.env.example`, `tests/test_webauthn.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-070** : activation WebAuthn rejetée en 400. (1) Défauts `localhost` sans port → `resolve_relying_party()` dérive rp_id/origines de la requête (config explicite prioritaire, forwarded sous TRUST_PROXY), appliqué aux endpoints register + login. (2) Challenge single-use → 5 derniers conservés, vérification contre le challenge de la cérémonie en cours. **Vérifié au navigateur** (authentificateur virtuel CDP, instance Docker) : register 200, clé listée, clé de test retirée. Tests : `test_webauthn.py` 19/19 (+8), suite complète 1249 passed / 6 skipped, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 | Correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/js/i18n.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071** : page « Configurations » inutilisable en mobile. (1) `#config-nav` masquée sous 768px sans toggle → hamburger `#config-hamburger` ajouté à l'en-tête (`.help-hamburger`, libellé `config.toc_toggle` FR/EN). (2) Ancres brutes sans JS → interception en `config.js` (scroll doux, lien actif, repli auto mobile, reset à l'ouverture). (3) Débordements 360px → bloc CSS mobile `#config-modal` (sommaire haut 46vh, grilles 1fr, add-rows wrap + largeurs inline neutralisées, items wrap, cibles 44px). `data-i18n-attr` multi-paires (`;`). Vérifié : `config-mobile.test.mjs` 11/11 (nouveau, au CI), pytest 1249 passed / 6 skipped, ruff/mypy 0, validate-imports 39 modules, unit 10/10, JSDOM ai 93/93 + ai-sidebar 6/6 + sidebar-filters 8/8 + mobile-editor 35/35 + config-ai-keys 7/7. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
---
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.16.2 | **Dernière mise à jour :** 2026-09-22
> **Version :** 2.16.5 | **Dernière mise à jour :** 2026-09-22
> **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)**
+12
View File
@@ -1486,6 +1486,18 @@
<div class="editor-modal" id="config-modal">
<div class="editor-container">
<div class="editor-header">
<button
class="help-hamburger"
id="config-hamburger"
data-i18n-attr="title:config.toc_toggle;aria-label:config.toc_toggle"
title="Afficher le sommaire"
aria-label="Afficher le sommaire"
>
<i
data-lucide="menu"
style="width: 18px; height: 18px"
></i>
</button>
<div class="editor-title" data-i18n="header.menu_config">Configurations</div>
<div class="editor-actions">
<button
+42
View File
@@ -767,6 +767,10 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
// visible) like the help modal does on open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
@@ -886,6 +890,44 @@ function initConfigModal() {
});
}
// BUG-071: 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
// reach a section. The header hamburger opens it as a top block; picking
// a section smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
configHamburger.addEventListener("click", function(e) {
e.stopPropagation();
var hidden = !configNav || configNav.style.display === "none" || configNav.style.display === "";
_setConfigNav(hidden);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
var hash = a.getAttribute("href");
if (!hash || hash.charAt(0) !== "#") return;
var target = document.getElementById(hash.slice(1));
if (!target) return;
e.preventDefault();
configNav.querySelectorAll(".help-nav-link").forEach(function(o) { o.classList.remove("active"); });
a.classList.add("active");
if (typeof target.scrollIntoView === "function") {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
if (_isConfigMobile()) _setConfigNav(false);
});
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeConfigModal();
+13 -6
View File
@@ -5,6 +5,7 @@
* Static DOM: data-i18n="key" → textContent
* data-i18n-placeholder="key" → placeholder
* data-i18n-attr:title="key" → title attribute
* data-i18n-attr="a:k1;b:k2" → several attributes (";"-separated)
* data-i18n-html="key" → innerHTML (use sparingly)
* Dynamic JS: import { t } from './i18n.js'; t('key', {param: 'val'})
* Live reload: setLocale('en') updates every data-i18n element instantly.
@@ -183,13 +184,19 @@ function _applyDOM() {
el.innerHTML = t(key);
});
// data-i18n-attr:TITLE → sets any attribute
// data-i18n-attr:ATTR:key[;ATTR:key…] → sets any attribute(s).
// Single-pair form (data-i18n-attr="title:key") is preserved; multiple
// pairs are separated with ";" (BUG-071: the config TOC toggle needs both
// title and aria-label translated).
document.querySelectorAll('[data-i18n-attr]').forEach(function (el) {
const raw = el.getAttribute('data-i18n-attr');
const colon = raw.indexOf(':');
if (colon === -1) return;
const attr = raw.substring(0, colon);
const key = raw.substring(colon + 1);
el.setAttribute(attr, t(key));
raw.split(';').forEach(function (pair) {
const colon = pair.indexOf(':');
if (colon === -1) return;
const attr = pair.substring(0, colon).trim();
const key = pair.substring(colon + 1).trim();
if (!attr || !key) return;
el.setAttribute(attr, t(key));
});
});
}
+1
View File
@@ -569,6 +569,7 @@
"config.test": "Test",
"config.timeout_label": "Search timeout (ms)",
"config.title": "Settings",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
"config.title_boost_label": "Title boost",
+1
View File
@@ -569,6 +569,7 @@
"config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
"config.title_boost_label": "Boost titre",
+76
View File
@@ -4723,6 +4723,82 @@ body.resizing-v {
}
}
/* BUG-071: Configurations modal — mobile usability (viewport ≤ 768px).
#config-nav shares the .help-nav rule that hides it, but the config modal
had no toggle (unlike the help modal): the header hamburger
(#config-hamburger, same .help-hamburger treatment) reveals it as a
collapsible top block. Two-column grids and fixed-width add-rows are
stacked/wrapped so nothing overflows a 360px viewport. */
@media (max-width: 768px) {
/* TOC as a collapsible top block (JS toggles inline display flex/none,
which wins over the hiding rule); the list scrolls within a capped nav. */
#config-modal #config-nav {
width: 100%;
min-width: 0;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 46vh;
}
/* Two-column grids → single column. */
#config-modal .ai-default-grid,
#config-modal .ai-provider-fields {
grid-template-columns: 1fr;
}
/* Add-rows (tokens, webhooks, tag filters) wrap instead of overflowing. */
#config-modal .config-add-row,
#config-modal .config-add-pattern {
flex-wrap: wrap;
}
#config-modal .config-add-row .config-input,
#config-modal .config-add-pattern .config-input,
#config-modal .config-add-row .config-select {
flex: 1 1 140px;
width: auto !important; /* override fixed inline widths (180/140/100px) */
min-width: 0;
}
#config-modal .config-add-row .config-btn-add,
#config-modal .config-add-pattern .config-btn-add {
flex: 1 1 auto;
min-height: 44px;
}
/* Webhook / token / share rows wrap; long URLs and meta take their own
line instead of squeezing the delete control off-screen. */
#config-modal .webhook-item,
#config-modal .token-item,
#config-modal .share-item {
flex-wrap: wrap;
}
#config-modal .webhook-url,
#config-modal .token-meta,
#config-modal .share-url {
flex: 1 1 100%;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
#config-modal .webhook-delete,
#config-modal .token-delete,
#config-modal .share-revoke {
min-width: 44px;
min-height: 44px;
}
/* Sticky AI-keys footer: full-width touch-friendly buttons. */
#config-modal .ai-keys-footer .config-btn-save,
#config-modal .ai-keys-footer .config-btn-secondary {
flex: 1 1 100%;
min-height: 44px;
}
/* Long inline code in tips (ex. MCP usage snippet) must wrap. */
#config-modal .qh-tip {
flex-wrap: wrap;
}
#config-modal .qh-tip span {
min-width: 0;
overflow-wrap: anywhere;
}
}
/* --- Toast notifications --- */
.toast-container {
position: fixed;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.16.2",
"version": "2.16.5",
"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": {
+94
View File
@@ -0,0 +1,94 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
* npx playwright test tests/e2e/config-mobile.spec.js --project=chromium-mobile
* (ObsiGate listening on http://localhost:2029, auth disabled)
*/
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
async function boot(page) {
await page.goto('/');
await page.waitForSelector('#app:not(.hidden)', { timeout: 15000 });
await expect(page.locator('#header-menu-btn')).toBeVisible({ timeout: 15000 });
}
async function openConfigModal(page) {
await page.locator('#header-menu-btn').click();
await page.locator('#config-open-btn').click();
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// TOC hidden by default on mobile, hamburger visible.
await expect(page.locator('#config-hamburger')).toBeVisible();
await expect(page.locator('#config-nav')).toBeHidden();
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
await page.locator('#config-hamburger').click();
const link = page.locator('#config-nav a[href="#cfg-tokens"]');
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
// …and the section scrolls into view inside the modal (smooth scroll:
// poll for the settled position instead of racing the animation).
await expect
.poll(
async () => {
const box = await page.locator('#cfg-tokens').boundingBox();
const modalBox = await page.locator('#config-modal').boundingBox();
if (!box || !modalBox) return Number.POSITIVE_INFINITY;
return box.y - (modalBox.y + modalBox.height);
},
{ timeout: 8000 },
)
.toBeLessThanOrEqual(0);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
for (const section of ['#cfg-ai', '#cfg-tokens', '#cfg-webhooks', '#cfg-partages-publics']) {
await page.locator('#config-hamburger').click();
await page.locator(`#config-nav a[href="${section}"]`).click();
}
const overflow = await page.evaluate(() => {
const scroller = document.getElementById('config-scroll');
return scroller.scrollWidth - scroller.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(1);
});
});
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
*
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
* 768px, but the config modal had no toggle (the help modal has
* #help-hamburger) → the table of contents was unreachable on mobile.
* The header must carry #config-hamburger (same .help-hamburger
* treatment: hidden on desktop, visible on mobile) wired in config.js.
* - BUG-071b: the TOC links were bare anchors with no JS — no active state,
* no auto-collapse on mobile, unreliable scrolling inside the modal.
* config.js must smooth-scroll to the section, mark it active and collapse
* the nav on mobile, and reset the nav on open.
* - BUG-071c: two-column grids (.ai-default-grid, .ai-provider-fields),
* fixed-width add-rows (.config-add-row, 180/140/100px inline widths) and
* single-line webhook/token/share items overflowed a 360px viewport.
* style.css must stack/wrap them below 768px with 44px touch targets.
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
* same class of bug as BUG-067 for the help modal).
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
* separated) so the toggle carries translated title AND aria-label.
*
* Usage: node tests/frontend/config-mobile.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "utf8");
const configJs = readFileSync(path.join(ROOT, "frontend", "js", "config.js"), "utf8");
const i18nJs = readFileSync(path.join(ROOT, "frontend", "js", "i18n.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const fr = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
const en = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
function test(label, fn) {
try {
fn();
console.log(" ✓ " + label);
} catch (err) {
console.error(" ✗ " + label + "\n " + err.message);
process.exitCode = 1;
}
}
// ── BUG-071a: header TOC toggle ─────────────────────────────────────────────
test("index.html — #config-hamburger exists in the config modal header", () => {
const modal = indexHtml.match(/<div class="editor-modal" id="config-modal">([\s\S]*?)<div class="editor-body help-body" id="config-body">/);
assert.ok(modal, "#config-modal with #config-body not found");
const header = modal[1].match(/<div class="editor-header">([\s\S]*?)<\/div>\s*<\/div>/);
assert.ok(header, "config modal .editor-header not found");
assert.match(header[1], /id="config-hamburger"/, "no #config-hamburger in the config header — TOC unreachable on mobile");
assert.match(header[1], /help-hamburger/, "the toggle must reuse .help-hamburger (desktop-hidden, mobile-visible)");
assert.match(header[1], /aria-label/, "the toggle needs an accessible label");
assert.match(header[1], /config\.toc_toggle/, "the toggle label must use the i18n key config.toc_toggle");
});
test("i18n — config.toc_toggle exists in FR and EN", () => {
assert.ok(fr["config.toc_toggle"], "fr.json missing config.toc_toggle");
assert.ok(en["config.toc_toggle"], "en.json missing config.toc_toggle");
assert.notEqual(fr["config.toc_toggle"], "config.toc_toggle", "FR value must be translated");
assert.notEqual(en["config.toc_toggle"], "config.toc_toggle", "EN value must be translated");
});
// ── BUG-071b: TOC behaviour in config.js ────────────────────────────────────
test("config.js — hamburger toggles #config-nav", () => {
assert.match(configJs, /getElementById\("config-hamburger"\)/, "no binding on #config-hamburger");
assert.match(configJs, /_setConfigNav\(/, "TOC open/close helper missing");
});
test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", () => {
assert.match(configJs, /#config-nav[\s\S]{0,400}?help-nav-link/, "no handler on the #config-nav links");
assert.match(configJs, /scrollIntoView/, "section scroll must use scrollIntoView inside the modal");
assert.match(configJs, /innerWidth <= 768/, "the nav must auto-collapse on mobile viewports only");
});
test("config.js — TOC display reset when the modal opens", () => {
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
});
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
test("style.css — config TOC becomes a capped top block on mobile", () => {
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
});
test("style.css — two-column config grids stack on mobile", () => {
assert.match(css, /#config-modal \.ai-default-grid/, ".ai-default-grid still 2 columns on mobile");
assert.match(css, /#config-modal \.ai-provider-fields/, ".ai-provider-fields still 3fr/2fr on mobile");
assert.match(css, /grid-template-columns: 1fr;/, "mobile grids must collapse to a single column");
});
test("style.css — add-rows wrap and fixed inline widths are neutralised", () => {
assert.match(css, /#config-modal \.config-add-row/, "no mobile rule for .config-add-row (token/webhook rows overflow)");
assert.match(css, /width: auto !important/, "fixed inline widths (180/140/100px) must be overridden on mobile");
assert.match(css, /min-height: 44px/, "mobile action controls need 44px touch targets");
});
test("style.css — webhook/token/share rows wrap on mobile", () => {
for (const cls of ["webhook-item", "token-item", "share-item"]) {
assert.match(css, new RegExp("#config-modal \\." + cls), `.${cls} has no mobile wrap rule`);
}
});
// ── BUG-071d: dead-anchor guard ─────────────────────────────────────────────
test("index.html — every #config-nav link resolves to an element id", () => {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
const hrefs = [...nav[1].matchAll(/href="(#[^"]+)"/g)].map((m) => m[1].slice(1));
assert.ok(hrefs.length > 0, "no links in #config-nav");
const missing = hrefs.filter((id) => !indexHtml.includes(`id="${id}"`));
assert.deepEqual(missing, [], `dead TOC anchors (cf. BUG-067): ${missing.join(", ")}`);
});
// ── BUG-071e: multi-pair data-i18n-attr ─────────────────────────────────────
test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
assert.match(i18nJs, /split\(['"];/, "pairs must be split on ';'");
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
});
if (process.exitCode) {
console.error("\nConfig mobile tests FAILED");
} else {
console.log("\nAll config mobile tests passed.");
}
+130 -2
View File
@@ -134,8 +134,8 @@ class TestWebauthnModule:
w._pending.clear()
w._store_challenge("u2:register")
key = "u2:register"
ch, _ = w._pending[key]
w._pending[key] = (ch, _t.time() - 1)
ch, _ = w._pending[key][0]
w._pending[key] = [(ch, _t.time() - 1)]
assert w._take_challenge(key) is None
def test_full_registration_and_authentication_roundtrip(self):
@@ -337,3 +337,131 @@ class TestWebauthnApi:
assert r2.status_code == 200
st = wa_client.get("/api/auth/mfa/status", headers=headers).json()
assert st["mfa_enabled"] is False
# ── BUG-070: relying party derived from the request ─────────────────────
#
# The old defaults (rp_id "localhost", origins ["http://localhost"]) rejected
# every real access URL: "Unexpected client data origin
# "http://localhost:2020", expected one of ['http://localhost']".
def _fake_request(host, scheme="http", forwarded_host=None, forwarded_proto=None):
from fastapi import Request
headers = [(b"host", host.encode())]
if forwarded_host is not None:
headers.append((b"x-forwarded-host", forwarded_host.encode()))
if forwarded_proto is not None:
headers.append((b"x-forwarded-proto", forwarded_proto.encode()))
return Request({
"type": "http", "method": "POST", "path": "/",
"headers": headers, "scheme": scheme,
"server": ("testserver", 80), "client": ("127.0.0.1", 5000),
})
class TestRelyingPartyResolution:
def test_defaults_without_request(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
assert w.resolve_relying_party(None) == ("localhost", ["http://localhost"])
def test_derives_host_with_port(self, monkeypatch):
"""Exact BUG-070 report: http://localhost:2020 was rejected."""
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
assert rp == "localhost"
assert origins == ["http://localhost:2020"]
def test_derives_ip_host(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
rp, origins = w.resolve_relying_party(_fake_request("127.0.0.1:2020"))
assert rp == "127.0.0.1"
assert origins == ["http://127.0.0.1:2020"]
def test_explicit_env_wins_over_request(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.setenv("OBSIGATE_WEBAUTHN_RP_ID", "obs.example.com")
monkeypatch.setenv("OBSIGATE_WEBAUTHN_ORIGINS",
"https://obs.example.com, https://www.obs.example.com")
rp, origins = w.resolve_relying_party(_fake_request("localhost:2020"))
assert rp == "obs.example.com"
assert origins == ["https://obs.example.com",
"https://www.obs.example.com"]
def test_forwarded_headers_require_trust(self, monkeypatch):
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "false")
req = _fake_request("internal:8080", scheme="http",
forwarded_host="obs.example.com",
forwarded_proto="https")
assert w.resolve_relying_party(req) == ("internal", ["http://internal:8080"])
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
assert w.resolve_relying_party(req) == ("obs.example.com",
["https://obs.example.com"])
def test_hostname_only(self):
import backend.auth.webauthn_mfa as w
assert w._hostname_only("example.com:2020") == "example.com"
assert w._hostname_only("example.com") == "example.com"
assert w._hostname_only("[::1]:8080") == "::1"
assert w._hostname_only("127.0.0.1:2020") == "127.0.0.1"
def test_retry_after_reoptions_still_verifies(self):
"""A re-requested options call (double-click) must not kill the
in-flight ceremony: "challenge was not expected challenge"."""
import backend.auth.webauthn_mfa as w
w._pending.clear()
auth = VirtualAuthenticator()
first = w._store_challenge("bob:register")
w._store_challenge("bob:register") # second options call overwrites
cred = auth.make_registration({"challenge": _b64url(first)})
rec = w.complete_registration("bob", cred, rp_id_override="localhost",
origins_override=["http://localhost"])
assert rec["credential_id"] == cred["id"]
def test_register_flow_without_env_config(self, wa_client, monkeypatch):
"""Full register + login roundtrip with no WEBAUTHN env at all: the
relying party derives from the request (TestClient host)."""
import backend.auth.webauthn_mfa as w
monkeypatch.delenv("OBSIGATE_WEBAUTHN_RP_ID", raising=False)
monkeypatch.delenv("OBSIGATE_WEBAUTHN_ORIGINS", raising=False)
w._pending.clear()
headers = _login_headers(wa_client)
r = wa_client.post("/api/auth/mfa/webauthn/register/options", headers=headers)
assert r.status_code == 200
options = r.json()["options"]
assert options["rp"]["id"] == "testserver"
auth = VirtualAuthenticator()
auth.RP_ID = "testserver"
auth.ORIGIN = "http://testserver"
cred = auth.make_registration(options)
r2 = wa_client.post("/api/auth/mfa/webauthn/register", headers=headers,
json={"credential": cred, "label": "Key"})
assert r2.status_code == 200, r2.text
opts_r = wa_client.post("/api/auth/mfa/webauthn/options",
json={"username": "testuser"})
assertion = auth.make_assertion(opts_r.json()["options"])
v = wa_client.post("/api/auth/mfa/webauthn/verify",
json={"username": "testuser", "credential": assertion})
assert v.status_code == 200, v.text
assert "access_token" in v.json()