Files
ESP32_Serial_Swiss_Army_Knife/tests/web_login_ui/browser.cjs
T
Commander1024 4435a7fddd Add Standalone Login Page Renderer
Add a hash-bound, no-store login document with focused C and Node host
tests. Keep rendering inert until the 8D.3 authentication cutover.
2026-09-05 18:45:12 +02:00

186 lines
13 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const vm = require('node:vm');
const {script} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
const token = 'a'.repeat(64), secret = 'SERVER_BODY_MUST_NOT_APPEAR';
const json = value => new Response(JSON.stringify(value));
const challenge = (csrf = token) => json({csrf, expires_in: 120});
const success = () => json({authenticated: true});
const tick = () => new Promise(resolve => setImmediate(resolve));
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
function browser(queue = []) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map();
for (const id of ['login', 'username', 'password', 'submit', 'message']) nodes[id] = {
value: '', disabled: id === 'submit', textContent: '', attrs: {},
setAttribute(k, v) { this.attrs[k] = v; },
addEventListener(k, fn) { this[k] = fn; }
};
let now = 100000, timerId = 0;
vm.runInNewContext(script, {
document: {getElementById: id => { assert.ok(nodes[id], id); return nodes[id]; }},
window: {addEventListener: (k, fn) => { events[k] = fn; }, location: {replace: p => redirects.push(p)}},
TextEncoder, TextDecoder, Uint8Array, AbortController, Response, Date: {now: () => now},
setTimeout: (fn, ms) => { timers.set(++timerId, {fn, ms}); return timerId; },
clearTimeout: id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, ...options});
assert.ok(queue.length, 'unexpected/automatic fetch');
const next = queue.shift();
return typeof next === 'function' ? next(options) : next;
}
}, {timeout: 1000});
assert.equal(calls.length, 0); assert.equal(timers.size, 0); assert.equal(nodes.submit.disabled, false);
return {nodes, events, calls, redirects, timers, queue, advance: ms => { now += ms; },
submit: (user = 'alice', pass = 'password') => {
nodes.username.value = user; nodes.password.value = pass;
let prevented = false;
const result = nodes.login.submit({preventDefault() { prevented = true; }});
assert.ok(prevented); return result;
},
idle() {
for (const id of ['submit', 'username', 'password']) assert.equal(nodes[id].disabled, false, id);
assert.equal(nodes.password.value, '');
assert.equal(timers.size, 0); assert.ok(!nodes.message.textContent.includes(secret));
}
};
}
let passed = 0;
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
(async () => {
await test('no automatic fetch; challenge GET, CSRF JSON POST, redirect', async () => {
const b = browser([challenge(), success()]); await b.submit(); b.idle();
assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.length, 2);
const [get, post] = b.calls;
assert.equal(get.url, '/api/login-challenge'); assert.equal(get.method || 'GET', 'GET');
assert.equal(get.headers['X-Login-Bootstrap'], '1'); assert.equal(get.body, undefined);
assert.equal(post.url, '/api/login'); assert.equal(post.method, 'POST');
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(post.body), {username: 'alice', password: 'password'});
for (const call of b.calls) {
for (const [k, v] of Object.entries({credentials: 'same-origin', mode: 'same-origin', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
assert.ok(call.signal instanceof AbortSignal);
}
});
await test('wrong credentials stay on page; manual retry obtains fresh challenge', async () => {
const b = browser([challenge(), new Response(secret, {status: 401})]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.match(b.nodes.message.textContent, /incorrect/i);
b.queue.push(challenge('b'.repeat(64)), success()); await b.submit();
assert.equal(b.calls[2].url, '/api/login-challenge'); assert.equal(b.calls[3].headers['X-CSRF-Token'], 'b'.repeat(64));
});
await test('status errors at both stages; manual-only bounded backoff', async () => {
for (const stage of ['challenge', 'login']) for (const status of [400, 401, 403, 409, 413, 415, 429, 503, 500]) {
let response, aborted = false;
const b = browser([...(stage === 'login' ? [challenge()] : []), options => {
// Model fetch abort terminating its body stream, not a real HTTP socket.
const stream = new ReadableStream({start(controller) {
controller.enqueue(new TextEncoder().encode(secret));
options.signal.addEventListener('abort', () => {
aborted = true; controller.error(new DOMException('Aborted', 'AbortError'));
}, {once: true});
}});
response = new Response(stream, {status, headers: {'Retry-After': '2'}});
return response;
}]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.ok(aborted, `${stage} ${status}: response stream abort`);
for (const call of b.calls) assert.ok(call.signal.aborted, `${stage} ${status}: signal`);
await assert.rejects(response.body.getReader().read(), {name: 'AbortError'});
assert.ok(b.nodes.message.textContent); const count = b.calls.length;
if ([429, 503].includes(status)) { await b.submit(); assert.equal(b.calls.length, count); }
b.advance(3000); await tick(); assert.equal(b.calls.length, count); assert.equal(b.timers.size, 0);
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
for (const [raw, seconds] of [['0', 1], ['999', 120], ['bad', 5], ['1000', 5], ['', 5]]) {
const b = browser([new Response(secret, {status: 429, headers: {'Retry-After': raw}})]);
await b.submit(); assert.ok(b.nodes.message.textContent.includes(`Wait ${seconds} seconds`));
b.advance(seconds * 1000 - 1); await b.submit(); assert.equal(b.calls.length, 1);
b.advance(1); b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
});
await test('malformed, oversized, invalid UTF-8, absent and invalid-schema response bodies', async () => {
for (const stage of ['challenge', 'login']) {
const good = JSON.stringify(stage === 'challenge' ? {csrf: token, expires_in: 1} : {authenticated: true});
const invalid = [() => new Response(secret), () => new Response(null), () => new Response(Uint8Array.of(255)),
() => new Response(good.padEnd(513)), () => json(null), () => json({}), () => json([]),
...(stage === 'challenge' ? [() => json({csrf: token, expires_in: 0}), () => json({csrf: token, expires_in: 121}),
() => json({csrf: token, expires_in: 1.5}), () => json({csrf: token, expires_in: '1'}),
() => challenge('A'.repeat(64)), () => challenge('a'.repeat(63))] : [() => json({authenticated: 'true'}), () => json({authenticated: false})])];
for (const make of invalid) {
const b = browser([...(stage === 'login' ? [challenge()] : []), make()]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
assert.match(b.nodes.message.textContent, /Could not confirm/); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
}
const b = browser(stage === 'login' ? [challenge(), new Response(good.padEnd(512))] : [new Response(good.padEnd(512)), success()]);
await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
let cancelled = false;
const stream = new ReadableStream({start(c) { c.enqueue(new Uint8Array(300).fill(32)); c.enqueue(new Uint8Array(213).fill(32)); }, cancel() { cancelled = true; }});
const b = browser([new Response(stream)]); await b.submit(); b.idle(); assert.ok(cancelled);
});
await test('network errors at both stages; deadline abort; manual recovery', async () => {
for (const stage of ['challenge', 'login']) {
const b = browser([...(stage === 'login' ? [challenge()] : []), () => { throw new Error(secret); }]);
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []); assert.match(b.nodes.message.textContent, /Could not confirm/);
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
}
const b = browser([o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error(secret))))]);
const pending = b.submit(); await tick(); const timer = [...b.timers.values()][0];
assert.equal(timer.ms, 15000); timer.fn(); await pending; b.idle(); assert.ok(b.calls[0].signal.aborted);
});
await test('UTF-8 and JSON byte limits, empty and NUL input, exact boundaries', async () => {
for (const [u, p] of [['', 'p'], ['u', ''], ['a'.repeat(17), 'p'], ['u', 'a'.repeat(65)],
['é'.repeat(9), 'p'], ['u', 'é'.repeat(33)], ['u\0', 'p'], ['u', 'p\0']]) {
const b = browser(); await b.submit(u, p); b.idle(); assert.equal(b.calls.length, 0); assert.match(b.nodes.message.textContent, /UTF-8 bytes/);
}
for (const [u, p] of [['a'.repeat(16), 'p'.repeat(64)], ['é'.repeat(8), '🔑'.repeat(16)], ['\u0001'.repeat(16), '\u0001'.repeat(64)]]) {
const b = browser([challenge(), success()]); await b.submit(u, p); assert.deepEqual(b.redirects, ['/']);
assert.deepEqual(JSON.parse(b.calls[1].body), {username: u, password: p});
assert.ok(new TextEncoder().encode(b.calls[1].body).length <= 512);
}
});
await test('pending inputs disabled; duplicate and completion wipe retyped passwords at both stages', async () => {
for (const stage of ['challenge', 'login']) for (const duplicate of [false, true]) {
const d = deferred(), b = browser(stage === 'login' ? [challenge(), d.promise] : [d.promise, success()]);
const pending = b.submit(); await tick();
assert.equal(b.nodes.password.value, ''); assert.equal(b.nodes.login.attrs['aria-busy'], 'true');
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
if (duplicate) {
await b.submit('alice', 'manually retyped duplicate');
assert.equal(b.nodes.password.value, ''); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
}
b.nodes.password.value = 'manually retyped before completion';
d.resolve(stage === 'login' ? success() : challenge()); await pending; b.idle();
assert.equal(b.nodes.login.attrs['aria-busy'], 'false'); assert.deepEqual(b.redirects, ['/']);
}
});
await test('pagehide aborts; late fetch/body ignored at both stages; pageshow recovers', async () => {
for (const stage of ['challenge', 'login']) for (const bodyPending of [false, true]) {
const d = deferred(); let streamController;
const response = bodyPending ? new Response(new ReadableStream({start(c) { streamController = c; }})) : d.promise;
const b = browser([...(stage === 'login' ? [challenge()] : []), response]);
const pending = b.submit(); await tick(); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
b.events.pagehide({}); assert.ok(b.calls[0].signal.aborted);
for (const id of ['submit', 'username', 'password']) assert.equal(b.nodes[id].disabled, false, id);
assert.equal(b.nodes.password.value, ''); b.events.pageshow({persisted: true});
assert.equal(b.nodes.message.textContent, 'Ready to sign in.');
// New generation remains busy even when the old request finishes.
const newer = deferred(); b.queue.push(newer.promise, success()); const retry = b.submit('new-user', 'new-password');
const newSignal = b.calls.at(-1).signal;
b.nodes.password.value = 'new generation field sentinel';
if (bodyPending) {
streamController.enqueue(new TextEncoder().encode(JSON.stringify(stage === 'login' ? {authenticated: true} : {csrf: token, expires_in: 60})));
streamController.close();
} else d.resolve(stage === 'login' ? success() : challenge());
await pending; assert.deepEqual(b.redirects, []);
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
assert.equal(b.nodes.username.value, 'new-user'); assert.equal(b.nodes.password.value, 'new generation field sentinel');
assert.equal(newSignal.aborted, false); assert.equal(b.timers.size, 1);
assert.equal(b.nodes.login.attrs['aria-busy'], 'true'); assert.equal(b.nodes.message.textContent, 'Signing in...');
newer.resolve(challenge()); await retry; b.idle(); assert.deepEqual(b.redirects, ['/']);
}
});
console.log(`PASS ${passed} browser test groups`);
})().catch(error => { console.error(error); process.exitCode = 1; });