Add admin serial settings view
This commit is contained in:
@@ -6,12 +6,14 @@ 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'} = {}) {
|
||||
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': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': []};
|
||||
const fits = [];
|
||||
let serial = 0;
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
@@ -55,6 +57,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/settings/serial') return json(serialSettings());
|
||||
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');
|
||||
@@ -366,5 +369,94 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
Reference in New Issue
Block a user