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:
2026-09-09 10:15:23 +02:00
parent 60d9c54bb4
commit d9ec3c08de
26 changed files with 1164 additions and 81 deletions
+2 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { 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': []};
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': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1251,5 +1251,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
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});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+120
View File
@@ -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, /086400/);
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));
}
});
};
+9 -9
View File
@@ -45,10 +45,10 @@ def check_layout(html):
if cls in classes(node):
return node
raise AssertionError(cls)
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings'):
nodes = list(descendants(ids[ident]))
assert not any(n['tag'] == 'pre' for n in nodes)
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
@@ -59,9 +59,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result'):
assert ids[ident]['text'] == 'Check Operation Result'
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
assert 'settings-check' in classes(ids[ident]['parent'])
@@ -90,7 +90,7 @@ def check_layout(html):
):
assert rule in css, rule
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all three settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
def check_browser_layout(html, tmp, executable):
@@ -103,13 +103,13 @@ def check_browser_layout(html, tmp, executable):
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
probe = r'''
const cases = [];
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings']) {
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
cases.push(new Promise(resolve => {
frame.onload = () => {
const d = frame.contentDocument, win = frame.contentWindow;
d.getElementById('serial-settings').hidden = false;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
for (const id of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
@@ -166,6 +166,6 @@ def check_browser_layout(html, tmp, executable):
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 9
assert len(results) == 12
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')