Add admin firmware upload support

Implement authenticated HTTPS OTA uploads with bounded streaming, image
validation, reboot coordination, and lifecycle exclusion. Add the admin
UI,
regression tests, and Phase 10 acceptance documentation.
This commit is contained in:
2026-09-18 22:22:11 +02:00
parent 4f628a4098
commit 31a22eba06
31 changed files with 1442 additions and 88 deletions
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const snapshot = {generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true};
const file = (extra={}) => ({name:'firmware.bin',size:4194304,type:'',...extra});
const n = (b,id) => b.document.getElementById('firmware-'+id);
async function open() {
const b=await adminBrowser(); b.click('select-settings'); await tick();
b.queues['/api/settings/lifecycle'].push(json(snapshot)); b.click('settings-lifecycle'); await tick();
n(b,'file').files=[file()]; return b;
}
async function upload(b) { b.click('firmware-upload'); await tick(); return b.uploads.at(-1); }
await test('Firmware admin-only card validates filename hint/size, not unreliable browser MIME, and requires explicit confirmation', async()=>{
assert.match(html,/accept="\.bin"/); assert.match(html,/firmware\.bin built for this device/); assert.match(html,/Saved settings are kept/);
const u=browser(); u.start(); await tick(); n(u,'file').files=[file()]; await upload(u); assert.equal(u.uploads.length,0);
for(const f of [null,file({name:'flash.txt'}),file({size:0}),file({size:4194305}),file({size:NaN}),file({size:1.5})]) {
const b=await open(); n(b,'file').files=f?[f]:[]; await upload(b); assert.equal(b.uploads.length,0); assert.match(n(b,'detail').textContent,/nonempty .bin/);
}
const b=await open(); let warning=''; b.window.confirm=s=>{warning=s;return false;}; await upload(b); assert.equal(b.uploads.length,0);
assert.equal(warning,'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.');
b.window.confirm=()=>true; n(b,'file').files=[file({name:'APP.BIN',type:'text/plain'})]; const x=await upload(b); assert.ok(x.file);
});
await test('Firmware sends original File raw with current CSRF and browser-managed headers; one flight and lifecycle controls gated',async()=>{
const b=await open(), f=n(b,'file').files[0], x=await upload(b);
assert.equal(x.method,'POST'); assert.equal(x.url,'/api/firmware'); assert.equal(x.file,f);
assert.deepEqual(x.headers,{'Content-Type':'application/octet-stream','X-CSRF-Token':token}); assert.equal(x.timeout,180000);
assert.ok(n(b,'file').disabled && n(b,'upload').disabled);
for(const action of ['stop','restart','rotate','reboot']) {assert.ok(b.nodes['lifecycle-'+action].disabled); b.click('lifecycle-'+action);}
await upload(b); assert.equal(b.uploads.length,1); assert.ok(!b.calls.some(c=>c.url.endsWith('lifecycle-operation')&&c.method==='POST'));
x.upload.onprogress({lengthComputable:true,loaded:25,total:100}); assert.equal(n(b,'progress').value,25);
x.upload.onprogress({lengthComputable:true,loaded:100,total:100}); assert.match(n(b,'detail').textContent,/Validating firmware/);
for(const e of [{lengthComputable:false},{lengthComputable:true,loaded:NaN,total:100},{lengthComputable:true,loaded:1,total:0}]) { x.upload.onprogress(e); assert.match(n(b,'detail').textContent,/unavailable/); }
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/accepted; rebooting/); assert.match(n(b,'detail').textContent,/Reconnect and sign in/);
await upload(b); assert.equal(b.uploads.length,1); assert.ok(n(b,'upload').disabled);
x.onerror(); assert.match(n(b,'detail').textContent,/accepted; rebooting/);
});
await test('Firmware fences delayed session validation, changed identity/role/CSRF and logout before sending',async()=>{
for(const response of [failure(401),session({role:'user'}),session({role:'admin',username:'changed'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
const b=await open(); b.queues['/api/session'].push(response); await upload(b); assert.equal(b.uploads.length,0); assert.ok(b.redirects.length);
}
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); await upload(b); await upload(b); assert.equal(b.uploads.length,0);
b.click('sign-out'); await tick(); d.resolve(session({role:'admin'})); await tick(); assert.equal(b.uploads.length,0);
});
await test('Firmware session loss/pagehide/logout abort once and fence all late progress/success/error/401 callbacks',async()=>{
for(const end of ['logout','pagehide','expiry','identity']) {
const b=await open(), x=await upload(b);
if(end==='logout') b.click('sign-out');
else if(end==='pagehide') b.emit('pagehide');
else if(end==='expiry') x.reply(401,{error:'authentication_required'});
else { b.queues['/api/session'].push(session({role:'admin',username:'changed'})); b.click('select-serial'); b.click('connection-toggle'); await tick(); b.click('connection-toggle'); }
await tick(); assert.ok(x.aborted,end); const detail=n(b,'detail').textContent, redirects=b.redirects.length;
assert.match(detail,/may already be installed/);
x.upload.onprogress({lengthComputable:true,loaded:50,total:100}); x.reply(200,{ok:true,rebooting:true}); x.reply(401,{}); x.onerror();
assert.equal(n(b,'detail').textContent,detail); assert.equal(b.redirects.length,redirects); assert.equal(b.uploads.length,1);
}
});
await test('Firmware navigation retains single upload and lifecycle gate without resending',async()=>{
const b=await open(), x=await upload(b); b.click('settings-serial'); await tick(); b.click('settings-lifecycle'); await tick();
assert.ok(!x.aborted); assert.ok(b.nodes['lifecycle-reboot'].disabled); await upload(b); assert.equal(b.uploads.length,1);
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/rebooting/);
});
await test('Firmware maps every backend error safely, never displays arbitrary response text or retries',async()=>{
const groups={400:['invalid_request','invalid_firmware','firmware_incomplete'],403:['origin','csrf','admin_required'],408:['firmware_timeout'],413:['firmware_too_large'],415:['firmware_content_type'],500:['firmware_write_failed','firmware_commit_failed'],503:['unavailable','busy','firmware_unavailable','firmware_resources']};
for(const [status,codes] of Object.entries(groups)) for(const error of codes) {
const b=await open(), x=await upload(b); x.reply(Number(status),{error}); assert.doesNotMatch(n(b,'detail').textContent,/Update status unknown/); assert.ok(n(b,'detail').textContent.length > 0 && n(b,'detail').textContent.length < 100); assert.equal(b.uploads.length,1);
}
for(const response of ['SECRET ERROR BODY','x'.repeat(129),{ok:true},{ok:true,rebooting:true,secret:'bad'},{error:'<script>'},{error:'toString'}]) {
const b=await open(), x=await upload(b); x.reply(200,response); assert.match(n(b,'detail').textContent,/Update status unknown/); assert.doesNotMatch(n(b,'detail').textContent,/SECRET|script|bad/); assert.ok(n(b,'upload').disabled);
}
});
await test('Firmware refuses competing lifecycle work and stale prior-upload callbacks cannot affect an explicit retry',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.click('lifecycle-reboot'); await tick();
await upload(b); assert.equal(b.uploads.length,0); assert.ok(n(b,'upload').disabled);
b.queues['/api/settings/lifecycle-operation'].push(new Response(JSON.stringify({id:1,action:'reboot',state:'pending'}),{status:202}));
d.resolve(session({role:'admin'})); await tick(); await upload(b); assert.equal(b.uploads.length,0);
const retry=await open(), first=await upload(retry); first.reply(503,{error:'busy'});
assert.equal(n(retry,'upload').disabled,false); n(retry,'file').files=[file()]; const second=await upload(retry);
const message=n(retry,'detail').textContent; first.reply(401,{}); first.onerror(); first.upload.onprogress({lengthComputable:true,loaded:1,total:2});
assert.equal(retry.redirects.length,0); assert.equal(n(retry,'detail').textContent,message); assert.ok(!second.aborted);
second.reply(200,{ok:true,rebooting:true}); assert.match(n(retry,'detail').textContent,/accepted; rebooting/);
const preflight=await open(); preflight.queues['/api/session'].push(()=>{throw Error('offline');}); await upload(preflight);
assert.equal(preflight.uploads.length,0); assert.match(n(preflight,'detail').textContent,/no upload sent/); assert.equal(n(preflight,'upload').disabled,false);
});
await test('Firmware network/timeout/abort and lost acknowledgement are uncertain, locked, and never auto-replayed',async()=>{
for(const event of ['onerror','ontimeout','onabort']) {
const b=await open(), x=await upload(b); x[event](); assert.match(n(b,'detail').textContent,/Reconnect and check/); assert.ok(n(b,'upload').disabled);
await upload(b); assert.equal(b.uploads.length,1); x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/Update status unknown/);
}
const fresh=await open(); assert.equal(fresh.uploads.length,0); assert.equal(n(fresh,'upload').disabled,false);
});
};