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
+12 -3
View File
@@ -14,7 +14,15 @@ const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => se
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': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
const fits = [];
const fits = [], uploads = [];
class Upload {
constructor() { this.upload = {}; this.headers = {}; uploads.push(this); }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(key, value) { this.headers[key] = value; }
send(file) { this.file = file; }
abort() { this.aborted = true; this.onabort?.(); }
reply(status, value) { this.status = status; this.responseText = typeof value === 'string' ? value : JSON.stringify(value); this.onload?.(); }
}
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
@@ -60,7 +68,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, XMLHttpRequest: Upload,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
@@ -83,7 +91,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, uploads,
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
@@ -1399,5 +1407,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
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});
await require('./firmware.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; });