Implement HTTPS lifecycle and reboot controls

This commit is contained in:
2026-09-13 17:24:00 +02:00
parent 737bd29f9e
commit 36e80811e8
24 changed files with 1392 additions and 75 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': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-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': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -1372,5 +1372,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
})().catch(error => { console.error(error); process.exitCode = 1; });
+4 -4
View File
@@ -59,7 +59,7 @@ def check_layout(html):
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
assert ids[ident]['tag'] == 'dl'
assert 'settings-values' in classes(ids[ident])
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings'):
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings', 'lifecycle-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')
@@ -70,9 +70,9 @@ def check_layout(html):
ancestor(n, 'settings-edit')
except AssertionError:
ancestor(n, 'serial-edit')
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh'):
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh', 'lifecycle-refresh'):
assert ids[ident]['text'] == 'Refresh'
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result'):
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result', 'lifecycle-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'])
@@ -101,7 +101,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 six settings views')
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all seven settings views')
def check_browser_layout(html, tmp, executable):
+97
View File
@@ -0,0 +1,97 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
const path = '/api/settings/lifecycle', op = path + '-operation';
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,...extra});
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
async function refresh(b,v=fixture()) { b.queues[path].push(json(v)); b.click('lifecycle-refresh'); await tick(); }
async function submit(b,action='stop') { b.queues[op].push(reply('pending',42,202,action)); b.click('lifecycle-'+action); await tick(); }
await test('Lifecycle admin-only view and existing Network link are read-only and preserve both terminal drains', async()=>{
const u=browser(); u.start(); await tick(); u.click('settings-lifecycle'); await tick(); assert.ok(!u.calls.some(c=>c.url===path));
const b=await open(); assert.equal(b.nodes['lifecycle-settings'].hidden,false); assert.equal(posts(b).length,0);
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('lifecycle-network'); await tick(); assert.equal(b.nodes['lifecycle-settings'].hidden,true); assert.equal(b.nodes['network-settings'].hidden,false);
assert.equal(posts(b).length,0); assert.ok(!b.calls.some(c=>c.url==='/api/settings/network-operation'&&c.method==='POST'));
assert.match(html,/USB remains UART1 serial access, not a web administration console/); assert.match(html,/Native USB preserves network-independent UART1 serial access, not Wi-Fi administration/);
});
await test('Lifecycle confirmations name all-client loss/reboot USB interruption and submit exact generation once',async()=>{
for(const action of ['stop','restart','reboot']) {
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;}; b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,0);
assert.match(confirmation,/ALL/); assert.match(confirmation,/unsaved|Unsaved/); assert.match(confirmation,/UART0/); assert.match(confirmation,/no automatic retry/);
assert.match(confirmation,action==='reboot'?/USB and UART operation are interrupted/:/BOTH browser terminal routes/);
b.window.confirm=()=>true; await submit(b,action); assert.equal(posts(b).length,1); assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7});
assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64)); b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,1);
assert.match(n(b,'operation-detail').textContent,/do not resubmit/); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 && !t.interval));
b.queues[op].push(reply('ok',42,200,action)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/not proof of peer receipt/);
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
}
});
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
}
for(const v of [fixture({transitioning:true,controllable:false}),fixture({generation:4294967295,controllable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
});
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already have occurred/:/before lifecycle admission/);}
assert.equal(posts(b).length,1);
});
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{
for(const lost of [true,false]) {
const b=await open(); if(lost) { b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-stop'); await tick(); } else await submit(b);
b.queues[op].push(reply('ok',lost?42:43)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/);
await refresh(b); assert.ok(n(b,'stop').disabled); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,1);
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(posts(b).length,1);
}
const b=await open(); await submit(b); b.queues[op].push(reply('ok',42,200,'reboot')); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/); assert.ok(n(b,'stop').disabled);
});
await test('Lifecycle whole-request deadlines fence late session/read/ACK/body completion without mutation retry',async()=>{
for(const stage of ['session','snapshot','ack','body']) {
const b=await open(), d=deferred();
if(stage==='session') b.queues['/api/session'].push(d.promise);
if(stage==='snapshot') b.queues[path].push(d.promise);
if(stage==='ack') b.queues[op].push(d.promise);
if(stage==='body') b.queues[op].push({status:202,ok:true,headers:new Headers(),body:{getReader:()=>({read:()=>d.promise,cancel:async()=>{}})}});
b.click(stage==='snapshot'?'lifecycle-refresh':'lifecycle-stop'); await tick(); b.fire(15000); await tick(); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
d.resolve(stage==='session'?session({role:'admin'}):stage==='snapshot'?json(fixture({generation:99})):stage==='body'?{done:true}:reply('pending',42,202)); await tick();
assert.equal(posts(b).length,stage==='session'||stage==='snapshot'?0:1); assert.ok(n(b,'stop').disabled); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
}
});
await test('Lifecycle navigation/pagehide fences late reads/results/401 and never restores a mutation',async()=>{
for(const pagehide of [false,true]) {
const b=await open(), d=deferred(); b.queues[op].push(d.promise); b.click('lifecycle-stop'); await tick();
if(pagehide) b.emit('pagehide'); else b.click('settings-serial');
d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0); assert.equal(posts(b).length,1);
if(pagehide) {b.emit('pageshow');await tick();} else {b.queues[path].push(json(fixture()));b.click('settings-lifecycle');await tick();}
assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled);
}
});
await test('Lifecycle original-login switch/revocation denies mutation; fresh login document has no old result restore',async()=>{
for(const response of [failure(401),session({role:'admin',username:'another'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
const b=await open(); b.queues['/api/session'].push(response); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,0); assert.ok(b.sockets.every(s=>s.closed));
}
const b=await open(); b.queues[op].push(failure(401)); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
const fresh=await open(); assert.equal(posts(fresh).length,0); assert.equal(n(fresh,'stop').disabled,false);
});
await test('Lifecycle accepted restart followed by expired login closes both routes without replay or result restore',async()=>{
const b=await open(); await submit(b,'restart');
b.queues[op].push(failure(401)); b.click('lifecycle-result'); await tick();
assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
assert.ok(b.sockets.every(s=>s.closed));
const fresh=await open(fixture({generation:9}));
assert.equal(posts(fresh).length,0);
assert.ok(!fresh.calls.some(c=>c.url===op));
assert.equal(n(fresh,'restart').disabled,false);
});
await test('Lifecycle malformed/oversized result and busy response retain uncertainty with safe error text',async()=>{
for(const response of [failure(503),new Response('x'.repeat(97),{status:202}),reply('ok',42,202),reply('pending',0,202),new Response(JSON.stringify({id:42,action:'stop',state:'pending',secret:'bad'}),{status:202})]) {
const b=await open(); b.queues[op].push(response); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled); assert.doesNotMatch(n(b,'operation-detail').textContent,/SECRET ERROR BODY|secret|bad/); assert.match(n(b,'operation-detail').textContent,/Outcome may be unknown/);
}
});
};