Files
ObsiGate/tests/frontend/mfa-settings.test.mjs
T
bruno bca0fdd941
CI / lint (push) Successful in 1m56s
CI / security (push) Successful in 1m20s
CI / test (push) Successful in 4m17s
CI / build (push) Successful in 1m16s
CI / e2e (push) Successful in 12m35s
fix: login 2FA bloque sans erreur BUG-069 (challenge montait dans .login-box inexistant -> .login-card + erreur visible)
2026-09-22 20:38:37 -04:00

137 lines
6.6 KiB
JavaScript

#!/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 <img> 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.");
}