Reuse the existing settings view and typed controllers for accessible quick editing while keeping network credentials out of quick mode. Expand browser and layout coverage for focus, dismissal, bounds, and expiry.
1376 lines
114 KiB
JavaScript
1376 lines
114 KiB
JavaScript
'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: '<img>', 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 = '<img>'} = {}) {
|
|
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': []};
|
|
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, /^<img>.*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: '<img>'})), 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: '<img>', 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:'<img>'}]}),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:'<img>',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', '<img>']) {
|
|
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});
|
|
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|