'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; });