Add Typed Display Settings Administration
Implements admin-only Display settings with generation-checked Apply, Save, Load, Defaults, and Reset operations across the web UI, CLI, SSH dispatcher, and local UI owner. Adds bounded HTTP handling, session-isolated operation results, browser lifecycle support, and comprehensive host tests and documentation.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||
const path = '/api/settings/display', op = path + '-operation';
|
||||
const fixture = (extra = {}) => ({generation: 7, dim_seconds: 300, off_seconds: 600, ...extra});
|
||||
const reply = (action = 'apply', state = 'pending', id = 42, status = 200) => new Response(JSON.stringify({id, action, state}), {status});
|
||||
const ack = action => reply(action, 'pending', 42, 202);
|
||||
const n = (b, id) => b.nodes['display-' + id];
|
||||
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
|
||||
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
|
||||
const reads = b => b.calls.filter(c => c.url === path);
|
||||
async function open(value = fixture()) {
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json(value)); b.click('settings-display'); await tick(); return b;
|
||||
}
|
||||
async function complete(b, action, state = 'ok') {
|
||||
b.queues[op].push(reply(action, state)); b.queues[path].push(json(fixture({generation: 8})));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Display admin-only entry, actual label/value controls, absent-panel policy and navigation preserves terminals/lease', async () => {
|
||||
for (const id of ['settings-display','display-values','display-edit-dim_seconds','display-edit-off_seconds','display-refresh','display-result','display-apply','display-save','display-load','display-defaults','display-reset']) assert.ok(html.includes('id="' + id + '"'));
|
||||
assert.match(html, /absent panel/); assert.match(html, /0–86400/);
|
||||
const u = browser(); u.start(); await tick(); u.click('settings-display'); await tick(); assert.equal(reads(u).length, 0);
|
||||
const b = await open(); assert.equal(n(b,'dim_seconds').textContent, '300'); assert.equal(n(b,'edit-off_seconds').value, '600');
|
||||
assert.equal(n(b,'settings').hidden, false); assert.equal(b.nodes['network-settings'].hidden, true);
|
||||
const count = b.calls.length; b.click('settings-display'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
|
||||
for (let i = 0; i < 2; ++i) {
|
||||
b.sockets[i].emit('message', {data: Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1), [0,255,i]);
|
||||
b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length, 0);
|
||||
}
|
||||
b.click('settings-serial'); await tick(); assert.ok(b.sockets.every(s => !s.closed)); assert.equal(b.sockets.length, 2);
|
||||
});
|
||||
await test('Display strict snapshots reject extra/missing/type/range/order/status/oversized data and disable stale edits', async () => {
|
||||
for (const value of [null, {}, fixture({generation:0}), fixture({generation:4294967296}), fixture({dim_seconds:'0'}), fixture({off_seconds:86401}), fixture({dim_seconds:600}), fixture({extra:1})]) {
|
||||
const b = await open(value); assert.ok(n(b,'apply').disabled); assert.match(n(b,'detail').textContent, /unavailable|invalid/);
|
||||
}
|
||||
const b = await open(); b.queues[path].push(new Response('x'.repeat(129))); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
|
||||
b.queues[path].push(new Response(JSON.stringify(fixture()), {status:202})); b.click('display-refresh'); await tick(); assert.ok(n(b,'apply').disabled);
|
||||
});
|
||||
await test('Display typed Apply validates integer/zero/timeout order; bounded POST carries selected generation and CSRF', async () => {
|
||||
const b = await open();
|
||||
for (const [dim,off] of [['-1','600'],['1.5','600'],['1e2','600'],['01','600'],['86401','0'],['600','600'],['601','600'],['','600']]) {
|
||||
n(b,'edit-dim_seconds').value = dim; n(b,'edit-off_seconds').value = off; b.click('display-apply'); await tick(); assert.equal(posts(b).length,0);
|
||||
}
|
||||
n(b,'edit-dim_seconds').value = '0'; n(b,'edit-off_seconds').value = '86400'; b.queues[op].push(ack('apply')); b.click('display-apply'); await tick();
|
||||
const post = posts(b)[0]; assert.deepEqual(JSON.parse(post.body), {action:'apply',generation:7,dim_seconds:0,off_seconds:86400});
|
||||
assert.equal(post.headers['X-CSRF-Token'],token); assert.equal(post.mode,'cors'); assert.ok(post.body.length <= 256);
|
||||
assert.ok(n(b,'apply').disabled); assert.equal(n(b,'values').hidden,false);
|
||||
b.click('display-apply'); await tick(); assert.equal(posts(b).length,1);
|
||||
await complete(b,'apply'); assert.equal(reads(b).length,2); assert.equal(n(b,'apply').disabled,false); assert.match(n(b,'operation-detail').textContent,/completed/);
|
||||
});
|
||||
await test('Display explicit Save/Load/Defaults/Reset use working generation not drafts; only Reset confirms', async () => {
|
||||
for (const action of ['save','load','defaults','reset']) {
|
||||
const b = await open(); n(b,'edit-dim_seconds').value='123'; let confirmations=0;
|
||||
b.window.confirm=()=>{++confirmations; return false;};
|
||||
if (action==='reset') {b.click('display-reset'); await tick(); assert.equal(posts(b).length,0); assert.equal(confirmations,1);}
|
||||
b.window.confirm=()=>{++confirmations; return true;}; b.queues[op].push(ack(action)); b.click('display-'+action); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7}); assert.equal(confirmations,action==='reset'?2:0);
|
||||
await complete(b,action,action==='load'?'loaded_defaults':'ok'); assert.equal(reads(b).length,2);
|
||||
}
|
||||
});
|
||||
await test('Display terminal failure/conflict/cancellation refreshes once without replay or success claims', async () => {
|
||||
for (const state of ['failed','conflict','cancelled']) {
|
||||
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick(); await complete(b,'save',state);
|
||||
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.doesNotMatch(n(b,'operation-detail').textContent,/Operation completed/);
|
||||
}
|
||||
});
|
||||
await test('Display ten-poll and fifteen-second deadline bounds include delayed session work', async () => {
|
||||
const b=await open(); b.queues[op].push(ack('save')); b.click('display-save'); await tick();
|
||||
for(let i=0;i<10;i++){b.queues[op].push(reply('save')); b.fire(1000); await tick();}
|
||||
assert.equal(gets(b).length,10); assert.equal(posts(b).length,1); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
|
||||
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick();
|
||||
const d=deferred(); c.queues['/api/session'].push(d.promise); c.fire(1000); await tick(); c.elapse(15000); c.fire(15000); await tick();
|
||||
d.resolve(session({role:'admin',username:'alice'})); await tick(); assert.equal(gets(c).length,0); assert.equal(posts(c).length,1);
|
||||
});
|
||||
await test('Display lost ACK/result replacement/same-ID action mismatch preserve uncertainty and stop automatic following', async () => {
|
||||
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('display-save'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.match(n(b,'operation-detail').textContent,/unknown/);
|
||||
b.queues[op].push(reply('save','ok')); b.queues[path].push(json(fixture())); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
|
||||
for(const changed of [reply('save','pending',43), reply('reset','ok',42)]) {
|
||||
const c=await open(); c.queues[op].push(ack('save')); c.click('display-save'); await tick(); c.queues[op].push(changed); c.fire(1000); await tick();
|
||||
assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000)); assert.match(n(c,'operation-detail').textContent,/unknown/); assert.equal(posts(c).length,1);
|
||||
}
|
||||
});
|
||||
await test('Display operation rejects malformed status/schema/ID/action/state and impossible loaded-defaults replies', async () => {
|
||||
for(const r of [reply('save','pending',42,200),reply('save','ok',42,202),reply('reset','pending',42,202),new Response(JSON.stringify({id:42,action:'save',state:'pending',extra:1}),{status:202})]) {
|
||||
const b=await open(); b.queues[op].push(r); b.click('display-save'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/); assert.equal(gets(b).length,0);
|
||||
}
|
||||
const b=await open(); b.queues[op].push(reply('save','loaded_defaults')); b.click('display-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/unknown/);
|
||||
});
|
||||
await test('Display navigation fences pending read/POST/result and never resumes/replays on return', async () => {
|
||||
for(const stage of ['snapshot','post','result']) {
|
||||
const b=await open(); const d=deferred();
|
||||
if(stage==='snapshot'){b.queues[path].push(d.promise); b.click('display-refresh');}
|
||||
else if(stage==='post'){b.queues[op].push(d.promise); b.click('display-save');}
|
||||
else {b.queues[op].push(ack('save')); b.click('display-save'); await tick(); b.queues[op].push(d.promise); b.fire(1000);}
|
||||
await tick(); b.click('settings-serial'); await tick();
|
||||
const count=posts(b).length; d.resolve(stage==='snapshot'?json(fixture({generation:99})):stage==='post'?ack('save'):reply('save','ok')); await tick();
|
||||
assert.equal(n(b,'edit-dim_seconds').value,''); assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
b.queues[path].push(json(fixture())); b.click('settings-display'); await tick(); assert.equal(posts(b).length,count); assert.ok(b.sockets.every(s=>!s.closed));
|
||||
}
|
||||
});
|
||||
await test('Display endpoint401 and identity replacement close both routes; stale401 after navigation cannot expire current view', async () => {
|
||||
for(const route of [path,op]) {
|
||||
const b=await open(); b.queues[route].push(failure(401)); b.click(route===path?'display-refresh':'display-result'); await tick();
|
||||
assert.deepEqual(b.redirects,['/login']); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,'');
|
||||
}
|
||||
const b=await open(); const d=deferred(); b.queues[path].push(d.promise); b.click('display-refresh'); await tick(); b.click('settings-serial'); await tick(); d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0);
|
||||
const c=await open(); c.queues['/api/session'].push(session({role:'admin',username:'replacement'})); c.click('display-save'); await tick(); assert.deepEqual(c.redirects,['/']); assert.equal(posts(c).length,0);
|
||||
});
|
||||
await test('Display pagehide/expiry/logout fence drafts and in-flight work without backend cancellation claims', async () => {
|
||||
for(const event of ['pagehide','expiry','logout']) {
|
||||
const b=await open(); const d=deferred(); b.queues[op].push(d.promise); b.click('display-save'); await tick();
|
||||
if(event==='pagehide') b.emit('pagehide'); else if(event==='expiry') b.window.sakSessionExpired(); else {b.queues['/api/logout'].push(new Response(null,{status:204})); b.click('sign-out');}
|
||||
await tick(); d.resolve(ack('save')); await tick(); assert.ok(b.sockets.every(s=>s.closed)); assert.equal(n(b,'edit-dim_seconds').value,''); assert.equal(posts(b).length,1);
|
||||
assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user