'use strict'; const assert = require('node:assert/strict'); const vm = require('node:vm'); const {script, loader, html} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8')); const token = 'a'.repeat(64); const json = value => new Response(JSON.stringify(value)); const session = (extra = {}) => json({username: '', role: 'user', csrf: token, expires_in: 3600, ...extra}); const ticket = () => json({ticket: 't'.repeat(32)}); const serialSettings = (extra = {}) => ({running: true, baud: 230400, data_bits: '8', parity: 'none', stop_bits: '1', flow: 'rts-cts', dtr: 'on-connect', rts_threshold: 96, ...extra}); const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}}); const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; }; const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); }; function browser({onlyLoader = false, withLoader = false, role = 'user', username = ''} = {}) { const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = []; const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []}; const fits = []; let serial = 0, now = Date.now(); class Clock extends Date { static now() { return now; } } const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); }; const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); }; const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; }; class Socket { static OPEN = 1; constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.events = {}; this.sent = []; sockets.push(this); } addEventListener(k, fn) { this.events[k] = fn; } emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); this['on' + k]?.(event); } close() { this.closed = true; this.readyState = 3; this.emit('close'); } send(value) { this.sent.push(value); } } class Terminal { constructor(options) { this.options = options; this.writes = []; terminals.push(this); } loadAddon() {} open(host) { this.host = host; this.focusCalls = 0; } focus() { ++this.focusCalls; this.host.focus(); } resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; } write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); } } const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); }, setTimeout: timeout, clearTimeout: id => timers.delete(id), setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id), requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id), location: {origin: 'https://sak.local', replace: path => redirects.push(path)}}; class Element { constructor(tagName = 'div') { this.tagName = tagName.toUpperCase(); this.children = []; this._text = ''; this.value = ''; this.checked = false; this.dataset = {}; this.classList = {toggle() {}}; } get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } set textContent(value) { this.children.forEach(child => { child.parentNode = null; }); this.children = []; this._text = String(value); } appendChild(child) { this.children.push(child); child.parentNode = this; return child; } setAttribute(k, v) { this[k] = v; } getBoundingClientRect() { return {width: 100, height: 100}; } addEventListener(k, fn) { this[k === 'focus' ? 'onfocus' : k] = fn; } focus() { context.document.activeElement = this; this.onfocus?.(); } contains(node) { return node === this || this.children.some(child => child.contains(node)); } matches() { return !!this.hovered; } } const context = vm.createContext({window, document: { createElement: tag => new Element(tag), getElementById(id) { return nodes[id] ||= new Element(); } }, Terminal, FitAddon: {FitAddon: class { constructor() { this.measurements = []; this.calls = 0; fits.push(this); } proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; } }}, TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, fetch: async (url, options) => { // Apply the Origin regression guard to every mutation, including logout. assert.ok(Object.hasOwn(queues, url)); if (options.method === 'POST') assert.equal(options.mode, 'cors'); assert.equal(options.headers?.Origin, undefined); calls.push({url, ...options}); const next = queues[url].shift(); if (next !== undefined) return typeof next === 'function' ? next(options) : next; if (url === '/api/session') return session({role, username}); if (url === '/api/status') return json({}); if (url === '/api/settings/serial') return json(serialSettings()); if (url === '/api/settings/accounts') return json({users: [{username: 'alice', user_id: 1, auth_generation: 2, role: 'admin'}, {username: 'carol', user_id: 7, auth_generation: 2, role: 'user'}]}); if (url === '/api/ws-ticket') return ticket(); if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30}); throw new Error('network unavailable'); }}); if (withLoader || onlyLoader) vm.runInContext(loader, context); const start = () => vm.runInContext(script, context); const fire = ms => { const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`); const [id, t] = match; if (!t.interval) timers.delete(id); t.fn(); }; return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, click: id => nodes[id].click(), elapse: ms => { now += ms; }, window}; } async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; } let passed = 0; async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); } (async () => { await test('Quick Serial hover/focus/tap reuses one form, opens read-only and preserves socket/lease and binary drains', async () => { const b = browser({role: 'admin'}); b.start(); await tick(); const trigger = b.nodes['quick-serial'], host = b.nodes['serial-settings']; host.appendChild(b.nodes['quick-close']); trigger.pointerenter({pointerType: 'touch'}); await tick(); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 0); trigger.pointerenter({pointerType: 'mouse'}); await tick(); assert.equal(host['data-quick'], 'true'); assert.equal(trigger['aria-expanded'], 'true'); assert.equal(host.role, 'dialog'); assert.equal(b.nodes.terminal.hidden, false); const reads = b.calls.filter(c => c.url === '/api/settings/serial').length; trigger.focus(); b.click('quick-serial'); await tick(); assert.equal(b.document.activeElement, b.nodes['quick-close']); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, reads); trigger.pointerleave(); b.fire(250); assert.equal(host['data-quick'], 'true'); assert.equal(b.calls.filter(c => c.url.includes('settings') && c.method === 'POST').length, 0); const ws = b.sockets[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type:'hello',clientId:8,writerId:8,role:'writer'})}); b.terminals[0].input('not serial input'); assert.equal(ws.sent.length, 0); ws.emit('message', {data: Uint8Array.of(0,255).buffer}); assert.deepEqual(b.terminals[0].writes.at(-1), [0,255]); b.nodes['edit-baud'].value = '999'; b.emit('keydown', {key:'Escape',preventDefault(){},stopPropagation(){}}); assert.equal(host['data-quick'], 'false'); assert.equal(b.document.activeElement, trigger); assert.equal(b.nodes['edit-baud'].value, ''); assert.equal(trigger['aria-expanded'], 'false'); trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false'); b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed); }); await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => { const b = browser({role:'admin'}); b.start(); await tick(); b.nodes['quick-network'].focus(); await tick(); const term = b.terminals[0], before = term.focusCalls; b.nodes['quick-network'].focusout(); b.nodes['quick-serial'].focus(); await tick(); assert.equal(b.document.activeElement,b.nodes['quick-serial']); assert.equal(b.nodes['quick-serial']['aria-expanded'],'true'); assert.equal(b.nodes['quick-network']['aria-expanded'],'false'); assert.equal(term.focusCalls,before); b.nodes['quick-serial'].focusout(); b.nodes['quick-network'].focus(); await tick(); assert.equal(b.document.activeElement,b.nodes['quick-network']); assert.equal(term.focusCalls,before); }); await test('Quick automatic dismissal restores Serial/Admin state without stealing outside focus; Close/Escape focus only trigger', async () => { for (const mode of ['serial','admin']) { const b = browser({role:'admin'}); b.start(); await tick(); if (mode === 'admin') b.click('select-admin'); const terminal = b.terminals[mode === 'admin' ? 1 : 0]; b.nodes['quick-serial'].focus(); await tick(); const before = b.terminals.map(t => t.focusCalls); b.nodes['quick-serial'].focusout(); b.nodes['connection-toggle'].focus(); b.fire(250); await tick(); assert.equal(b.document.activeElement,b.nodes['connection-toggle']); assert.deepEqual(b.terminals.map(t => t.focusCalls),before); assert.equal(b.nodes['serial-settings']['data-quick'],'false'); assert.equal(b.nodes['select-' + mode]['aria-pressed'],'true'); assert.equal(terminal.host.hidden,false); b.click('quick-serial'); await tick(); b.nodes['connection-toggle'].focus(); b.emit('pointerdown',{target:b.nodes['connection-toggle']}); assert.equal(b.document.activeElement,b.nodes['connection-toggle']); assert.deepEqual(b.terminals.map(t => t.focusCalls),before); for (const dismiss of ['close','escape']) { b.click('quick-serial'); await tick(); if (dismiss === 'close') b.click('quick-close'); else b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}}); assert.equal(b.document.activeElement,b.nodes['quick-serial']); assert.deepEqual(b.terminals.map(t => t.focusCalls),before); } assert.ok(b.sockets.every(s => !s.closed)); } }); await test('Quick dismissal fences late snapshots, hover bridge/outside click and expiry without replay', async () => { const b = browser({role:'admin'}); b.start(); await tick(); const late = deferred(); b.queues['/api/settings/serial'].push(late.promise); b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick(); const request = b.calls.find(c => c.url === '/api/settings/serial'); b.nodes['quick-serial'].pointerleave(); b.nodes['serial-settings'].hovered = true; b.fire(250); assert.equal(b.nodes['serial-settings']['data-quick'], 'true'); b.emit('pointerdown', {target:b.nodes['connection-toggle']}); assert.ok(request.signal.aborted); late.resolve(json(serialSettings())); await tick(); assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, ''); b.click('quick-serial'); await tick(); b.emit('pagehide'); assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.ok(b.sockets.every(s => s.closed)); const count = b.calls.length; b.click('quick-serial'); await tick(); assert.equal(b.calls.length,count); }); await test('Quick full-page promotion preserves the sole draft/controller and full-page hover cannot discard drafts', async () => { const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick(); b.nodes['edit-baud'].value = '115200'; const reads = b.calls.length; b.nodes['quick-full'].click({preventDefault(){}}); assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '115200'); b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick(); assert.equal(b.calls.length,reads); assert.equal(b.nodes['edit-baud'].value, '115200'); const u = await connected(); u.click('quick-serial'); u.nodes['quick-network'].focus(); await tick(); assert.ok(!u.calls.some(c => c.url.startsWith('/api/settings/'))); }); await test('Quick Serial shares validation, explicit apply/save and pending result recovery without replay', async () => { const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick(); b.nodes['edit-baud'].value = 'bad'; b.click('serial-apply'); await tick(); assert.ok(!b.calls.some(c => c.url.endsWith('serial-operation'))); b.nodes['edit-baud'].value = '115200'; b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:4,action:'apply',state:'pending'}),{status:202})); b.click('serial-apply'); await tick(); b.click('quick-close'); await tick(); b.click('quick-serial'); await tick(); assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1); assert.ok(b.nodes['serial-apply'].disabled); assert.match(b.nodes['serial-operation-detail'].textContent,/uncertain|unknown|pending/); assert.ok(![...b.timers.values()].some(t => t.ms === 1000)); }); await test('Quick Save sends only the explicit persistence action, and active editor focus resists unrelated hover', async () => { const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick(); const host = b.nodes['serial-settings']; host.appendChild(b.nodes['edit-baud']); b.nodes['edit-baud'].value = '9600'; b.nodes['edit-baud'].focus(); b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick(); assert.equal(b.nodes['quick-serial']['aria-expanded'],'true'); assert.equal(b.nodes['edit-baud'].value,'9600'); b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:5,action:'save',state:'pending'}),{status:202})); b.click('serial-save'); await tick(); const posts = b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST'); assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action:'save'}); b.nodes['quick-full'].click({preventDefault(){}}); await tick(); assert.ok([...b.timers.values()].some(t => t.ms === 1000)); assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1); }); await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => { const b = await connected(); assert.equal(b.calls[0].url, '/api/session'); const post = b.calls.find(c => c.url === '/api/ws-ticket'); assert.equal(post.method, 'POST'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token); for (const call of b.calls) for (const [k, v] of Object.entries({credentials: 'same-origin', mode: call.method === 'POST' ? 'cors' : 'same-origin', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v); assert.match(b.nodes['session-info'].textContent, /^.*one hour absolute/); const ws = b.sockets[0], term = b.terminals[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})}); term.input('x'.repeat(2050)); assert.deepEqual(ws.sent.map(x => x.length), [1024, 1024, 2]); ws.emit('message', {data: Uint8Array.of(0, 255, 13, 10).buffer}); assert.deepEqual(term.writes, [[0, 255, 13, 10]]); b.click('release-control'); assert.equal(ws.sent.at(-1), 'release-writer'); ws.emit('message', {data: JSON.stringify({type: 'writer', writerId: 0, role: 'observer'})}); b.click('request-control'); assert.equal(ws.sent.at(-1), 'request-writer'); b.click('connection-toggle'); assert.ok(ws.closed); assert.equal(b.nodes['connection-toggle'].textContent, 'Connect'); const count = b.calls.length; ws.emit('close'); await tick(); assert.equal(b.calls.length, count); b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session'); }); await test('401 at session/ticket/status stops everything and navigates only once', async () => { for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) { const b = browser(); b.queues[path].push(failure(401)); b.start(); await tick(); assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.timers.size, 0); b.window.sakSessionExpired(); assert.equal(b.redirects.length, 1); b.click('connection-toggle'); await tick(); assert.equal(b.redirects.length, 1); } }); await test('403 mutation is manual-only; capacity backoff is not credentials and revalidates', async () => { for (const status of [403, 429, 503]) { const b = browser(); b.queues['/api/ws-ticket'].push(failure(status)); b.start(); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 0); if (status === 403) { assert.match(b.nodes['connection-detail'].textContent, /security check/); assert.equal(b.nodes['connection-toggle'].textContent, 'Connect'); b.click('connection-toggle'); } else { assert.match(b.nodes['connection-detail'].textContent, /capacity or backoff/); b.fire(7000); } await tick(); assert.equal(b.calls.filter(c => c.url === '/api/session').length, 2); assert.equal(b.sockets.length, 1); } }); await test('logout success, lost success, uncertain network and explicit recovery', async () => { for (const outcome of ['204', 'lost401', 'lost200', 'offline', '403', '503']) { const b = await connected(); b.queues['/api/logout'].push(outcome === '204' ? new Response(null, {status: 204}) : ['403', '503'].includes(outcome) ? failure(Number(outcome)) : () => { throw new Error('SECRET NETWORK'); }); if (outcome === 'lost401') b.queues['/api/session'].push(session(), failure(401)); if (outcome === 'offline') b.queues['/api/session'].push(session(), () => { throw new Error('offline'); }); await b.click('sign-out'); await tick(); assert.ok(b.sockets[0].closed); assert.equal(b.calls.filter(c => c.url === '/api/logout').length, 1); const post = b.calls.find(c => c.url === '/api/logout'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token); if (['204', 'lost401'].includes(outcome)) { assert.deepEqual(b.redirects, ['/login']); assert.equal(b.timers.size, 0); } else { assert.deepEqual(b.redirects, []); assert.match(b.nodes['connection-status'].textContent, /not confirmed/); assert.ok(!b.nodes['connection-detail'].textContent.includes('SECRET')); assert.equal(b.nodes['sign-out'].disabled, false); const count = b.calls.length; b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session'); assert.equal(b.sockets.length, 2); } } }); await test('logout cancels pending status/ticket/session; late 401 and WS events cannot affect new work', async () => { for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) { const d = deferred(), b = browser(); b.queues[path].push(d.promise); b.start(); await tick(); b.queues['/api/logout'].push(failure(403)); await b.click('sign-out'); await tick(); assert.ok(b.calls.find(c => c.url === path).signal.aborted); const detail = b.nodes['connection-detail'].textContent; d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.nodes['connection-detail'].textContent, detail); } const b = await connected(), old = b.sockets[0]; b.click('connection-toggle'); b.click('connection-toggle'); await tick(); old.emit('open'); old.emit('message', {data: JSON.stringify({type: 'hello', clientId: 99, writerId: 99, role: 'writer'})}); old.emit('error'); old.emit('close'); assert.equal(b.nodes['client-id'].textContent, '—'); assert.equal(b.terminals[0].options.disableStdin, true); }); await test('pagehide/restore revalidates, preserves pause; late logout cannot navigate restored page', async () => { for (const paused of [false, true]) { const b = await connected(); if (paused) b.click('connection-toggle'); b.emit('pagehide'); const count = b.calls.length; b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.calls[count].url, '/api/session'); assert.equal(b.sockets.length, paused ? 1 : 2); if (paused) assert.equal(b.nodes['connection-toggle'].textContent, 'Connect'); } const b = await connected(), d = deferred(); b.queues['/api/logout'].push(d.promise); const pending = b.click('sign-out'); await tick(); b.emit('pagehide'); b.emit('pageshow', {persisted: true}); await tick(); d.resolve(new Response(null, {status: 204})); await pending; assert.deepEqual(b.redirects, []); }); await test('bounded schema/body validation, timeout, expiry and retry session checks', async () => { for (const response of [session({csrf: 'A'.repeat(64)}), session({expires_in: 3601}), session({expires_in: -1}), session({expires_in: 1.5}), session({role: 'root'}), session({username: 'x'.repeat(17)}), new Response(' '.repeat(513)), new Response(Uint8Array.of(255)), json(null)]) { const b = browser(); b.queues['/api/session'].push(response); b.start(); await tick(); assert.equal(b.sockets.length, 0); assert.equal(b.calls.length, 1); assert.ok(b.calls[0].signal.aborted); } const b = browser(); b.queues['/api/session'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout'))))); b.start(); b.fire(15000); await tick(); b.fire(1000); await tick(); assert.equal(b.calls[1].url, '/api/session'); const c = browser(); c.queues['/api/session'].push(session({expires_in: 2})); c.start(); await tick(); const expiry = [...c.timers.values()].find(timer => timer.ms >= 0 && timer.ms <= 2000); assert.ok(expiry); c.fire(expiry.ms); assert.deepEqual(c.redirects, ['/login']); }); await test('late body completions and superseded restore session are ignored', async () => { for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) { let stream; const b = browser(); b.queues[path].push(new Response(new ReadableStream({start(c) { stream = c; }}))); b.start(); await tick(); b.emit('pagehide'); const text = path === '/api/session' ? {username: 'late', role: 'admin', csrf: token, expires_in: 3600} : path === '/api/ws-ticket' ? {ticket: 't'.repeat(32)} : {wifi: {available: true, state: 'LATE'}}; stream.enqueue(new TextEncoder().encode(JSON.stringify(text))); stream.close(); await tick(); assert.ok(b.sockets.every(socket => socket.closed)); assert.deepEqual(b.redirects, []); assert.equal(b.timers.size, 0); assert.ok(!b.nodes['wifi-summary'].textContent.includes('LATE')); } const b = await connected(); b.click('connection-toggle'); b.emit('pagehide'); const d = deferred(); b.queues['/api/session'].push(d.promise); b.emit('pageshow', {persisted: true}); await tick(); b.click('connection-toggle'); await tick(); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 2); }); await test('inline asset failures: 401 login, offline usable fallback, pagehide and shared navigation guard', async () => { for (const status of [401, 503]) { const b = browser({onlyLoader: true}); b.queues['/api/session'].push(failure(status)); b.emit('error', {target: {tagName: 'SCRIPT'}}); await tick(); assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []); assert.equal(b.timers.size, 0); b.emit('error', {target: {tagName: 'SCRIPT'}}); assert.equal(b.calls.length, 1); } const b = browser({onlyLoader: true}), d = deferred(); b.queues['/api/session'].push(d.promise); b.emit('error', {target: {tagName: 'LINK'}}); b.emit('pagehide'); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); const c = browser({withLoader: true}); c.start(); await tick(); c.queues['/api/session'].push(failure(401)); c.emit('error', {target: {tagName: 'IMG'}}); await tick(); assert.deepEqual(c.redirects, ['/login']); assert.ok(c.sockets[0].closed); assert.equal(c.timers.size, 0); }); async function adminBrowser() { const b = browser({role: 'admin'}); b.queues['/api/session'].push(session({role: 'admin'})); b.start(); await tick(); const ws = b.sockets[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})}); b.click('select-admin'); b.click('admin-toggle'); await tick(); assert.equal(b.sockets.length, 2); b.sockets[1].emit('open'); return b; } await test('ordinary user is serial-only; admin selection preserves socket/lease and isolates input/output', async () => { const u = await connected(); assert.equal(u.nodes['terminal-selector'].hidden, true); u.click('select-admin'); u.click('admin-toggle'); await tick(); assert.equal(u.terminals.length, 1); assert.equal(u.sockets.length, 1); const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals; assert.equal(admin.url, 'wss://sak.local/ws/admin?ticket=' + '0123456789abcdef'.repeat(4)); assert.equal(b.calls.find(c => c.url === '/api/admin/ws-ticket').headers['X-CSRF-Token'], token); st.input('WRONG'); at.input('x'.repeat(1025)); assert.equal(serial.sent.length, 0); assert.deepEqual(admin.sent.map(x => x.length), [512, 512, 1]); serial.emit('message', {data: Uint8Array.of(0, 255).buffer}); admin.emit('message', {data: Uint8Array.of(27, 91).buffer}); assert.deepEqual(st.writes, [[0, 255]]); assert.deepEqual(at.writes, [[27, 91]]); b.click('release-control'); assert.equal(serial.sent.at(-1), 'release-writer'); for (let i = 0; i < 20; ++i) { b.click('select-serial'); at.input('WRONG'); b.click('select-admin'); } assert.equal(b.sockets.length, 2); assert.equal(b.terminals.length, 2); assert.ok(!serial.closed); assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false); assert.equal(admin.sent.length, 3); b.click('admin-toggle'); assert.ok(admin.closed); assert.equal(admin.onmessage, null); assert.ok(!serial.closed); b.click('admin-toggle'); await tick(); const replacement = b.sockets[2]; replacement.emit('open'); replacement.emit('close'); assert.ok(!serial.closed); assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin'); b.click('select-serial'); st.input('ok'); assert.deepEqual([...serial.sent.at(-1)], [111, 107]); }); await test('admin rejects serial-format and malformed tickets before upgrade without disturbing serial', async () => { for (const value of ['t'.repeat(32), 'a'.repeat(63), 'a'.repeat(65), 'g'.repeat(64), null]) { const b = browser({role: 'admin'}); b.start(); await tick(); const serial = b.sockets[0]; serial.emit('open'); b.queues['/api/admin/ws-ticket'].push(json({ticket: value})); b.click('select-admin'); b.click('admin-toggle'); await tick(); assert.equal(b.sockets.length, 1); assert.ok(!serial.closed); assert.equal(b.nodes['admin-detail'].textContent, 'Admin connection failed. Open admin to retry.'); } }); await test('bounded hidden output continues draining, separate scrollback and input overflow closes only admin', async () => { const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals; assert.equal(st.options.scrollback, 5000); assert.equal(at.options.scrollback, 5000); st.holdWrites = true; for (let i = 0; i < 65; ++i) serial.emit('message', {data: new Uint8Array(1024).buffer}); assert.equal(st.writes.length, 64); assert.match(b.nodes['output-detail'].textContent, /serial 1024 B, admin 0 B/); st.pending.shift()(); serial.emit('message', {data: Uint8Array.of(42).buffer}); assert.equal(st.writes.at(-1)[0], 42); at.input('x'.repeat(4097)); assert.ok(admin.closed); assert.ok(!serial.closed); assert.equal(admin.sent.length, 0); }); await test('admin failures and cancellation are isolated; 401/logout/expiry/pagehide close both', async () => { for (const status of [403, 503, 401]) { const b = await adminBrowser(), serial = b.sockets[0]; b.click('admin-toggle'); b.queues['/api/admin/ws-ticket'].push(failure(status)); b.click('admin-toggle'); await tick(); assert.equal(!!serial.closed, status === 401); assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []); assert.equal(b.sockets.length, 2); } for (const action of ['pagehide', 'expiry', 'logout']) { const b = await adminBrowser(); if (action === 'pagehide') b.emit('pagehide'); if (action === 'expiry') b.window.sakSessionExpired(); if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); } await tick(); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.sockets[1].onmessage, null); if (action === 'pagehide') { b.queues['/api/session'].push(session({role: 'admin'})); b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.sockets.length, 3); assert.match(b.sockets[2].url, /\/ws\/serial/); assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin'); } } const b = await adminBrowser(); b.click('admin-toggle'); const d = deferred(); b.queues['/api/admin/ws-ticket'].push(d.promise); b.click('admin-toggle'); await tick(); b.click('admin-toggle'); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.ok(!b.sockets[0].closed); assert.equal(b.sockets.length, 2); }); await test('selected resize, listener cleanup, admin handshake deadline and stale callback fencing', async () => { const b = await adminBrowser(), [serial, old] = b.sockets; b.fire(-1); assert.equal(b.terminals[1].cols, 80); assert.equal(b.terminals[0].cols, undefined); b.click('select-serial'); b.fire(-1); assert.equal(b.terminals[0].rows, 24); const stale = old.onmessage; b.click('select-admin'); b.click('admin-toggle'); b.click('admin-toggle'); await tick(); stale({data: Uint8Array.of(99).buffer}); assert.equal(b.terminals[1].writes.length, 0); b.fire(15000); assert.ok(b.sockets[2].closed); assert.ok(!serial.closed); assert.match(b.nodes['admin-detail'].textContent, /timed out/); for (let i = 0; i < 3; ++i) { b.emit('pagehide'); assert.equal(b.events.resize.length, 0); assert.ok(b.sockets.every(s => s.onmessage === null)); b.queues['/api/session'].push(session({role: 'admin'})); b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.events.resize.length, 1); } b.window.sakSessionExpired(); assert.equal(b.events.resize.length, 0); }); await test('changed session identity replaces document before adoption, including live admin and paused restore', async () => { const identities = [ {username: 'other-admin', role: 'admin', csrf: 'b'.repeat(64)}, {username: 'other-user', role: 'user', csrf: 'b'.repeat(64)}, {role: 'admin', csrf: 'b'.repeat(64)}, // Same account, different login session. {username: 'other-admin', role: 'admin'}, // Principal fields are checked independently. {role: 'user'} ]; for (const identity of identities) for (const route of ['restore', 'paused-restore', 'live', 'logout']) { const b = await adminBrowser(), [serial, admin] = b.sockets; serial.emit('message', {data: Uint8Array.of(65).buffer}); admin.emit('message', {data: Uint8Array.of(66).buffer}); const stale = admin.onmessage, info = b.nodes['session-info'].textContent; const count = b.calls.length; b.queues['/api/session'].push(session(identity)); if (route.includes('restore')) { if (route === 'paused-restore') b.click('connection-toggle'); b.emit('pagehide'); assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); b.emit('pageshow', {persisted: true}); b.click('select-admin'); assert.equal(b.nodes['admin-terminal'].hidden, true); } else if (route === 'live') { b.click('connection-toggle'); assert.ok(!admin.closed); b.click('connection-toggle'); } else await b.click('sign-out'); await tick(); assert.deepEqual(b.redirects, ['/']); assert.ok(serial.closed && admin.closed); assert.equal(b.nodes['session-info'].textContent, info); // B was never adopted into A's document. assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); assert.deepEqual(b.calls.slice(count).map(c => c.url), ['/api/session']); assert.equal(b.timers.size, 0); assert.equal(b.events.resize.length, 0); stale({data: Uint8Array.of(67).buffer}); b.terminals[1].input('WRONG'); b.click('select-admin'); b.click('admin-toggle'); b.emit('pageshow', {persisted: true}); assert.deepEqual(b.terminals[1].writes, [[66]]); assert.equal(admin.sent.length, 0); assert.equal(b.nodes['admin-terminal'].hidden, true); assert.equal(b.sockets.length, 2); } }); await test('same-session restore preserves both scrollbacks; pending validation never reveals them', async () => { for (const paused of [false, true]) { const b = await adminBrowser(); b.sockets[0].emit('message', {data: Uint8Array.of(65).buffer}); b.sockets[1].emit('message', {data: Uint8Array.of(66).buffer}); if (paused) b.click('connection-toggle'); b.emit('pagehide'); const d = deferred(); b.queues['/api/session'].push(d.promise); b.emit('pageshow', {persisted: true}); await tick(); b.click('select-serial'); b.click('select-admin'); assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); d.resolve(session({role: 'admin', expires_in: 3500})); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.terminals.length, 2); assert.deepEqual(b.terminals.map(t => t.writes), [[[65]], [[66]]]); assert.equal(b.nodes['admin-terminal'].hidden, false); assert.equal(b.sockets.length, paused ? 2 : 3); } const b = await adminBrowser(), admin = b.sockets[1]; admin.emit('message', {data: Uint8Array.of(66).buffer}); b.click('connection-toggle'); b.click('connection-toggle'); await tick(); assert.ok(!admin.closed); assert.deepEqual(b.redirects, []); assert.deepEqual(b.terminals[1].writes, [[66]]); }); await test('undefined first fit retries unchanged bounds, stops after three retries and fences stale work', async () => { const b = await adminBrowser(), fit = b.fits[1]; fit.measurements.push(undefined); b.fire(-1); assert.equal(b.terminals[1].cols, undefined); b.fire(-1); assert.equal(b.terminals[1].cols, 80); assert.equal(fit.calls, 2); b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 2); // Successful measurement is cached. b.click('select-serial'); b.click('select-admin'); fit.measurements.push(undefined, undefined, undefined, undefined); for (let i = 0; i < 4; ++i) b.fire(-1); assert.equal(fit.calls, 6); assert.ok(![...b.timers.values()].some(t => t.ms === -1)); b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 7); // Failed bounds were never cached. for (const action of ['pagehide', 'expiry', 'logout']) { const c = await adminBrowser(); c.fits[1].measurements.push(undefined); c.fire(-1); const stale = [...c.timers.values()].find(t => t.ms === -1).fn; if (action === 'pagehide') c.emit('pagehide'); if (action === 'expiry') c.window.sakSessionExpired(); if (action === 'logout') { c.queues['/api/logout'].push(failure(403)); await c.click('sign-out'); } await tick(); stale(); assert.equal(c.fits[1].calls, 1); assert.ok(![...c.timers.values()].some(t => t.ms === -1)); if (action === 'pagehide') { c.emit('pageshow', {persisted: true}); await tick(); stale(); assert.equal(c.fits[1].calls, 1); c.fire(-1); assert.equal(c.fits[1].calls, 2); } } }); await test('Settings is admin-only, read-only, bounded and preserves both sockets, lease and hidden output', async () => { const u = await connected(); u.click('select-settings'); u.click('refresh-settings'); await tick(); assert.ok(!u.calls.some(c => c.url === '/api/settings/serial')); const b = await adminBrowser(), [serial, admin] = b.sockets; assert.ok(!b.calls.some(c => c.url === '/api/settings/serial')); for (let i = 0; i < 10; ++i) { b.click('select-settings'); await tick(); assert.equal(b.nodes['serial-settings'].hidden, false); assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true); assert.equal(b.nodes['settings-values'].hidden, false); assert.equal(b.nodes['setting-baud'].textContent, '230400'); assert.equal(b.nodes['setting-dtr'].textContent, 'on-connect'); assert.equal(b.nodes['setting-running'].textContent, 'Running'); b.terminals.forEach(t => { assert.equal(t.options.disableStdin, true); t.input('WRONG'); }); serial.emit('message', {data: Uint8Array.of(65).buffer}); admin.emit('message', {data: Uint8Array.of(66).buffer}); b.click('select-serial'); b.click('select-admin'); } assert.equal(b.sockets.length, 2); assert.ok(!serial.closed && !admin.closed); assert.equal(serial.sent.length, 0); assert.equal(admin.sent.length, 0); assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false); assert.deepEqual(b.terminals.map(t => t.writes.length), [10, 10]); const reads = b.calls.filter(c => c.url === '/api/settings/serial'); assert.equal(reads.length, 10); assert.ok(reads.every(c => c.method === 'GET' && c.body === undefined)); b.queues['/api/settings/serial'].push(json(serialSettings({running: false, baud: 110, data_bits: '7', parity: 'odd', stop_bits: '2', flow: 'none', dtr: 'inactive', rts_threshold: 1}))); b.click('select-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Stopped'); b.click('refresh-settings'); await tick(); assert.equal(b.nodes['setting-running'].textContent, 'Running'); }); await test('Settings rejects malformed/oversized schemas, contains errors, and retries only explicitly', async () => { for (const response of [json(null), json(serialSettings({secret: 'bad'})), json(serialSettings({baud: 1000001})), json(serialSettings({running: 1})), json(serialSettings({parity: ''})), json(serialSettings({rts_threshold: 0})), new Response(' '.repeat(257)), new Response(Uint8Array.of(255)), failure(400), failure(403), failure(404), failure(429), failure(503)]) { const b = await adminBrowser(); b.queues['/api/settings/serial'].push(response); b.click('select-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, ''); assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/); assert.ok(!b.nodes['settings-detail'].textContent.includes('SECRET')); assert.equal(b.nodes['refresh-settings'].disabled, false); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1); b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); } const b = await adminBrowser(); b.queues['/api/settings/serial'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout'))))); b.click('select-settings'); await tick(); b.click('refresh-settings'); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 1); b.fire(15000); await tick(); assert.match(b.nodes['settings-detail'].textContent, /Refresh to retry/); assert.ok(b.sockets.every(s => !s.closed)); }); await test('Settings cancellation fences late replies; session change, 401, logout and restore clear the view', async () => { for (const action of ['switch', 'pagehide', 'expiry', 'logout']) { const b = await adminBrowser(), d = deferred(); b.queues['/api/settings/serial'].push(d.promise); b.click('select-settings'); await tick(); const call = b.calls.find(c => c.url === '/api/settings/serial'); if (action === 'switch') b.click('select-serial'); if (action === 'pagehide') b.emit('pagehide'); if (action === 'expiry') b.window.sakSessionExpired(); if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); } assert.ok(call.signal.aborted); d.resolve(failure(401)); await tick(); assert.equal(b.nodes['serial-settings'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, ''); if (action === 'switch') { assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed)); } if (action === 'pagehide') { b.emit('pageshow', {persisted: true}); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.nodes['settings-values'].hidden, true); b.click('refresh-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); } } for (const change of ['401', 'identity']) { const b = await adminBrowser(); if (change === '401') b.queues['/api/settings/serial'].push(failure(401)); else b.queues['/api/session'].push(session({role: 'admin', csrf: 'b'.repeat(64)})); b.click('select-settings'); await tick(); assert.deepEqual(b.redirects, [change === '401' ? '/login' : '/']); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.nodes['serial-settings'].hidden, true); assert.equal(b.nodes['setting-baud'].textContent, ''); } }); await test('Settings session validation never strands concurrent serial reconnect; newer admission fences old settings checks', async () => { const b = await adminBrowser(), d = deferred(); b.queues['/api/session'].push(d.promise); b.sockets[0].emit('close'); b.fire(1000); await tick(); b.click('select-settings'); await tick(); assert.equal(b.nodes['settings-values'].hidden, false); d.resolve(session({role: 'admin'})); await tick(); assert.equal(b.sockets.length, 3); assert.ok(!b.sockets[1].closed); const c = await adminBrowser(), old = deferred(); c.queues['/api/session'].push(old.promise); c.click('select-settings'); await tick(); c.sockets[0].emit('close'); c.fire(1000); await tick(); old.resolve(session({role: 'admin'})); await tick(); assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed); assert.equal(c.nodes['refresh-settings'].disabled, false); assert.match(c.nodes['settings-detail'].textContent, /Refresh to retry/); c.click('refresh-settings'); await tick(); assert.equal(c.nodes['settings-values'].hidden, false); }); await test('Serial typed actions: draft, confirmation, CSRF, explicit results and working/persisted semantics', async () => { const b = browser({role: 'admin'}); b.start(); await tick(); b.sockets[0].emit('open'); b.click('select-settings'); await tick(); const path = '/api/settings/serial-operation'; assert.equal(b.nodes['edit-baud'].value, '230400'); const baseline = b.calls.length; b.nodes['edit-baud'].value = '460800'; assert.equal(b.calls.length, baseline); b.window.confirm = () => false; b.click('serial-reset'); await tick(); assert.equal(b.calls.length, baseline); b.window.confirm = () => true; for (const [i, action] of ['apply', 'save', 'load', 'defaults', 'reset', 'start', 'stop'].entries()) { if (i) { b.click('refresh-settings'); await tick(); } b.queues[path].push(json({id: i + 1, action, state: 'pending'})); b.click('serial-' + action); await tick(); const post = b.calls.filter(c => c.url === path && c.method === 'POST').at(-1); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json'); assert.ok(Buffer.byteLength(post.body) <= 256); const payload = JSON.parse(post.body); assert.equal(payload.action, action); if (action === 'apply') { assert.equal(payload.baud, 460800); assert.equal(payload.rts_threshold, 96); assert.equal(Object.keys(payload).length, 8); } else assert.deepEqual(payload, {action}); assert.ok(b.nodes['serial-apply'].disabled && b.nodes['serial-result'].disabled); assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden); assert.match(b.nodes['settings-detail'].textContent, /stale/); const count = b.calls.length; b.click('serial-save'); await tick(); assert.equal(b.calls.length, count); b.queues[path].push(json({id: i + 1, action, state: 'ok'})); b.fire(1000); await tick(); assert.ok(!b.nodes['serial-apply'].disabled && !b.nodes['serial-edit'].hidden); assert.match(b.nodes['serial-operation-detail'].textContent, /completed.*RAM.*NVS/); } assert.equal(b.sockets.length, 1); assert.ok(!b.sockets[0].closed && !b.sockets[0].sent.length); }); await test('Serial validation and capacity failures never auto-retry or transmit command strings', async () => { const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick(); const path = '/api/settings/serial-operation'; for (const [key, value] of [['baud', '0'], ['baud', '1000001'], ['baud', '1e3'], ['baud', '-1'], ['parity', 'mark'], ['rts_threshold', '128']]) { b.click('refresh-settings'); await tick(); b.nodes['edit-' + key].value = value; const count = b.calls.length; b.click('serial-apply'); await tick(); assert.equal(b.calls.length, count); } b.click('refresh-settings'); await tick(); for (const status of [400, 403, 429, 503]) { b.queues[path].push(failure(status)); b.click('serial-save'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result/); const count = b.calls.length; await tick(); b.click('serial-save'); await tick(); assert.equal(b.calls.length, count); assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('SECRET')); b.queues[path].push(json({id: 0, action: 'none', state: 'idle'})); b.click('serial-result'); await tick(); b.click('refresh-settings'); await tick(); } assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 4); b.queues[path].push(json({id: 1, action: 'save', state: 'pending'})); b.click('serial-save'); await tick(); b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.fire(1000); await tick(); b.click('refresh-settings'); await tick(); b.queues[path].push(() => { throw new Error('lost'); }); b.click('serial-reset'); await tick(); b.queues[path].push(json({id: 1, action: 'save', state: 'ok'})); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /acknowledgement was lost.*earlier operation/); assert.ok(b.sockets.every(s => !s.closed)); }); await test('Serial navigation fences delayed acknowledgement without losing sockets, lease or uncertain-work gate', async () => { const b = browser({role: 'admin'}); b.start(); await tick(); const serial = b.sockets[0]; serial.emit('open'); serial.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})}); b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open'); b.click('select-settings'); await tick(); const d = deferred(), path = '/api/settings/serial-operation'; b.queues[path].push(d.promise); b.click('serial-reset'); await tick(); const post = b.calls.find(c => c.url === path); b.click('select-serial'); assert.ok(post.signal.aborted); d.resolve(json({id: 44, action: 'reset', state: 'pending'})); await tick(); b.click('select-settings'); await tick(); assert.ok(b.nodes['serial-reset'].disabled); b.queues[path].push(json({id: 44, action: 'reset', state: 'ok'})); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /completed/); assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); assert.deepEqual(serial.sent, []); }); await test('Serial result bounds, failure explanations, timeout and no result replay', async () => { const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick(); const path = '/api/settings/serial-operation'; for (const state of ['loaded_defaults', 'failed', 'rollback_failed', 'cancelled', 'pending']) { b.queues[path].push(json({id: 42, action: 'reset', state})); b.click('serial-result'); await tick(); assert.ok(!b.nodes['serial-operation-detail'].textContent.includes('unknown. Check')); } for (const response of [new Response(' '.repeat(97)), json({id: 0, action: 'save', state: 'ok'}), json({id: 42, action: 'save', state: 'ok', extra: 1}), json({id: 42, action: '', state: 'ok'}), json({id: -1, action: 'none', state: 'idle'}), new Response(Uint8Array.of(255))]) { b.queues[path].push(response); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /outcome unknown/); } b.queues[path].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout'))))); b.click('serial-result'); await tick(); b.fire(15000); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 0); }); await test('Serial mutation security: current-session identity, 401 and pagehide cancel work safely', async () => { for (const mode of ['identity', '401', 'pagehide']) { const b = browser({role: 'admin'}); b.start(); await tick(); b.click('select-settings'); await tick(); const path = '/api/settings/serial-operation', d = deferred(); if (mode === 'identity') b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'})); else b.queues[path].push(mode === '401' ? failure(401) : d.promise); b.click('serial-save'); await tick(); if (mode === 'identity') { assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.filter(c => c.url === path).length, 0); } if (mode === '401') assert.deepEqual(b.redirects, ['/login']); if (mode === 'pagehide') { b.emit('pagehide'); const text = b.nodes['serial-operation-detail'].textContent; d.resolve(json({id: 42, action: 'save', state: 'pending'})); await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, text); } assert.ok(b.sockets.every(s => s.closed) && b.nodes['serial-settings'].hidden); } }); await test('Serial uncertain outcomes survive repeated result reads, refresh and navigation', async () => { for (const lostAck of [true, false]) { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.queues[path].push(lostAck ? () => { throw new Error('lost'); } : json({id: 41, action: 'save', state: 'pending'})); b.click('serial-save'); await tick(); const warning = lostAck ? /acknowledgement was lost/ : /Previous result was replaced.*unknown/; for (let i = 0; i < 2; ++i) { b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'})); if (!lostAck && i === 0) b.fire(1000); else b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, warning); } b.queues[path].push(failure(503)); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, warning); assert.match(b.nodes['serial-operation-detail'].textContent, /No automatic retry/); b.click('refresh-settings'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, warning); b.click('select-serial'); b.click('select-settings'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, warning); b.queues[path].push(json({id: 42, action: 'reset', state: 'ok'})); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, warning); b.click('refresh-settings'); await tick(); b.queues[path].push(json({id: 43, action: 'save', state: 'pending'})); b.click('serial-save'); await tick(); assert.doesNotMatch(b.nodes['serial-operation-detail'].textContent, warning); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 2); assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed)); assert.ok(b.sockets.every(s => !s.sent.length)); } }); await test('Automatic checks: pending then completion refreshes working config, retains outcome and isolates sockets', async () => { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); const before = b.calls.filter(c => c.url === '/api/settings/serial').length; b.queues[path].push(json({id: 50, action: 'apply', state: 'pending'})); b.nodes['edit-baud'].value = '460800'; b.click('serial-apply'); assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/); await tick(); for (const state of ['pending', 'pending', 'ok']) { assert.ok(b.nodes['edit-baud'].disabled && b.nodes['serial-stop'].disabled); assert.ok(!b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden); b.queues[path].push(json({id: 50, action: 'apply', state})); if (state === 'ok') b.queues['/api/settings/serial'].push(json({...serialSettings(), baud: 460800})); b.elapse(1000); b.fire(1000); await tick(); } assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 3); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, before + 1); assert.equal(b.nodes['setting-baud'].textContent, '460800'); assert.match(b.nodes['serial-operation-detail'].textContent, /completed/); assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/); assert.ok(!b.nodes['edit-baud'].disabled && !b.nodes['serial-apply'].disabled); assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000)); assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length)); }); await test('Repeated current Settings selection preserves submission, polling and completion refresh', async () => { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.nodes['edit-baud'].value = '460800'; const repeatedSelection = async request => { const count = b.calls.length, timers = [...b.timers]; const view = () => Object.fromEntries(Object.entries(b.nodes).map(([id, node]) => [id, [node.hidden, node.disabled, node.value, node.textContent, node['aria-pressed']]])); const before = view(); for (let i = 0; i < 3; ++i) { b.click('select-settings'); await tick(); } assert.equal(b.calls.length, count); assert.deepEqual([...b.timers], timers); assert.deepEqual(view(), before); if (request) assert.ok(!request.signal.aborted); assert.ok(!b.nodes['serial-settings'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['serial-edit'].hidden); }; const post = deferred(); b.queues[path].push(post.promise); b.click('serial-apply'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /Applying/); await repeatedSelection(b.calls.filter(c => c.url === path).at(-1)); post.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick(); await repeatedSelection(); const pending = deferred(); b.queues[path].push(pending.promise); b.fire(1000); await tick(); await repeatedSelection(b.calls.filter(c => c.url === path).at(-1)); pending.resolve(json({id: 57, action: 'apply', state: 'pending'})); await tick(); await repeatedSelection(); const refresh = deferred(); b.queues['/api/settings/serial'].push(refresh.promise); b.queues[path].push(json({id: 57, action: 'apply', state: 'ok'})); b.fire(1000); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /completed/); assert.match(b.nodes['settings-detail'].textContent, /Reading.*stale/); await repeatedSelection(b.calls.filter(c => c.url === '/api/settings/serial').at(-1)); refresh.resolve(json({...serialSettings(), baud: 460800})); await tick(); assert.equal(b.nodes['setting-baud'].textContent, '460800'); assert.equal(b.nodes['edit-baud'].value, '460800'); assert.ok(!b.nodes['serial-apply'].disabled); assert.match(b.nodes['serial-operation-detail'].textContent, /completed/); assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/); await repeatedSelection(); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1); assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 2); assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 2); assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000)); assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length)); assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8'); }); await test('Ten automatic GET attempts exhaust budget; manual recovery completes without POST retry', async () => { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.queues[path].push(json({id: 51, action: 'save', state: 'pending'})); b.click('serial-save'); await tick(); for (let i = 0; i < 10; ++i) { b.queues[path].push(json({id: 51, action: 'save', state: 'pending'})); b.elapse(1000); b.fire(1000); await tick(); } assert.equal(b.calls.filter(c => c.url === path && c.method === 'GET').length, 10); assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000)); assert.match(b.nodes['serial-operation-detail'].textContent, /Automatic checking stopped.*uncertain.*Check Result/); assert.ok(b.nodes['serial-save'].disabled && !b.nodes['serial-result'].disabled); assert.match(b.nodes['settings-detail'].textContent, /stale/); b.queues[path].push(json({id: 51, action: 'save', state: 'ok'})); b.click('serial-result'); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /completed/); assert.ok(!b.nodes['serial-save'].disabled); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1); }); await test('15s overall deadline aborts slow checks and fences late bodies and delayed timers', async () => { for (const mode of ['fetch', 'body', 'delayed-timer', 'late-response']) { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.queues[path].push(json({id: 52, action: 'stop', state: 'pending'})); b.click('serial-stop'); await tick(); const d = deferred(); let stream; if (mode !== 'delayed-timer') { b.queues[path].push(mode === 'body' ? new Response(new ReadableStream({start(c) { stream = c; }})) : d.promise); b.elapse(1000); b.fire(1000); await tick(); b.elapse(14000); if (mode === 'late-response') { d.resolve(json({id: 52, action: 'stop', state: 'ok'})); await tick(); } else b.fire(15000); assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted); } else { b.elapse(15000); b.fire(1000); } await tick(); const text = b.nodes['serial-operation-detail'].textContent; assert.match(text, /Automatic checking stopped/); assert.ok(!b.nodes['serial-result'].disabled); b.queues[path].push(json({id: 52, action: 'stop', state: 'ok'})); b.click('serial-result'); await tick(); const recovered = b.nodes['serial-operation-detail'].textContent; if (stream) { stream.enqueue(new TextEncoder().encode(JSON.stringify({id: 52, action: 'stop', state: 'failed'}))); stream.close(); } else d.resolve(json({id: 52, action: 'stop', state: 'failed'})); await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, recovered); assert.match(recovered, /completed/); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1); } }); await test('Known terminal outcomes always refresh; failed refresh preserves visible stale snapshot and outcome', async () => { for (const state of ['ok', 'failed', 'rollback_failed', 'cancelled', 'loaded_defaults']) { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.queues[path].push(json({id: 53, action: 'reset', state: 'pending'})); b.click('serial-reset'); await tick(); const d = deferred(); b.queues['/api/settings/serial'].push(d.promise); b.queues[path].push(json({id: 53, action: 'reset', state})); b.fire(1000); await tick(); const outcome = b.nodes['serial-operation-detail'].textContent; assert.ok(b.nodes['edit-baud'].disabled && !b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden); assert.match(b.nodes['settings-detail'].textContent, /stale/); d.resolve(failure(503)); await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, outcome); assert.equal(b.nodes['setting-baud'].textContent, '230400'); assert.ok(!b.nodes['serial-edit'].hidden && !b.nodes['settings-values'].hidden && !b.nodes['refresh-settings'].disabled); assert.match(b.nodes['settings-detail'].textContent, /stale.*Refresh/); b.click('refresh-settings'); await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, outcome); assert.doesNotMatch(b.nodes['settings-detail'].textContent, /stale/); } }); await test('Automatic timers and in-flight checks cancel on navigation, pagehide, logout and identity change', async () => { for (const mode of ['navigation', 'pagehide', 'logout', 'identity', 'expiry']) for (const inFlight of [false, true]) { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); b.queues[path].push(json({id: 54, action: 'start', state: 'pending'})); b.click('serial-start'); await tick(); const callbacks = [...b.timers.values()].filter(t => t.ms === 1000 || t.ms === 15000).map(t => t.fn); const d = deferred(); if (inFlight) { b.queues[path].push(d.promise); b.fire(1000); await tick(); } if (mode === 'navigation') b.click('select-serial'); if (mode === 'pagehide') b.emit('pagehide'); if (mode === 'expiry') b.window.sakSessionExpired(); if (mode === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); b.click('sign-out'); } if (mode === 'identity') { b.queues['/api/session'].push(session({role: 'admin', username: 'replacement'})); b.click('connection-toggle'); b.click('connection-toggle'); } await tick(); const text = b.nodes['serial-operation-detail'].textContent, count = b.calls.filter(c => c.url === path).length; if (inFlight) assert.ok(b.calls.filter(c => c.url === path).at(-1).signal.aborted); for (const callback of callbacks) callback(); d.resolve(json({id: 54, action: 'start', state: 'ok'})); await tick(); assert.equal(b.nodes['serial-operation-detail'].textContent, text); assert.equal(b.calls.filter(c => c.url === path).length, count); assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000)); if (mode === 'navigation') { b.click('select-settings'); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); } if (mode === 'pagehide') { b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.calls.filter(c => c.url === path).length, count); } } }); await test('Automatic read errors stop checking; cancelled completion refresh cannot overwrite a newer view', async () => { const path = '/api/settings/serial-operation'; for (const response of [failure(503), new Response(' '.repeat(97)), () => { throw new Error('network'); }]) { const b = await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json({id: 55, action: 'save', state: 'pending'}), response); b.click('serial-save'); await tick(); b.fire(1000); await tick(); assert.match(b.nodes['serial-operation-detail'].textContent, /Check Result.*No automatic retry/); assert.match(b.nodes['settings-detail'].textContent, /stale/); assert.ok(!b.nodes['serial-result'].disabled && b.nodes['serial-save'].disabled); assert.ok(![...b.timers.values()].some(t => t.ms === 1000 || t.ms === 15000)); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 1); } const b = await adminBrowser(); b.click('select-settings'); await tick(); const d = deferred(); b.queues['/api/settings/serial'].push(d.promise); b.queues[path].push(json({id: 56, action: 'load', state: 'pending'}), json({id: 56, action: 'load', state: 'ok'})); b.click('serial-load'); await tick(); b.fire(1000); await tick(); const outcome = b.nodes['serial-operation-detail'].textContent; const read = b.calls.filter(c => c.url === '/api/settings/serial').at(-1); b.click('select-serial'); assert.ok(read.signal.aborted); b.click('select-settings'); await tick(); d.resolve(json({...serialSettings(), baud: 110})); await tick(); assert.equal(b.nodes['setting-baud'].textContent, '230400'); assert.equal(b.nodes['serial-operation-detail'].textContent, outcome); }); async function accountsBrowser() { const b = browser({role: 'admin', username: 'alice'}); b.start(); await tick(); b.sockets[0].emit('open'); b.click('select-admin'); b.click('admin-toggle'); await tick(); b.sockets[1].emit('open'); b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick(); return b; } const keysPath = '/api/settings/accounts/keys'; const fingerprint = 'SHA256:' + 'a'.repeat(43); const keysReply = (extra = {}) => json({username:'carol',user_id:7,auth_generation:2,keys:[{index:0,type:'ssh-ed25519',fingerprint}],...extra}); async function keyBrowser() { const b = await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.queues[keysPath].push(keysReply()); b.click('account-keys-refresh'); await tick(); return b; } const accountPath = '/api/settings/account-operation'; const accountReply = (id, state, action = 'role') => json({id, state, action}); await test('Key list exact protected identity POST, safe fingerprints and socket/lease isolation', async () => { const b=await keyBrowser(), p=b.calls.find(c=>c.url===keysPath); assert.deepEqual(JSON.parse(p.body),{username:'carol',user_id:7,auth_generation:2}); assert.equal(p.headers['X-CSRF-Token'],token); assert.equal(p.headers['Content-Type'],'application/json'); assert.match(b.nodes['account-keys-list'].textContent,/0: ssh-ed25519 SHA256:/); assert.ok(!b.nodes['account-key-delete'].disabled); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); const u=await connected(); u.click('account-keys-refresh'); u.click('account-key-add'); await tick(); assert.ok(!u.calls.some(c=>c.url===keysPath || c.url===accountPath)); }); await test('ECDSA P-256 lists and imports; key read timeout and 401 use existing session isolation', async () => { const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:[{index:0,type:'ecdsa-sha2-nistp256',fingerprint}]})); b.click('account-keys-refresh'); await tick(); assert.match(b.nodes['account-keys-list'].textContent,/ecdsa-sha2-nistp256/); b.nodes['account-public-key'].value='ecdsa-sha2-nistp256 AAAA comment'; b.queues[accountPath].push(accountReply(34,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key,'ecdsa-sha2-nistp256 AAAA comment'); const t=await keyBrowser(); t.queues[keysPath].push(o=>new Promise((_,reject)=>o.signal.addEventListener('abort',()=>reject(new Error('timeout'))))); t.click('account-keys-refresh'); await tick(); t.fire(15000); await tick(); assert.ok(!t.nodes['account-keys-refresh'].disabled); assert.ok(t.nodes['account-key-delete'].disabled); assert.ok(t.sockets.every(s=>!s.closed)); t.queues[keysPath].push(failure(401)); t.click('account-keys-refresh'); await tick(); assert.deepEqual(t.redirects,['/login']); assert.ok(t.sockets.every(s=>s.closed)); assert.equal(t.nodes['account-keys-list'].textContent,''); }); await test('Key import/delete/clear confirm exact body, single POST and refresh new generation keys', async () => { for(const action of ['key-add','key-delete','key-clear']) { const b=await keyBrowser(); const publicKey='ssh-ed25519 AAAA comment'; b.nodes['account-public-key'].value=publicKey; b.window.confirm=()=>false; b.click('account-'+action); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath)); b.nodes['account-public-key'].value=publicKey; let confirmation; b.window.confirm=m=>{confirmation=m; return true;}; b.queues[accountPath].push(accountReply(30,'pending',action)); b.click('account-'+action); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.match(confirmation,/carol/); if(action==='key-delete') assert.ok(confirmation.includes(fingerprint)); const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='key-add'?{public_key:publicKey}:action==='key-delete'?{key_index:0}:{})}); b.queues[accountPath].push(accountReply(30,'ok',action)); b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); b.queues[keysPath].push(keysReply({auth_generation:3})); b.fire(1000); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.equal(JSON.parse(b.calls.filter(c=>c.url===keysPath).at(-1).body).auth_generation,3); assert.ok(!b.nodes['account-key-delete'].disabled); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)}); function assertKeySlots(b, indices) { assert.deepEqual(b.nodes['account-keys-list'].children.map(node => [node.tagName, node.textContent]), indices.flatMap(index => [['DT', `${index}: `], ['DD', `ssh-ed25519 ${slotKey(index).fingerprint}`]])); assert.equal(b.nodes['account-key-index'].value, String(indices[0])); for(let index=0;index<3;++index) { const option=b.nodes['key-option-'+index], present=indices.includes(index); assert.equal(option.value,String(index)); assert.equal(option.hidden,!present); assert.equal(option.disabled,!present); assert.equal(option.textContent,present?`${index}: ${slotKey(index).fingerprint}`:''); } assert.ok(!b.nodes['account-key-delete'].disabled && !b.nodes['account-key-clear'].disabled); } for(const indices of [[1],[0,2]]) await test(`Sparse key slots [${indices}] render and delete by index, not array position`, async () => { for(const selected of indices) { const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(slotKey)})); b.click('account-keys-refresh'); await tick(); assertKeySlots(b,indices); let confirmation; b.window.confirm=m=>{confirmation=m;return true;}; b.nodes['account-key-index'].value=String(selected); b.nodes['account-key-index'].change(); b.queues[accountPath].push(accountReply(35,'pending','key-delete')); b.click('account-key-delete'); await tick(); assert.ok(confirmation.includes(slotKey(selected).fingerprint)); for(const other of indices.filter(index=>index!==selected)) assert.ok(!confirmation.includes(slotKey(other).fingerprint)); const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:2,key_index:selected}); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); await test('Key deletion automatically refreshes sparse survivors and uses their new identity for the next deletion', async () => { for(const [before,removed,after] of [[[0,1],0,[1]],[[0,1,2],1,[0,2]]]) { const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:before.map(slotKey)})); b.click('account-keys-refresh'); await tick(); b.nodes['account-key-index'].value=String(removed); b.queues[accountPath].push(accountReply(36,'pending','key-delete')); b.click('account-key-delete'); await tick(); const reads=b.calls.filter(c=>c.url===keysPath).length; b.queues[accountPath].push(accountReply(36,'ok','key-delete')); b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); b.queues[keysPath].push(keysReply({auth_generation:3,keys:after.map(slotKey)})); b.fire(1000); await tick(); assertKeySlots(b,after); assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/); const keyReads=b.calls.filter(c=>c.url===keysPath); assert.equal(keyReads.length,reads+1); assert.deepEqual(JSON.parse(keyReads.at(-1).body),{username:'carol',user_id:7,auth_generation:3}); b.nodes['account-key-index'].value=String(removed); b.click('account-key-delete'); await tick(); assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); const selected=after.at(-1); let confirmation; b.window.confirm=m=>{confirmation=m;return true;}; b.nodes['account-key-index'].value=String(selected); b.queues[accountPath].push(accountReply(37,'pending','key-delete')); b.click('account-key-delete'); await tick(); assert.ok(confirmation.includes(slotKey(selected).fingerprint)); const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,2); assert.deepEqual(JSON.parse(posts[1].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:3,key_index:selected}); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); await test('Duplicate and out-of-range key slots reject the whole list and cannot authorize deletion', async () => { for(const indices of [[1,1],[0,2,2],[-1],[3],[0,3],[1.5],['1']]) { const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(index=>({...slotKey(0),index}))})); b.click('account-keys-refresh'); await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); assert.match(b.nodes['account-keys-detail'].textContent,/unavailable or invalid/); assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled && b.nodes['account-key-index'].disabled); let confirmations=0; b.window.confirm=()=>{++confirmations;return true;}; b.nodes['account-key-index'].value='0'; b.click('account-key-delete'); b.click('account-key-clear'); await tick(); assert.equal(confirmations,0); assert.ok(!b.calls.some(c=>c.url===accountPath)); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); assert.ok(![...b.timers.values()].some(t=>t.ms===1000)); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); await test('Key list rejects stale identities, invalid schema and optional endpoint failures without retry', async () => { for(const response of [failure(409),failure(404),failure(503),keysReply({user_id:8}),keysReply({auth_generation:3}),keysReply({keys:[{index:3,type:'ssh-ed25519',fingerprint}]}),keysReply({keys:[{index:0,type:'ssh-ed25519',fingerprint:''}]}),keysReply({keys:Array(4).fill({})}),new Response(' '.repeat(769))]) { const b=await keyBrowser(); b.queues[keysPath].push(response); b.click('account-keys-refresh'); await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled); assert.match(b.nodes['account-keys-detail'].textContent,/stale|unavailable/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); b.click('account-key-delete'); await tick(); assert.ok(!b.calls.some(c=>c.url===accountPath)); } }); await test('Pasted keys clear on contexts and late list headers/body cannot change new target', async () => { for(const streamed of [false,true]) for(const mode of ['target','view','domain','refresh','pagehide','logout']) { const b=await keyBrowser(), d=deferred(); let stream; b.queues[keysPath].push(streamed?new Response(new ReadableStream({start(c){stream=c;}})):d.promise); b.click('account-keys-refresh'); await tick(); const p=b.calls.filter(c=>c.url===keysPath).at(-1); b.nodes['account-public-key'].value='PASTED'; if(mode==='target') { b.nodes['account-target'].value='0'; b.nodes['account-target'].change(); } if(mode==='view') b.click('select-serial'); if(mode==='domain') b.click('settings-serial'); if(mode==='refresh') b.click('refresh-accounts'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out'); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(p.signal.aborted); if(streamed) { try {stream.enqueue(new TextEncoder().encode(await keysReply().text())); stream.close();} catch {} } else d.resolve(failure(401)); await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); if(mode!=='logout') assert.deepEqual(b.redirects,[]); } }); await test('Key UTF-8 and JSON bounds reject private/multiline/oversize; cancellation clears paste', async () => { for(const value of ['-----BEGIN OPENSSH PRIVATE KEY-----','ssh-ed25519 AAAA\nssh-ed25519 BBBB','ssh-ed25519 AAAA '+ 'é'.repeat(185),'ssh-ed25519 AAAA '+ 'x'.repeat(369),'ssh-ed25519 AAAA '+ '\\'.repeat(367)]) { const b=await keyBrowser(); b.nodes['account-public-key'].value=value; b.click('account-key-add'); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath)); } const b=await keyBrowser(); b.nodes['account-public-key'].value='ssh-ed25519 AAAA '+ 'x'.repeat(367); b.queues[accountPath].push(accountReply(31,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(Buffer.byteLength(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key),384); }); await test('Key outcomes duplicate/full/stale/failed refresh, bounded polls and self 401 uncertainty', async () => { for(const state of ['duplicate','full','stale','failed']) { const b=await keyBrowser(); b.queues[accountPath].push(accountReply(32,state,'key-add')); b.queues[keysPath].push(keysReply()); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/Username already|Account capacity/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2); } const b=await keyBrowser(); b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.click('account-key-clear'); await tick(); for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.elapse(1000); b.fire(1000); await tick(); } assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); assert.match(b.nodes['account-operation-detail'].textContent,/stopped/); const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick(); assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/); }); await test('Account definition rows and key rows clear as DOM children and fence late lists', async () => { const b = await keyBrowser(), list = b.nodes['accounts-list'], keys = b.nodes['account-keys-list']; assert.deepEqual(list.children.map(n => [n.tagName, n.textContent]), [['DT', 'alice'], ['DD', 'admin (you)'], ['DT', 'carol'], ['DD', 'user']]); assert.ok(keys.children.length > 0); assert.ok([...list.children, ...keys.children].every(n => n.children.length === 0)); const old = [...list.children, ...keys.children], d = deferred(); b.queues['/api/settings/accounts'].push(d.promise); b.click('refresh-accounts'); await tick(); b.click('settings-serial'); await tick(); assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0); assert.ok(old.every(n => n.parentNode === null)); d.resolve(json({users: [{username: 'late', role: 'user', user_id: 9, auth_generation: 1}]})); await tick(); assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0); }); await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => { const u = await connected(); u.click('settings-accounts'); await tick(); assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts')); const b = await accountsBrowser(); assert.match(b.nodes['accounts-list'].textContent, /alice.*admin.*you/); assert.match(b.nodes['accounts-list'].textContent, /carol.*user/); assert.ok(!b.nodes['account-delete'].disabled); const calls=b.calls.length; b.window.confirm=()=>false; b.click('account-delete'); await tick(); assert.equal(b.calls.length,calls); b.window.confirm=()=>true; b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); assert.ok(!b.nodes['account-delete'].disabled); for(let i=0;i<3;++i) { b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); } assert.equal(b.sockets.length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); for (const bad of [{users:[{username:'',role:'user',user_id:1,auth_generation:1}]}, {users:Array(9).fill({})}, {users:[],password:'SECRET'}, {users:[{username:'safe',role:'admin',user_id:0,auth_generation:1}]}]) { b.queues['/api/settings/accounts'].push(json(bad)); b.click('refresh-accounts'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.ok(!b.nodes['accounts-list'].textContent.includes('SECRET')); } }); await test('Accounts role/delete confirmation, typed identity, automatic result and list refresh', async () => { for (const action of ['role','delete']) { const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); b.nodes['account-role'].value='admin'; const button=action==='role'?'account-change-role':'account-delete', before=b.calls.length; b.window.confirm=()=>false; b.click(button); await tick(); assert.equal(b.calls.length,before); b.window.confirm=()=>true; b.queues[accountPath].push(accountReply(10,'pending',action)); b.click(button); await tick(); const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='role'?{role:'admin'}:{})}); assert.equal(posts[0].headers['X-CSRF-Token'],token); assert.ok(b.nodes['account-target'].disabled); for (const state of ['pending','ok']) { b.queues[accountPath].push(accountReply(10,state,action)); b.fire(1000); await tick(); } assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.match(b.nodes['accounts-detail'].textContent,/refreshed/); assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,2); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); await test('Accounts bounded checks exhaust to manual recovery without POST retry', async () => { const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.queues[accountPath].push(accountReply(11,'pending')); b.click('account-change-role'); await tick(); for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(11,'pending')); b.elapse(1000); b.fire(1000); await tick(); } assert.match(b.nodes['account-operation-detail'].textContent,/stopped.*Check Result/); assert.ok(!b.nodes['account-result'].disabled); assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='GET').length,10); b.queues[accountPath].push(accountReply(11,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/completed/); assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); }); await test('Accounts cancellation fences pending posts, checks and refreshes on domain/view/pagehide', async () => { for (const mode of ['domain','view','pagehide']) { const b=await accountsBrowser(), d=deferred(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.queues[accountPath].push(d.promise); b.click('account-change-role'); await tick(); const request=b.calls.filter(c=>c.url===accountPath).at(-1); if(mode==='domain') b.click('settings-serial'); else if(mode==='view') b.click('select-serial'); else b.emit('pagehide'); assert.ok(request.signal.aborted); const detail=b.nodes['account-operation-detail'].textContent; d.resolve(accountReply(12,'pending')); await tick(); assert.equal(b.nodes['account-operation-detail'].textContent,detail); assert.equal(b.nodes['accounts-list'].textContent,''); assert.ok(!b.calls.some(c=>c.url===accountPath && c.method==='GET')); } const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.queues[accountPath].push(accountReply(13,'pending')); b.click('account-change-role'); await tick(); b.click('settings-serial'); await tick(); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 || t.ms===15000)); }); await test('Accounts timeout, stale/protected/failed outcomes, failed refresh and unknown acknowledgement', async () => { for(const state of ['stale','protected','failed','cancelled']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(14,state)); b.queues['/api/settings/accounts'].push(failure(503)); b.click('account-result'); await tick(); assert.match(b.nodes['accounts-detail'].textContent,/stale/); assert.match(b.nodes['accounts-list'].textContent,/carol/); } const b=await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.queues[accountPath].push(()=>{throw new Error('lost');}); b.click('account-change-role'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); for(let i=0;i<2;++i) { b.queues[accountPath].push(accountReply(15,'ok')); b.click('account-result'); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/Acknowledgement lost/); } b.queues[accountPath].push(accountReply(16,'pending')); b.nodes['account-target'].value='1'; b.nodes['account-role'].value='admin'; b.click('account-change-role'); await tick(); const d=deferred(); b.queues[accountPath].push(d.promise); b.fire(1000); await tick(); b.elapse(15000); b.fire(15000); await tick(); d.resolve(accountReply(16,'ok')); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/unknown/); assert.ok(!b.nodes['account-result'].disabled); }); await test('Accounts 401 and identity changes close routes without adopting stale list', async () => { for(const identity of [false,true]) { const b=await accountsBrowser(); if(identity) b.queues['/api/session'].push(session({role:'admin',username:'replacement'})); else b.queues['/api/settings/accounts'].push(failure(401)); b.click('refresh-accounts'); await tick(); assert.deepEqual(b.redirects,[identity?'/':'/login']); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(b.nodes['accounts-list'].textContent,''); } }); await test('Routine actions never confirm; Reset cancellation has no request or state change', async () => { const b = await adminBrowser(), path = '/api/settings/serial-operation'; b.click('select-settings'); await tick(); const confirms = []; b.window.confirm = message => { confirms.push(message); return false; }; for (const [i, action] of ['apply', 'start', 'stop', 'load', 'defaults', 'save'].entries()) { b.queues[path].push(json({id: i + 1, action, state: 'pending'}), json({id: i + 1, action, state: 'ok'})); b.click('serial-' + action); await tick(); b.fire(1000); await tick(); } assert.deepEqual(confirms, []); const count = b.calls.length, text = b.nodes['serial-operation-detail'].textContent; b.click('serial-reset'); await tick(); assert.equal(b.calls.length, count); assert.equal(b.nodes['serial-operation-detail'].textContent, text); assert.equal(confirms.length, 1); assert.match(confirms[0], /overwrites saved NVS configuration/); assert.equal(b.calls.filter(c => c.url === path && c.method === 'POST').length, 6); }); const generatePath = '/api/settings/accounts/generate-password', secret = 'Abcdefghijklmnopqrst_-12'; function draft(b, purpose = 'create', password = ' typed "pass\\word ') { b.nodes['account-purpose'].value = purpose; b.nodes['account-purpose'].change(); if (purpose === 'create') { b.nodes['account-username'].value = 'new_account'; b.nodes['account-username'].input(); } b.nodes['account-password'].value = b.nodes['account-password-confirm'].value = password; } function cleanSecret(b) { for (const id of ['account-password','account-password-confirm','account-generated']) assert.equal(b.nodes[id].value, '', id); assert.equal(b.nodes['account-password-saved'].checked, false); assert.ok(b.nodes['account-generated-panel'].hidden); } function noSecretOutput(b, value = secret) { for (const [id, node] of Object.entries(b.nodes)) assert.ok(!node.textContent.includes(value), id); } async function generated(b) { b.queues[generatePath].push(json({password: secret})); b.click('account-generate'); await tick(); assert.equal(b.nodes['account-generated'].value, secret); b.nodes['account-password-confirm'].value = secret; b.nodes['account-password-confirm'].input(); b.nodes['account-password-saved'].checked = true; b.nodes['account-password-saved'].change(); } await test('Create/password exact JSON, escaped printable ASCII and bounds, CSRF and isolated sockets', async () => { for (const purpose of ['create','password']) for (const password of [' '.repeat(12), ' typed "pass\\word ', '\\'.repeat(64)]) { const b = await accountsBrowser(); draft(b, purpose, password); b.nodes['account-target'].value = '1'; b.nodes['account-create-role'].value = 'admin'; b.queues[accountPath].push(accountReply(20, 'pending', purpose)); b.click('account-submit-password'); cleanSecret(b); await tick(); const post = b.calls.find(c => c.url === accountPath && c.method === 'POST'); assert.ok(post); assert.deepEqual(JSON.parse(post.body), purpose === 'create' ? {action:purpose, username:'new_account', role:'admin', password} : {action:purpose, username:'carol', user_id:7, auth_generation:2, password}); assert.ok(Buffer.byteLength(post.body) <= 768); assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json'); b.queues[accountPath].push(accountReply(20, 'ok', purpose)); b.fire(1000); await tick(); noSecretOutput(b, password); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length)); } }); await test('Credential validation rejects short/long/non-ASCII/mismatch and invalid usernames; every attempt wipes', async () => { for (const password of ['a'.repeat(11), 'a'.repeat(65), 'é'.repeat(12), 'abcde\nfghijklm', 'abcde\u007ffghijklm']) { const b=await accountsBrowser(); draft(b, 'create', password); b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath)); } for (const name of ['', 'Aname', 'a'.repeat(17), 'a b', '']) { const b=await accountsBrowser(); draft(b); b.nodes['account-username'].value=name; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath)); } const b=await accountsBrowser(); draft(b); b.nodes['account-password-confirm'].value='different password'; b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath)); }); await test('Explicit generation is bodyless CSRF, bounded, separate from account list and mutation slot', async () => { const b=await accountsBrowser(); draft(b); const listReads=b.calls.filter(c=>c.url==='/api/settings/accounts').length; assert.ok(!b.calls.some(c=>c.url===generatePath)); await generated(b); const post=b.calls.find(c=>c.url===generatePath); assert.equal(post.body,''); assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.headers['Content-Type'],undefined); assert.equal(b.calls.filter(c=>c.url==='/api/settings/accounts').length,listReads); assert.ok(!b.calls.some(c=>c.url===accountPath)); noSecretOutput(b); assert.ok([...b.timers.values()].some(t=>t.ms===60000)); b.queues[accountPath].push(accountReply(21,'pending','create')); b.click('account-submit-password'); cleanSecret(b); await tick(); assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).password,secret); }); await test('Generated acknowledgement binds exact value and intent; edits and regeneration reset it', async () => { for (const edit of ['unchecked','password','confirm','username','role','target','purpose','regenerate','silent-target','silent-password']) { const b=await accountsBrowser(); draft(b); await generated(b); if(edit==='unchecked') b.nodes['account-password-saved'].checked=false; if(edit==='password') { b.nodes['account-password'].value='different password'; b.nodes['account-password'].input(); } if(edit==='confirm') b.nodes['account-password-confirm'].input(); if(edit==='username') { b.nodes['account-username'].value='another'; b.nodes['account-username'].input(); } if(edit==='role') { b.nodes['account-create-role'].value='admin'; b.nodes['account-create-role'].change(); } if(edit==='target' || edit==='silent-target') { b.nodes['account-target'].value='1'; if(edit==='target') b.nodes['account-target'].change(); } if(edit==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); } if(edit==='regenerate') { b.queues[generatePath].push(json({password:secret})); b.click('account-generate'); await tick(); } if(edit==='silent-password') b.nodes['account-password'].value=b.nodes['account-password-confirm'].value='different password'; if(!edit.startsWith('silent')) assert.equal(b.nodes['account-password-saved'].checked,false); b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath)); } }); await test('Generated lifetime wipes at 60 seconds and expired delayed timer cannot authorize submission', async () => { for(const timer of [true,false]) { const b=await accountsBrowser(); draft(b); await generated(b); b.elapse(60000); if(timer) b.fire(60000); else b.click('account-submit-password'); await tick(); cleanSecret(b); assert.ok(!b.calls.some(c=>c.url===accountPath)); } }); await test('All secret lifecycle cleanup: view/domain/target/purpose/refresh/pagehide/logout/identity/401', async () => { for(const mode of ['view','domain','target','purpose','refresh','pagehide','logout','identity','401']) { const b=await accountsBrowser(); draft(b); await generated(b); if(mode==='view') b.click('select-serial'); if(mode==='domain') b.click('settings-serial'); if(mode==='target') { b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); } if(mode==='purpose') { b.nodes['account-purpose'].value='password'; b.nodes['account-purpose'].change(); } if(mode==='refresh') b.click('refresh-accounts'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out'); if(mode==='identity' || mode==='401') { b.queues['/api/session'].push(mode==='identity'?session({role:'admin',username:'newadmin'}):failure(401)); b.click('refresh-accounts'); } await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(![...b.timers.values()].some(t=>t.ms===60000)); } }); await test('Late generation headers and streamed bodies cannot resurrect secrets after form cancellation', async () => { for(const body of [false,true]) for(const mode of ['target','edit','purpose','domain','pagehide','logout','refresh']) { const b=await accountsBrowser(); draft(b); const d=deferred(); let stream; b.queues[generatePath].push(body?new Response(new ReadableStream({start(c){stream=c;}})):d.promise); b.click('account-generate'); await tick(); const post=b.calls.find(c=>c.url===generatePath); if(mode==='target') b.nodes['account-target'].change(); if(mode==='edit') b.nodes['account-password'].input(); if(mode==='purpose') b.nodes['account-purpose'].change(); if(mode==='domain') b.click('settings-serial'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out'); if(mode==='refresh') b.click('refresh-accounts'); assert.ok(post.signal.aborted); if(body) { stream.enqueue(new TextEncoder().encode(JSON.stringify({password:secret}))); stream.close(); } else d.resolve(json({password:secret})); await tick(); cleanSecret(b); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); assert.ok(!b.calls.some(c=>c.url===accountPath)); } }); await test('Generation errors/schema/96-byte overflow/timeouts never echo response or retry', async () => { for(const response of [failure(503), failure(403), json({password:secret,extra:1}),json({password:'!'.repeat(24)}),json({password:'x'.repeat(25)}),new Response(' '.repeat(97)),json({password:secret+' '.repeat(100)})]) { const b=await accountsBrowser(); draft(b); b.queues[generatePath].push(response); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b,'SECRET ERROR BODY'); assert.match(b.nodes['account-secret-detail'].textContent,/Nothing applied/); assert.equal(b.calls.filter(c=>c.url===generatePath).length,1); } const b=await accountsBrowser(); draft(b); const d=deferred(); b.queues[generatePath].push(d.promise); b.click('account-generate'); await tick(); b.fire(15000); d.resolve(json({password:secret})); await tick(); cleanSecret(b); }); await test('Generation timeout releases controls on fetch abort; explicit retry is isolated from serial/admin', async () => { const b = await accountsBrowser(); draft(b); b.queues[generatePath].push(options => new Promise((_, reject) => { options.signal.addEventListener('abort', () => reject(new Error('SECRET timeout')), {once:true}); })); b.click('account-generate'); await tick(); assert.ok(b.nodes['account-generate'].disabled); b.fire(15000); await tick(); cleanSecret(b); noSecretOutput(b, 'SECRET timeout'); assert.ok(!b.nodes['account-generate'].disabled && !b.nodes['account-submit-password'].disabled); assert.match(b.nodes['account-secret-detail'].textContent, /Nothing applied/); assert.equal(b.calls.filter(c => c.url === generatePath).length, 1); assert.ok(!b.calls.some(c => c.url === accountPath)); await generated(b); assert.equal(b.calls.filter(c => c.url === generatePath).length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length)); }); await test('Generation endpoint 401 wipes secrets and closes both routes once without a mutation', async () => { const b = await accountsBrowser(); draft(b); await generated(b); b.queues[generatePath].push(failure(401)); b.click('account-generate'); await tick(); cleanSecret(b); noSecretOutput(b); noSecretOutput(b, 'SECRET ERROR BODY'); assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.timers.size, 0); assert.ok(!b.calls.some(c => c.url === accountPath)); b.window.sakSessionExpired(); assert.deepEqual(b.redirects, ['/login']); }); await test('Credential pre-submit session cancellation fences late responses and never sends the password', async () => { for (const purpose of ['create', 'password']) for (const mode of ['view', 'pagehide', 'identity', '401']) { const b = await accountsBrowser(); draft(b, purpose); await generated(b); const pending = deferred(); b.queues['/api/session'].push(pending.promise); b.click('account-submit-password'); cleanSecret(b); await tick(); const check = b.calls.filter(c => c.url === '/api/session').at(-1); if (mode === 'view') b.click('select-serial'); if (mode === 'pagehide') b.emit('pagehide'); pending.resolve(mode === 'identity' ? session({role:'admin', username:'replacement'}) : mode === '401' ? failure(401) : session({role:'admin', username:'alice'})); await tick(); cleanSecret(b); noSecretOutput(b); assert.ok(check.signal.aborted); assert.ok(!b.calls.some(c => c.url === accountPath)); assert.deepEqual(b.redirects, mode === 'identity' ? ['/'] : mode === '401' ? ['/login'] : []); assert.ok(b.sockets.every(s => mode === 'view' ? !s.closed : s.closed)); } }); await test('Self password/role/delete confirmation, no preemptive logout; 401 closes without success claim', async () => { for(const action of ['password','role','delete']) for(const stage of ['post','poll']) { const b=await accountsBrowser(); let warning=''; b.window.confirm=m=>{warning=m;return true;}; if(action==='password') { draft(b,'password'); await generated(b); } const button=action==='password'?'account-submit-password':action==='role'?'account-change-role':'account-delete'; b.queues[accountPath].push(stage==='post'?failure(401):accountReply(25,'pending',action)); b.click(button); cleanSecret(b); assert.ok(b.sockets.every(s=>!s.closed)); await tick(); assert.match(warning,/ALL.*web\/SSH.*browser serial\/admin/); assert.match(warning,/401.*NOT proof/); assert.match(warning,/even a no-op role/); assert.ok(!warning.includes(secret)); if(stage==='poll') { assert.ok(b.sockets.every(s=>!s.closed)); b.queues['/api/session'].push(failure(401)); b.fire(1000); await tick(); } assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); cleanSecret(b); noSecretOutput(b); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/completed and saved/); assert.ok(!b.calls.some(c=>c.url==='/api/logout')); } }); await test('Cancelled confirmation and failed credential POST wipe; duplicate/full safe result messages', async () => { for(const cancel of [true,false]) { const b=await accountsBrowser(); draft(b); await generated(b); b.window.confirm=()=>!cancel; b.queues[accountPath].push(failure(503)); b.click('account-submit-password'); cleanSecret(b); await tick(); noSecretOutput(b); assert.equal(b.calls.filter(c=>c.url===accountPath).length,cancel?0:1); } for(const state of ['duplicate','full']) { const b=await accountsBrowser(); b.queues[accountPath].push(accountReply(27,state,'create')); b.click('account-result'); await tick(); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/undefined|unknown/); } }); await test('Rejected self mutations retain both routes and lease without logout or success claims', async () => { for (const action of ['password', 'role', 'delete']) for (const outcome of [403, 'protected', 'failed', 'stale']) { const b = await accountsBrowser(); b.sockets[0].emit('message', {data:JSON.stringify({type:'hello', clientId:8, writerId:8, role:'writer'})}); if (action === 'password') { draft(b, 'password'); await generated(b); } b.queues[accountPath].push(outcome === 403 ? failure(403) : accountReply(28, 'pending', action)); b.click(action === 'password' ? 'account-submit-password' : action === 'role' ? 'account-change-role' : 'account-delete'); await tick(); if (outcome !== 403) { b.queues[accountPath].push(accountReply(28, outcome, action)); b.fire(1000); await tick(); } cleanSecret(b); noSecretOutput(b); assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length)); assert.equal(b.nodes['writer-id'].textContent, '8'); assert.equal(b.nodes['release-control'].disabled, false); assert.doesNotMatch(b.nodes['account-operation-detail'].textContent, /completed and saved/); assert.ok(!b.calls.some(c => c.url === '/api/logout')); assert.equal(b.calls.filter(c => c.url === accountPath && c.method === 'POST').length, 1); } }); await test('Generation session checks cannot strand reconnect; newer admission fences old generation', async () => { const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues['/api/session'].push(old.promise); b.click('account-generate'); await tick(); b.click('connection-toggle'); b.click('connection-toggle'); await tick(); old.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(b.sockets.length,3); assert.ok(!b.sockets[1].closed); assert.ok(!b.calls.some(c=>c.url===generatePath)); cleanSecret(b); await generated(b); assert.equal(b.nodes['account-generated'].value,secret); const c=await accountsBrowser(); draft(c); const reconnect=deferred(); c.click('connection-toggle'); c.queues['/api/session'].push(reconnect.promise); c.click('connection-toggle'); await tick(); await generated(c); reconnect.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(c.sockets.length,3); assert.equal(c.nodes['account-generated'].value,secret); }); await test('Old generation/refresh responses cannot overwrite new target snapshot or generated value', async () => { const b=await accountsBrowser(); draft(b); const old=deferred(); b.queues[generatePath].push(old.promise); b.click('account-generate'); await tick(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change(); await generated(b); old.resolve(json({password:'x'.repeat(24)})); await tick(); assert.equal(b.nodes['account-generated'].value,secret); assert.equal(b.nodes['account-password-saved'].checked,true); const stale=deferred(); b.queues['/api/settings/accounts'].push(stale.promise); b.click('refresh-accounts'); await tick(); cleanSecret(b); b.click('settings-serial'); await tick(); b.click('settings-accounts'); await tick(); draft(b); await generated(b); stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick(); assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret); }); await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}); await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}); await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}); await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}); await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}); console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`); })().catch(error => { console.error(error); process.exitCode = 1; });