Files
Live_RC/tests/browser_auth.js
larssand 9016ce02e4 feat(auth): add optional admin PIN for all API writes
Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/*
request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also
closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog
and the pending save is retried after login. Reads stay open because the public overlay
hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same
routes by client address.

The PIN hash and signing secret live in data/, outside the app_state blob, so they
never leak through GET /api/state, exports or backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:14:58 +02:00

46 lines
3.1 KiB
JavaScript

import assert from "node:assert/strict";
// End-to-end admin PIN flow through the real UI: enable a PIN in Settings, log out, make a
// normal edit, get prompted by the login dialog the 401 triggers, reject a wrong PIN, accept
// the right one, confirm the pending edit actually reaches the backend, then turn the PIN
// off again so the rest of the shared suite keeps running unauthenticated.
export async function runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor }) {
const statusText = () => evaluate(`document.querySelector("#authSettings strong")?.textContent || ""`);
const openSystemTab = async () => {
await navigate("settings", "settingsForm");
await evaluate(`document.querySelector('[data-settings-tab="system"]').click()`);
await waitFor(async () => Boolean(await evaluate(`document.getElementById("authPinForm")`)), "admin PIN panel rendered");
};
await openSystemTab();
assert.match(await statusText(), /^Off/);
await fill("authPinForm", { newPin: "4711", confirmPin: "4711" });
await evaluate(`document.getElementById("authPinForm").requestSubmit()`);
await waitFor(async () => /logged in in this browser/.test(await statusText()), "PIN enabled and logged in");
await evaluate(`document.getElementById("authLogout").click()`);
await waitFor(async () => /not logged in/.test(await statusText()), "logged out");
console.log("PASS enabling the admin PIN in Settings logs this browser in, and logging out works");
try {
await navigate("classes", "classForm");
await fill("classForm", { name: "Auth Protected Class" });
await evaluate(`document.getElementById("classForm").requestSubmit()`);
await waitFor(async () => Boolean(await evaluate(`document.getElementById("authLoginForm")`)), "a blocked save opens the PIN dialog");
assert.ok(!(await savedState()).classes.some((item) => item.name === "Auth Protected Class"), "nothing is saved without the PIN");
await evaluate(`(() => { const form = document.getElementById("authLoginForm"); form.elements.namedItem("pin").value = "0000"; form.requestSubmit(); })()`);
await waitFor(async () => (await evaluate(`document.getElementById("authLoginError")?.hidden`)) === false, "wrong PIN shows an error");
await evaluate(`(() => { const form = document.getElementById("authLoginForm"); form.elements.namedItem("pin").value = "4711"; form.requestSubmit(); })()`);
await waitFor(async () => !(await evaluate(`Boolean(document.getElementById("authLoginForm"))`)), "dialog closes on the right PIN");
await waitFor(async () => (await savedState()).classes.some((item) => item.name === "Auth Protected Class"), "the pending edit is saved after logging in");
console.log("PASS a save while logged out prompts for the PIN, rejects a wrong one, and goes through after the right one");
} finally {
await evaluate(`fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pin: "4711" }) }).then(() => fetch("/api/auth/disable", { method: "POST" }))`);
}
await openSystemTab();
assert.match(await statusText(), /^Off/, "PIN protection is off again for the rest of the suite");
}