Files
ESP32_Serial_Swiss_Army_Knife/tests/web_ui_session/ssh.cjs
T

83 lines
10 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,identity_generation:3,algorithm:'ecdsa-sha2-nistp256',fingerprint:'SHA256:'+'A'.repeat(43),rotatable:!extra.transitioning&&extra.generation!==4294967295,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[8].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 host rotation confirms fingerprint and both generations, preserves HTTPS and never replays',async()=>{
const b=await open(); let text=''; b.window.confirm=t=>{text=t;return false;}; b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
for(const pattern of [/SHA256:/,/identity generation 3/,/service generation 7/,/ALL SSH sessions/,/known_hosts/,/trusted UART0/,/ssh host-key info/,/HTTPS stays accessible/,/persistence fails/])assert.match(text,pattern);
await submit(b,'rotate');assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,target:0,identity_generation:3});
b.click('ssh-rotate');await tick();assert.equal(posts(b).length,1);assert.equal(b.sockets.length,2);
b.queues[op].push(reply('failed',42,200,'rotate'));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/persisted even if restart failed/);
await refresh(b,fixture({identity_generation:4}));assert.equal(posts(b).length,1);
});
await test('SSH host metadata malformed or unavailable fails closed for rotation only',async()=>{
for(const extra of [{identity_generation:0,fingerprint:''},{identity_generation:4294967295},{fingerprint:'private-key'},{algorithm:'ssh-rsa'}]){
const b=await open(fixture(extra));assert.ok(n(b,'rotate').disabled);b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
}
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));assert.ok(n(b,'rotate').disabled);assert.equal(n(b,'stop').disabled,false);
});
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);
});
};