#!/usr/bin/env node /** * ObsiGate β€” Account security section non-regression tests (BUG-068). * * Static checks on the "πŸ”’ SΓ©curitΓ© du compte" configuration section: * - BUG-068a: the TOTP QR code must NOT depend on the third-party * https://api.qrserver.com service (blocked by the CSP * `img-src 'self' data: blob:`, so the QR never displayed β€” and the * otpauth URI, TOTP secret included, leaked to a third party). The setup * endpoint returns a local SVG data: URI (qr_data_url) instead. * - BUG-068b: .config-btn-primary / .config-btn-danger are used by * frontend/js/auth.js but were never defined β€” buttons fell back to the * browser default and ignored the theme. They must exist and derive from * theme variables. * - BUG-068c: recovery codes issued on first-time WebAuthn enable were lost * (no #mfa-setup-flow-area in the "already enabled" view). * - BUG-068d: the section had no password change although * POST /api/auth/change-password exists. * * Usage: node tests/frontend/mfa-settings.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 auth = readFileSync(path.join(ROOT, "frontend", "js", "auth.js"), "utf8"); const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8"); const router = readFileSync(path.join(ROOT, "backend", "auth", "router.py"), "utf8"); const indexHtml = readFileSync(path.join(ROOT, "frontend", "index.html"), "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; } } // ── QR code: local data URI, no third-party service ───────────────────────── test("auth.js β€” no external QR service left (CSP blocks it, secret leaks)", () => { assert.doesNotMatch(auth, /qrserver\.com/, "api.qrserver.com is blocked by img-src and leaks the otpauth URI"); assert.doesNotMatch(auth, /https:\/\/api\./, "no third-party https://api.* image may carry the TOTP secret"); }); test("auth.js β€” setup flow renders the backend qr_data_url with a fallback", () => { assert.match(auth, /qr_data_url/, "the QR must use the backend-provided qr_data_url"); assert.match(auth, /mfa-qr-fallback/, "a manual-entry fallback must show when no QR is available"); assert.match(auth, /mfa\.qr_unavailable/, "the fallback needs its i18n string"); }); test("backend β€” /mfa/totp/setup returns a local qr_data_url", () => { assert.match(router, /qr_data_url/, "setup must include qr_data_url in its response"); assert.match(router, /svg_data_uri/, "the QR must be generated locally (segno SVG data URI)"); assert.match(router, /import segno/, "segno import must stay local with a graceful fallback"); }); // ── Buttons follow the theme ──────────────────────────────────────────────── for (const cls of ["config-btn-primary", "config-btn-danger"]) { test(`style.css β€” .${cls} is defined from theme variables`, () => { const rule = css.match(new RegExp(`\\.${cls}\\s*\\{([^}]*)\\}`)); assert.ok(rule, `.${cls} rule not found β€” buttons fall back to the browser default`); assert.match(rule[1], /var\(--/, `.${cls} must derive from CSS theme variables, not hardcoded colors`); }); } test("style.css β€” themed buttons have disabled states", () => { assert.match(css, /\.config-btn-primary:disabled/, ".config-btn-primary needs a disabled state"); assert.match(css, /\.config-btn-danger:disabled/, ".config-btn-danger needs a disabled state"); }); // ── Recovery codes are never lost ─────────────────────────────────────────── test("auth.js β€” _showRecoveryCodes falls back to the WebAuthn flow area", () => { const fn = auth.match(/function _showRecoveryCodes\(codes(?:, targetId)?\) \{([\s\S]*?)\n\}/); assert.ok(fn, "_showRecoveryCodes helper not found"); assert.match(fn[1], /webauthn-flow-area/, "codes issued on first WebAuthn enable must render without #mfa-setup-flow-area"); }); // ── Password change lives in the security section ─────────────────────────── test("auth.js β€” password change calls POST /api/auth/change-password", () => { assert.match(auth, /\/api\/auth\/change-password/, "changePassword must hit the existing endpoint"); assert.match(auth, /_renderPasswordSection/, "the security section must render a password card"); }); test("i18n β€” password + QR strings exist in FR and EN", () => { for (const key of [ "mfa.qr_unavailable", "mfa.password_change_title", "mfa.password_change_btn", "mfa.password_mismatch", "mfa.password_changed", "mfa.challenge_unavailable", ]) { assert.ok(fr[key], `fr.json missing ${key}`); assert.ok(en[key], `en.json missing ${key}`); } }); // ── BUG-069: the MFA challenge must mount into a real DOM node ────────────── test("auth.js β€” challenge mounts into .login-card (exists in index.html)", () => { assert.doesNotMatch( auth, /querySelector\("\.login-box"\)/, "showMfaChallenge queried .login-box, which never existed in index.html β†’ silent return, login stuck with no error", ); assert.match( auth, /querySelector\("\.login-card"\)/, "the challenge must mount into the real login container", ); assert.ok( indexHtml.includes('class="login-card"'), "index.html must contain the .login-card mount point", ); }); test("auth.js β€” showMfaChallenge never fails silently", () => { const fn = auth.match(/function showMfaChallenge\(username, rememberMe, loginBtn, loginErrorEl, mfaMethod\) \{([\s\S]*?)\n \/\/ WebAuthn second factor/); assert.ok(fn, "showMfaChallenge helper not found"); assert.match(fn[1], /challenge_unavailable/, "a missing mount point must surface an error, not silently return"); assert.doesNotMatch(fn[1], /if \(!loginBox\) return;/, "bare silent return is forbidden in the challenge flow"); }); if (process.exitCode) { console.error("\nMFA settings tests FAILED"); } else { console.log("\nAll MFA settings tests passed."); }