'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,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable: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('HTTPS identity rotation confirms public fingerprint and both generations, shares pending gate, and requires renewed trust/login',async()=>{ const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;}; b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,0); for(const text of ['ab'.repeat(32),'generation 11','generation 7','UART0','trust','ALL','SSH','USB','sign in freshly']) assert.ok(confirmation.includes(text),text); assert.match(n(b,'identity').textContent,/Stored HTTPS identity generation 11/); b.window.confirm=()=>true; await submit(b,'rotate'); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,identity_generation:11}); b.click('lifecycle-stop'); b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,1); b.queues[op].push(reply('failed',42,200,'rotate')); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/identity may already be persisted/); assert.match(n(b,'operation-detail').textContent,/No rollback/); assert.ok(n(b,'rotate').disabled); await refresh(b); assert.equal(n(b,'rotate').disabled,false); assert.equal(posts(b).length,1); }); await test('HTTPS identity metadata rejects missing generations, secrets and malformed fingerprints; unavailable identity does not disable ordinary service controls',async()=>{ for(const v of [fixture({identity_generation:undefined}),fixture({identity_generation:0}),fixture({identity_generation:4294967295}),fixture({fingerprint:'