Provide admin-only SSH status plus generation-safe start, stop, and single-session disconnect operations through the bounded dispatcher. Include Settings UI coverage, lifecycle safeguards, and host-side regression tests.
69 lines
8.7 KiB
JavaScript
69 lines
8.7 KiB
JavaScript
'use strict';
|
|
const assert = require('node:assert/strict');
|
|
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
|
const path='/api/settings/ssh', op=path+'-operation';
|
|
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
|
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
|
|
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
|
const n=(b,id)=>b.nodes['ssh-'+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-ssh');await tick();return b;}
|
|
function select(b,id=9){n(b,'target').value=String(id);n(b,'target').change();}
|
|
async function refresh(b,v=fixture()){b.queues[path].push(json(v));b.click('ssh-refresh');await tick();}
|
|
async function submit(b,action='disconnect'){if(action==='disconnect')select(b);b.window.confirm=()=>true;b.queues[op].push(reply('pending',42,202,action));b.click('ssh-'+action);await tick();}
|
|
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
|
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
|
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
|
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);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);}
|
|
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
|
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
|
});
|
|
await test('SSH exact confirmed start stop targeted disconnect requests and manual bounded result flow',async()=>{
|
|
for(const action of ['start','stop','disconnect']){
|
|
const b=await open(fixture({running:action!=='start'}));if(action==='disconnect')select(b);
|
|
let confirmation='';b.window.confirm=text=>{confirmation=text;return false;};b.click('ssh-'+action);await tick();assert.equal(posts(b).length,0);
|
|
assert.match(confirmation,/Settings and host identity are unchanged/);assert.match(confirmation,action==='stop'?/ALL SSH sessions/:action==='start'?/port 22/:/only SSH session 9/);
|
|
await submit(b,action);assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7,target:action==='disconnect'?9:0});assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64));
|
|
b.click('ssh-'+action);await tick();assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Check Operation Result/);
|
|
b.queues[op].push(reply('ok',42,200,action));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/execution time/);assert.ok(n(b,'start').disabled&&n(b,'stop').disabled&&n(b,'disconnect').disabled);
|
|
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
|
}
|
|
});
|
|
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
|
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
|
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
|
b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);select(b);assert.equal(n(b,'disconnect').disabled,false);
|
|
}
|
|
const b=await open();select(b);await refresh(b,fixture({sessions:[row(10,{route:2}),row()]}));assert.equal(n(b,'target').value,'9');assert.equal(n(b,'disconnect').disabled,false);
|
|
});
|
|
await test('SSH invalid/unavailable/transition/exhausted snapshots fail closed without affecting terminals',async()=>{
|
|
const invalid=[{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({sessions:[row(),row()]}),fixture({sessions:[row(9,{name_hex:'zz'})]}),fixture({sessions:[row(9,{route:3})]}),fixture({sessions:[row(9,{id:0})]})];
|
|
for(const v of invalid){const b=await open(v);assert.ok(n(b,'disconnect').disabled&&n(b,'stop').disabled&&n(b,'start').disabled);assert.match(n(b,'detail').textContent,/unavailable/);assert.equal(posts(b).length,0);}
|
|
for(const extra of [{transitioning:true},{generation:4294967295}]){const b=await open(fixture(extra));assert.ok(n(b,'stop').disabled&&n(b,'start').disabled);}
|
|
const b=await open();select(b);b.queues[path].push(failure(503));b.click('ssh-refresh');await tick();assert.ok(n(b,'disconnect').disabled);assert.equal(b.sockets.length,2);
|
|
});
|
|
await test('SSH single-flight whole-read deadline and navigation fence late snapshots without replay',async()=>{
|
|
for(const stage of ['session','snapshot']){
|
|
const b=await open(), d=deferred();select(b);b.queues[stage==='session'?'/api/session':path].push(d.promise);b.click('ssh-refresh');await tick();b.click('ssh-stop');b.click('ssh-refresh');await tick();assert.equal(posts(b).length,0);
|
|
b.fire(15000);await tick();assert.match(n(b,'detail').textContent,/timed out/);await refresh(b);d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();select(b);await submit(b);assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
|
}
|
|
const b=await open(),d=deferred();b.queues[path].push(d.promise);b.click('ssh-refresh');await tick();b.click('settings-serial');await tick();d.resolve(json(fixture()));await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(n(b,'values').children.length,0);assert.equal(posts(b).length,0);
|
|
});
|
|
await test('SSH captures confirmation before delayed auth and handles conflict failed cancelled without replay',async()=>{
|
|
for(const state of ['conflict','failed','cancelled']){
|
|
const b=await open(),d=deferred();select(b);b.window.confirm=()=>true;b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);d.resolve(session({role:'admin'}));await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
|
b.queues[op].push(reply(state));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='conflict'?/No action admitted/:state==='failed'?/may still finish/:/Rejected before execution/);assert.equal(posts(b).length,1);
|
|
}
|
|
});
|
|
await test('SSH lost acknowledgement/result replacement and timeout retain uncertainty across navigation',async()=>{
|
|
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('ssh-disconnect');await tick();assert.ok(n(b,'disconnect').disabled);b.click('select-serial');b.queues[path].push(json(fixture()));b.click('select-settings');await tick();assert.equal(posts(b).length,1);
|
|
b.queues[op].push(reply('ok',41));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Acknowledgement was lost/);
|
|
b.queues[op].push(reply('ok',43));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Previous result replaced/);
|
|
await refresh(b);const d=deferred();select(b);b.queues[op].push(d.promise);b.click('ssh-disconnect');await tick();b.fire(15000);await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.ok(n(b,'stop').disabled);d.resolve(reply('pending',44,202));await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.equal(posts(b).length,2);
|
|
});
|
|
await test('SSH revoked session and pagehide cancel UI work without affecting newer context via late401',async()=>{
|
|
const b=await open();select(b);b.queues['/api/session'].push(failure(401));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);
|
|
const c=await open(),d=deferred();c.queues[path].push(d.promise);c.click('ssh-refresh');await tick();c.emit('pagehide');d.resolve(failure(401));await tick();assert.equal(posts(c).length,0);assert.equal(n(c,'values').children.length,0);
|
|
});
|
|
};
|