Add Typed Admin Network Settings
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const {script, loader} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const {script, loader, html} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const token = 'a'.repeat(64);
|
||||
const json = value => new Response(JSON.stringify(value));
|
||||
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
|
||||
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
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': []};
|
||||
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': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -1225,5 +1225,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
|
||||
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
});
|
||||
await require('./network.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; });
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||
const path = '/api/settings/network', operation = path + '-operation';
|
||||
const fixture = () => ({wifi: {generation: 7, enabled_at_boot: true,
|
||||
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
|
||||
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
|
||||
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
|
||||
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
|
||||
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
|
||||
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
|
||||
const ack = action => reply(42, 'pending', action, 202);
|
||||
const n = (b, id) => b.nodes['network-' + id];
|
||||
const input = (b, id, value, event = 'input') => { n(b, id).value = value; n(b, id)[event](); };
|
||||
const target = (b, value) => input(b, 'target', value, 'change');
|
||||
const secret = (b, value = 'a safe PSK') => { input(b, 'password-mode', 'replace', 'change'); input(b, 'password', value); };
|
||||
const clean = b => { assert.equal(n(b, 'password').value, ''); assert.equal(n(b, 'password-mode').value, 'keep'); assert.ok(n(b, 'password').disabled); assert.ok(![...b.timers.values()].some(t => t.ms === 60000)); };
|
||||
const posts = b => b.calls.filter(c => c.url === operation && c.method === 'POST');
|
||||
const gets = b => b.calls.filter(c => c.url === operation && c.method === 'GET');
|
||||
const reads = b => b.calls.filter(c => c.url === path);
|
||||
const safe = (b, text = 'SECRET') => { for (const node of Object.values(b.nodes)) assert.ok(!node.textContent.includes(text)); };
|
||||
async function open(value = fixture()) {
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json(value)); b.click('settings-network'); await tick();
|
||||
return b;
|
||||
}
|
||||
async function complete(b, action, state = 'accepted', value = fixture(), error = 0) {
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
|
||||
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
|
||||
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
|
||||
// Allow at least one excess character so native maxlength cannot silently turn an oversized paste into a valid credential/SSID.
|
||||
assert.match(html, /id="network-password" type="password" maxlength="64" autocomplete="new-password" disabled/);
|
||||
assert.match(html, /id="network-ssid" maxlength="256"/);
|
||||
for (const text of ['NOT browser drafts', 'No Wi-Fi defaults/reset', 'Accepted is NOT connected', 'UART0', 'native USB', 'STA-only responder', 'not secure flash erasure', 'blank never clears', '60 seconds']) assert.ok(html.toLowerCase().includes(text.toLowerCase()), text);
|
||||
assert.ok(!html.includes('id="network-wifi-defaults"') && !html.includes('id="network-wifi-reset"'));
|
||||
});
|
||||
await test('Network is admin-only; navigation retains terminals, writer lease, hidden binary drains and no routine confirmation', async () => {
|
||||
const u = browser(); u.start(); await tick(); u.click('settings-network'); await tick(); assert.equal(reads(u).length, 0);
|
||||
const b = await open(); const count = b.calls.length;
|
||||
b.window.confirm = () => { throw new Error('No fetch confirmations'); };
|
||||
b.click('settings-network'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.equal(n(b, 'edit').hidden, false); assert.equal(b.nodes['serial-settings-content'].hidden, true); assert.equal(b.nodes['account-settings'].hidden, true);
|
||||
assert.match(n(b, 'summary').textContent, /STA 3/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
|
||||
assert.equal(n(b, 'password-clear').hidden, true); clean(b);
|
||||
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('not to terminal'); assert.equal(b.sockets[i].sent.length, 0);
|
||||
}
|
||||
b.click('settings-accounts'); await tick(); b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
assert.match(b.nodes['input-state'].textContent, /writer lease retained/);
|
||||
});
|
||||
await test('Network summary shows readable quoted ASCII SSIDs and lossless hex fallback', async () => {
|
||||
const v = fixture(); v.wifi.ap.ssid = 'ESP32-SAK-FA7059';
|
||||
const names = ['Moppelkotze', 'Voltage-legacy', 'TKRZ', ''];
|
||||
v.wifi.profiles.forEach((p, i) => { p.ssid = names[i]; });
|
||||
const b = await open(v);
|
||||
for (const name of [v.wifi.ap.ssid, ...names]) assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
|
||||
assert.ok(!n(b, 'summary').textContent.includes('SSID hex:'));
|
||||
for (const name of [' leading and trailing ', 'a"b\\c', '~'.repeat(32)]) {
|
||||
v.wifi.ap.ssid = name; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
|
||||
assert.equal(n(b, 'ssid').value, name);
|
||||
}
|
||||
for (const bytes of ['A\0\xff', '\r\n\t', '\x7f', '\xc3\xa9']) {
|
||||
v.wifi.ap.ssid = bytes; v.wifi.profiles[0].ssid = bytes;
|
||||
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
const hex = Array.from(bytes, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
|
||||
assert.equal(n(b, 'summary').textContent.split('SSID hex: ' + hex).length - 1, 2);
|
||||
}
|
||||
assert.equal(posts(b).length, 0);
|
||||
});
|
||||
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
|
||||
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
|
||||
v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1,
|
||||
v => v.wifi.ap.policy = 'open', v => v.wifi.ap.channel = 12, v => v.wifi.ap.ssid = '', v => v.wifi.ap.password_configured = false,
|
||||
v => v.wifi.ap.ssid = '\u0100', v => v.wifi.ap.ssid = '\ud800', v => v.wifi.ap.ssid = 'x'.repeat(33),
|
||||
v => v.wifi.profiles.pop(), v => v.wifi.profiles.push(v.wifi.profiles[0]), v => v.wifi.profiles[1].index = 0,
|
||||
v => v.wifi.profiles[1].enabled = 'false', v => v.wifi.profiles[1].priority = 256, v => v.wifi.profiles[1].priority = 0.5,
|
||||
v => v.wifi.profiles[1].security = 'open', v => v.wifi.profiles[1].enabled = true, v => v.wifi.profiles[0].password_configured = false,
|
||||
v => v.wifi.profiles[1].password_configured = true, v => v.wifi.profiles[0].password_configured = 'yes',
|
||||
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
|
||||
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
|
||||
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
|
||||
const b = await open(), before = n(b, 'summary').textContent;
|
||||
for (const change of edits) {
|
||||
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.match(n(b, 'detail').textContent, /stale.*invalid/); assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||
}
|
||||
for (const value of [null, [], {}, {wifi: null}, {wifi: [], runtime: {}, mdns: {}}]) {
|
||||
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
|
||||
}
|
||||
});
|
||||
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
|
||||
const b = await open();
|
||||
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
|
||||
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||
}
|
||||
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
|
||||
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
|
||||
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
|
||||
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
|
||||
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
|
||||
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
|
||||
console.log('Network escaped snapshot fixture bytes:', Buffer.byteLength(encoded));
|
||||
});
|
||||
await test('Network ordinary Unicode text is UTF-8 once, escaped as byte codepoints, while unchanged existing UTF-8 is omitted', async () => {
|
||||
for (const text of ['café', '東京📡', 'é'.repeat(16), 'a"b\\c', '\ufeffoffice']) {
|
||||
const b = await open(); input(b, 'ssid', text); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
const post = posts(b)[0]; assert.ok(post); assert.ok(!/[^\x00-\x7f]/.test(post.body));
|
||||
assert.deepEqual(Buffer.from(JSON.parse(post.body).ssid, 'latin1'), Buffer.from(text, 'utf8'));
|
||||
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
}
|
||||
const v = fixture(); v.wifi.ap.ssid = Buffer.from('café📡', 'utf8').toString('latin1'); const b = await open(v);
|
||||
assert.equal(n(b, 'ssid').value, 'café📡'); n(b, 'channel').value = '7'; b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action: 'wifi-patch', generation: 7, channel: 7});
|
||||
});
|
||||
await test('Network arbitrary bytes, embedded zero, invalid UTF-8 and BOM have lossless mode conversion or explicit refusal', async () => {
|
||||
for (const bytes of ['A\0\xff', '\xc0\xaf', '\xed\xa0\x80', '\xff\xfe', '\r\n\t', '\xef\xbb\xbfhello']) {
|
||||
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
|
||||
if (bytes === '\xef\xbb\xbfhello') { assert.equal(n(b, 'ssid').value, '\ufeffhello'); input(b, 'ssid-mode', 'hex', 'change'); }
|
||||
else {
|
||||
assert.equal(n(b, 'ssid-mode').value, 'hex'); const before = n(b, 'ssid').value;
|
||||
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid-mode').value, 'hex'); assert.equal(n(b, 'ssid').value, before);
|
||||
}
|
||||
input(b, 'ssid', n(b, 'ssid').value + ' 42'); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(JSON.parse(posts(b)[0].body).ssid, bytes + 'B');
|
||||
}
|
||||
const b = await open(); input(b, 'ssid', 'café'); input(b, 'ssid-mode', 'hex', 'change'); assert.equal(n(b, 'ssid').value, '63 61 66 c3 a9');
|
||||
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid').value, 'café');
|
||||
});
|
||||
await test('Network SSID max32 decoded bytes and invalid hex/surrogates never submit or truncate', async () => {
|
||||
for (const text of ['x'.repeat(33), 'é'.repeat(17), '📡'.repeat(9), '\ud800', '\udc00', 'x'.repeat(10000)]) {
|
||||
const b = await open(); input(b, 'ssid', text); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, /Not submitted/);
|
||||
}
|
||||
for (const hex of ['f', 'gg', '0x41', '41 42', '41\t42', 'ff'.repeat(33), 'ff '.repeat(100)]) {
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', hex); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', '00'.repeat(32)); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(JSON.parse(posts(b)[0].body).ssid, '\0'.repeat(32)); assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
|
||||
});
|
||||
await test('Network every byte00..FF round-trips without secret export or UTF-8 reinterpretation', async () => {
|
||||
for (let start = 0; start < 256; start += 32) {
|
||||
const bytes = String.fromCharCode(...Array.from({length:32}, (_, i) => start + i));
|
||||
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
|
||||
if (n(b, 'ssid-mode').value !== 'hex') input(b, 'ssid-mode', 'hex', 'change');
|
||||
const expected = [...bytes].reverse().join(''); input(b, 'ssid', [...expected].map(c => c.charCodeAt(0).toString(16).padStart(2,'0')).join(' '));
|
||||
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
const wire = posts(b)[0].body; assert.ok(!/[^\x00-\x7f]/.test(wire)); assert.equal(JSON.parse(wire).ssid, expected);
|
||||
}
|
||||
});
|
||||
await test('Network four stable profile targets and canonical input number/enum limits are typed and bounded', async () => {
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const b = await open(); target(b, String(i)); input(b, 'ssid', 'profile-' + i); input(b, 'priority', '255'); input(b, 'security', 'wpa3', 'change');
|
||||
n(b, 'enabled').checked = true; n(b, 'enabled').change(); secret(b, 'p'.repeat(63)); b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:i,...(i === 0 ? {} : {enabled:true}),priority:255,security:'wpa3',ssid:'profile-' + i,password:'p'.repeat(63)});
|
||||
assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
|
||||
}
|
||||
for (const [id, values] of [['channel',['0','12','1.0','01','1e1','-1','9999']], ['priority',['-1','256','1.5','1e2','00']], ['security',['open','wpa2','<img>']], ['policy',['open','<img>']]]) {
|
||||
for (const value of values) {
|
||||
const b = await open(); if (['priority','security'].includes(id)) target(b,'1');
|
||||
input(b,id,value,['policy','security'].includes(id) ? 'change' : 'input'); b.click('network-apply'); await tick(); assert.equal(posts(b).length,0);
|
||||
}
|
||||
}
|
||||
const b = await open(); input(b,'ssid','é'.repeat(16)); assert.match(n(b,'ssid-detail').textContent,/32 \/ 32 bytes.*UTF-8/);
|
||||
input(b,'ssid','é'.repeat(17)); assert.match(n(b,'ssid-detail').textContent,/exceeds 32 bytes/);
|
||||
});
|
||||
await test('Network all canonical runtime states and signed diagnostic endpoints remain truthful', async () => {
|
||||
for (const state of ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown']) {
|
||||
const v=fixture(); v.runtime.state=state; v.runtime.active_profile=-1; v.runtime.last_error=-2147483648; v.mdns.last_error=2147483647;
|
||||
const b=await open(v); assert.match(n(b,'summary').textContent,new RegExp('Runtime: '+state)); assert.equal(n(b,'apply').disabled,false);
|
||||
assert.match(n(b,'summary').textContent,/-2147483648/); assert.match(n(b,'summary').textContent,/2147483647/);
|
||||
}
|
||||
});
|
||||
await test('Network Keep defaults and omission of unchanged fields prevent accidental secret mutations', async () => {
|
||||
const b = await open(); n(b, 'password').value = 'autofill must not replace'; input(b, 'channel', '7');
|
||||
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'wifi-patch', generation:7, channel:7}); clean(b);
|
||||
const c = await open(); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0); assert.match(n(c, 'operation-detail').textContent, /No selected-target changes/);
|
||||
target(c, '2'); input(c, 'priority', '255'); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(c)[0].body), {action:'profile-patch', generation:7, profile:2, priority:255});
|
||||
});
|
||||
await test('Network replacement ASCII bounds, escaped password and full escaped byte request remain below768 and wipe immediately', async () => {
|
||||
for (const password of [' '.repeat(8), 'valid"\\pass', '\\'.repeat(63)]) {
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', 'ff '.repeat(31) + 'ff'); input(b, 'policy', 'always', 'change'); input(b, 'channel', '11'); n(b, 'boot').checked = false; n(b, 'boot').change();
|
||||
secret(b, password); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); clean(b); await tick();
|
||||
const post = posts(b)[0]; assert.equal(JSON.parse(post.body).password, password); assert.equal(JSON.parse(post.body).ssid, '\xff'.repeat(32)); assert.ok(Buffer.byteLength(post.body) <= 768); safe(b, password);
|
||||
if (password.length === 63) console.log('Network fully escaped AP patch fixture bytes:', Buffer.byteLength(post.body));
|
||||
}
|
||||
for (const password of ['', 'a'.repeat(7), 'a'.repeat(64), 'é'.repeat(8), 'test\npass', 'test\x7fpass']) {
|
||||
const b = await open(); secret(b, password); b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
});
|
||||
await test('Network explicit STA disable+clear, empty SSID constraints, AP no-clear and enabled STA PSK constraints', async () => {
|
||||
for (const which of ['ap', '0']) {
|
||||
const b = await open(); target(b, which); n(b, 'password-mode').value = 'clear'; b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); target(b, '0'); n(b, 'enabled').checked = false; n(b, 'enabled').change(); input(b, 'ssid', ''); input(b, 'password-mode', 'clear', 'change');
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:0,enabled:false,ssid:'',clear_password:true});
|
||||
const c = await open(); target(c, '1'); n(c, 'enabled').checked = true; n(c, 'enabled').change(); input(c, 'ssid', 'new office'); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0);
|
||||
secret(c); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick(); assert.equal(JSON.parse(posts(c)[0].body).enabled, true);
|
||||
const d = await open(); input(d, 'ssid', ''); d.click('network-apply'); await tick(); assert.equal(posts(d).length, 0);
|
||||
});
|
||||
await test('Network transient PSK cleanup covers target, draft context, domain/view, refresh, logout, pagehide, session and timeout', async () => {
|
||||
for (const mode of ['target','ssid','ssid-mode','priority','channel','boot','policy','enabled','security','suffix','password-mode','domain','view','refresh','pagehide','logout','identity','401','timeout']) {
|
||||
const b = await open(); secret(b); assert.equal(n(b, 'password').value, 'a safe PSK');
|
||||
if (mode === 'target') target(b, '1');
|
||||
else if (['ssid','priority','channel','suffix'].includes(mode)) n(b, mode).input();
|
||||
else if (mode === 'password-mode') input(b, 'password-mode', 'keep', 'change');
|
||||
else if (['ssid-mode','boot','policy','enabled','security'].includes(mode)) n(b, mode).change();
|
||||
else if (mode === 'domain') b.click('settings-accounts');
|
||||
else if (mode === 'view') b.click('select-serial');
|
||||
else if (mode === 'refresh') { b.queues[path].push(json(fixture())); b.click('network-refresh'); }
|
||||
else if (mode === 'pagehide') b.emit('pagehide');
|
||||
else if (mode === 'logout') b.click('sign-out');
|
||||
else if (mode === 'identity' || mode === '401') { b.queues['/api/session'].push(mode === 'identity' ? session({role:'admin',username:'replacement'}) : failure(401)); b.click('network-refresh'); }
|
||||
else { b.elapse(60000); b.fire(60000); }
|
||||
await tick(); clean(b); safe(b, 'a safe PSK');
|
||||
}
|
||||
});
|
||||
await test('Network delayed secret expiry/context mismatch rejects replacement, confirmation cancellation wipes without a request', async () => {
|
||||
for (const mode of ['time','target','draft']) {
|
||||
const b = await open(); secret(b);
|
||||
if (mode === 'time') b.elapse(60000); else if (mode === 'target') n(b, 'target').value = '0'; else n(b, 'channel').value = '7';
|
||||
b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); secret(b); b.window.confirm = () => false; const count = b.calls.length; b.click('network-apply'); await tick(); clean(b); assert.equal(b.calls.length, count);
|
||||
});
|
||||
await test('Network exact generation/action fields, Save device-working-not-draft and RAM-only mDNS semantics', async () => {
|
||||
for (const action of ['wifi-save','wifi-load','start','stop','reconnect','next-profile','mdns-set','mdns-save','mdns-load','mdns-defaults']) {
|
||||
const b = await open(); input(b, 'ssid', 'UNAPPLIED DRAFT'); input(b, 'suffix', 'new-suffix'); secret(b);
|
||||
const confirms = []; b.window.confirm = text => { confirms.push(text); return true; };
|
||||
b.queues[operation].push(ack(action)); b.click('network-' + action); clean(b); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action, ...(action.startsWith('wifi-') ? {generation:7} : action.startsWith('mdns-') ? {generation:3} : {}), ...(action === 'mdns-set' ? {suffix:'new-suffix'} : {})});
|
||||
assert.equal(confirms.length, ['wifi-load','start','stop','reconnect','next-profile','mdns-load','mdns-defaults'].includes(action) ? 1 : 0);
|
||||
if (['wifi-load','start','stop','reconnect','next-profile'].includes(action)) assert.match(confirms[0], /HTTPS.*BOTH.*NOT online.*UART0.*USB/);
|
||||
const state = action.endsWith('-save') ? 'ok' : 'accepted'; await complete(b, action, state);
|
||||
assert.equal(reads(b).length, 2); assert.ok(b.sockets.every(s => !s.closed));
|
||||
assert.equal(n(b, 'ssid').value, 'access');
|
||||
}
|
||||
for (const suffix of ['', 'Upper', '-bad', 'bad-', 'a'.repeat(56), 'a.b', 'é', 'bad suffix']) {
|
||||
const b = await open(); input(b, 'suffix', suffix); b.click('network-mdns-set'); await tick(); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
});
|
||||
await test('Network confirms only disruptive selected patch: disabled STA staging and boot-only do not prompt', async () => {
|
||||
for (const mode of ['boot','disabled','enabled','ap']) {
|
||||
const b = await open(), confirmations = []; b.window.confirm = s => { confirmations.push(s); return true; };
|
||||
if (mode === 'boot') { n(b, 'boot').checked = false; n(b, 'boot').change(); }
|
||||
else if (mode === 'ap') input(b, 'channel', '7');
|
||||
else { target(b, mode === 'disabled' ? '1' : '0'); input(b, 'priority', '42'); }
|
||||
const action = ['disabled','enabled'].includes(mode) ? 'profile-patch' : 'wifi-patch'; b.queues[operation].push(ack(action)); b.click('network-apply'); await tick();
|
||||
assert.equal(confirmations.length, ['ap','enabled'].includes(mode) ? 1 : 0); assert.equal(posts(b).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network pending leaves visible stale settings, single flight and accepted does not claim online', async () => {
|
||||
const b = await open(), old = n(b, 'summary').textContent; input(b, 'channel', '7'); b.queues[operation].push(ack('wifi-patch'));
|
||||
b.click('network-apply'); await tick(); const count = b.calls.length; b.click('network-apply'); b.click('network-refresh'); b.click('network-result'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.equal(n(b, 'summary').textContent, old); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'ssid').disabled); assert.match(n(b, 'detail').textContent, /stale/);
|
||||
await complete(b, 'wifi-patch'); assert.match(n(b, 'operation-detail').textContent, /Accepted:.*NOT association, DHCP, online/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
|
||||
assert.equal(posts(b).length, 1); assert.equal(reads(b).length, 2); assert.equal(n(b, 'apply').disabled, false);
|
||||
});
|
||||
await test('Network all known terminal results refresh once; stale and applied_not_queued never auto-retry or imply rollback', async () => {
|
||||
for (const [state, action] of [['failed','wifi-load'],['cancelled','stop'],['stale','wifi-patch'],['invalid','profile-patch'],['loaded_defaults','mdns-load'],['applied_not_queued','mdns-set'],['ok','mdns-save'],['accepted','reconnect']]) {
|
||||
const b = await open(); b.queues[operation].push(reply(42,state,action,200,state === 'cancelled' ? 0 : 259)); const v = fixture(); v.mdns.last_error = 259; v.runtime.state = 'error'; v.runtime.last_error = 259;
|
||||
b.queues[path].push(json(v)); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, new RegExp('Error: ' + (state === 'cancelled' ? 0 : 259)));
|
||||
if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/);
|
||||
if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/);
|
||||
if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/);
|
||||
assert.match(n(b, 'summary').textContent, /Runtime: error/); assert.match(n(b, 'summary').textContent, /last error 259/);
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled);
|
||||
});
|
||||
await test('Network operation strict status/four-field128-byte shape/action/state/id/error validation rejects malformed ACKs and results', async () => {
|
||||
const invalid = [null, [], {}, {id:42,action:'stop',state:'pending'}, {id:42,action:'stop',state:'pending',error:'SECRET'}, {id:42,action:'stop',state:'pending',error:0,password:'SECRET'},
|
||||
{id:0,action:'stop',state:'idle',error:0}, {id:42,action:'none',state:'pending',error:0}, {id:42,action:'stop',state:'online',error:0}, {id:4294967296,action:'stop',state:'accepted',error:0},
|
||||
{id:42,action:'stop',state:'ok',error:0}, {id:42,action:'wifi-save',state:'accepted',error:0}, {id:42,action:'stop',state:'loaded_defaults',error:0}, {id:42,action:'wifi-patch',state:'applied_not_queued',error:0},
|
||||
{id:42,action:'stop',state:'pending',error:1}, {id:42,action:'stop',state:'failed',error:2147483648}];
|
||||
const b = await open();
|
||||
for (const value of invalid) { b.queues[operation].push(json(value)); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); safe(b); }
|
||||
for (const response of [new Response(' '.repeat(129)), new Response(Uint8Array.of(255)), reply(42,'pending','stop',202)]) { b.queues[operation].push(response); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); }
|
||||
for (const response of [reply(42,'pending','stop',200), reply(42,'accepted','stop',202), reply(42,'pending','start',202)]) {
|
||||
const c = await open(); c.queues[operation].push(response); c.click('network-stop'); await tick(); assert.match(n(c, 'operation-detail').textContent, /unknown/); assert.equal(gets(c).length, 0); assert.equal(posts(c).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network auto-check has ten GET maximum then manual-only recovery without mutation replay', async () => {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick();
|
||||
for (let i = 0; i < 10; ++i) { b.queues[operation].push(reply(42,'pending','stop')); b.elapse(1000); b.fire(1000); await tick(); }
|
||||
assert.equal(gets(b).length, 10); assert.equal(posts(b).length, 1); assert.match(n(b, 'operation-detail').textContent, /Automatic checking stopped/); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled);
|
||||
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 1);
|
||||
});
|
||||
await test('Network fifteen-second auto deadline bounds delayed session and result reads and rejects late completion', async () => {
|
||||
for (const where of ['session','result']) {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); const d = deferred();
|
||||
b.queues[where === 'session' ? '/api/session' : operation].push(d.promise); b.fire(1000); await tick();
|
||||
b.elapse(15000); b.fire(15000); await tick(); d.resolve(where === 'session' ? session({role:'admin'}) : reply(42,'accepted','stop')); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(n(b, 'result').disabled, false); assert.equal(reads(b).length, 1); assert.equal(posts(b).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network lost ACK, rejected POST and failed GET stop checking; manual latest result carries persistent uncertainty', async () => {
|
||||
for (const response of [() => { throw new Error('SECRET lost ACK'); }, failure(400), failure(403), failure(503)]) {
|
||||
const b = await open(); secret(b); b.queues[operation].push(response); b.click('network-apply'); await tick(); clean(b); safe(b);
|
||||
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 0); assert.ok(n(b, 'apply').disabled); assert.match(n(b, 'operation-detail').textContent, /No automatic mutation retry/);
|
||||
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost.*earlier request/); assert.equal(posts(b).length, 1);
|
||||
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost/);
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(failure(503)); b.fire(1000); await tick();
|
||||
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 1); assert.equal(n(b, 'result').disabled, false); assert.match(n(b, 'operation-detail').textContent, /manually/);
|
||||
});
|
||||
await test('Network replaced operation ID stops auto-following; same ID action mismatch is invalid; idle never proves cancellation', async () => {
|
||||
for (const replacement of [reply(43,'pending','start'), reply(43,'accepted','start'), reply(0,'idle','none')]) {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(replacement); b.queues[path].push(json(fixture())); b.fire(1000); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Previous result replaced.*unknown/); assert.equal(gets(b).length, 1); assert.equal(posts(b).length, 1);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(reply(42,'accepted','start')); b.fire(1000); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(reads(b).length, 1); assert.ok(n(b, 'stop').disabled);
|
||||
});
|
||||
await test('Network navigation fences pending snapshot, POST and GET headers/bodies; never resumes or replays on return', async () => {
|
||||
for (const phase of ['snapshot','post','get']) for (const streamed of [false,true]) for (const exit of ['domain','view','pagehide']) {
|
||||
const b = await open(); let stream; const d = deferred(); const response = streamed ? new Response(new ReadableStream({start(c) { stream = c; }}), {status: phase === 'post' ? 202 : 200}) : d.promise;
|
||||
if (phase === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
|
||||
else { b.queues[operation].push(phase === 'post' ? response : ack('stop')); b.click('network-stop'); await tick(); if (phase === 'get') { b.queues[operation].push(response); b.fire(1000); } }
|
||||
await tick(); const request = b.calls.filter(c => c.url === (phase === 'snapshot' ? path : operation)).at(-1);
|
||||
if (exit === 'domain') b.click('settings-accounts'); else if (exit === 'view') b.click('select-serial'); else b.emit('pagehide');
|
||||
assert.ok(request.signal.aborted); const detail = n(b, 'operation-detail')?.textContent;
|
||||
const result = phase === 'snapshot' ? fixture() : {id:42,action:'stop',state:phase === 'post' ? 'pending' : 'accepted',error:0};
|
||||
if (streamed) { stream.enqueue(new TextEncoder().encode(JSON.stringify(result))); stream.close(); } else d.resolve(new Response(JSON.stringify(result), {status:phase === 'post' ? 202 : 200}));
|
||||
await tick(); assert.equal(n(b, 'summary').textContent, ''); assert.equal(n(b, 'operation-detail')?.textContent, detail); clean(b);
|
||||
const mutations = posts(b).length, resultReads = gets(b).length;
|
||||
if (exit !== 'pagehide') {
|
||||
b.queues[path].push(json(fixture())); b.click(exit === 'domain' ? 'settings-network' : 'select-settings'); await tick();
|
||||
assert.equal(posts(b).length, mutations); assert.equal(gets(b).length, resultReads); assert.ok(b.sockets.every(s => !s.closed));
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('Network pre-submit session cancellation wipes secrets and cannot submit after target or session identity changes', async () => {
|
||||
for (const mode of ['target','context','domain','pagehide','identity','401']) {
|
||||
const b = await open(); secret(b); const d = deferred(); b.queues['/api/session'].push(d.promise); b.click('network-apply'); clean(b); await tick();
|
||||
if (mode === 'target') target(b, '1'); else if (mode === 'context') input(b, 'suffix', 'new-context'); else if (mode === 'domain') b.click('settings-accounts'); else if (mode === 'pagehide') b.emit('pagehide');
|
||||
d.resolve(mode === '401' ? failure(401) : session({role:'admin', ...(mode === 'identity' ? {username:'newadmin'} : {})})); await tick();
|
||||
assert.equal(posts(b).length, 0); clean(b); safe(b, 'a safe PSK');
|
||||
if (mode === 'identity' || mode === '401') { assert.deepEqual(b.redirects,[mode === 'identity' ? '/' : '/login']); assert.ok(b.sockets.every(s => s.closed)); }
|
||||
}
|
||||
});
|
||||
await test('Network endpoint401/session replacement wipe and close both routes without a success claim', async () => {
|
||||
for (const where of ['snapshot','post','get','identity']) {
|
||||
const b = await open(); secret(b);
|
||||
if (where === 'snapshot') { b.queues[path].push(failure(401)); b.click('network-refresh'); }
|
||||
else if (where === 'identity') { b.queues['/api/session'].push(session({role:'admin',csrf:'b'.repeat(64)})); b.click('network-apply'); }
|
||||
else { b.queues[operation].push(where === 'post' ? failure(401) : ack('wifi-patch')); b.click('network-apply'); await tick(); if (where === 'get') { b.queues[operation].push(failure(401)); b.fire(1000); } }
|
||||
await tick(); clean(b); assert.ok(b.sockets.every(s => s.closed)); assert.deepEqual(b.redirects, [where === 'identity' ? '/' : '/login']); assert.equal(n(b, 'summary').textContent, '');
|
||||
assert.doesNotMatch(n(b, 'operation-detail')?.textContent || '', /Accepted:|completed successfully/); assert.equal(b.timers.size, 0);
|
||||
}
|
||||
});
|
||||
await test('Network request timeouts and stale errors release single-flight state without retry or late login navigation', async () => {
|
||||
for (const kind of ['snapshot','post','get']) {
|
||||
const b = await open(); const response = o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout'))));
|
||||
if (kind === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
|
||||
else if (kind === 'post') { b.queues[operation].push(response); b.click('network-stop'); }
|
||||
else { b.queues[operation].push(response); b.click('network-result'); }
|
||||
await tick(); b.fire(15000); await tick(); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled); safe(b);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000)); assert.equal(posts(b).length, kind === 'post' ? 1 : 0);
|
||||
}
|
||||
const b = await open(), d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick(); b.click('settings-accounts'); await tick(); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed));
|
||||
});
|
||||
await test('Network request ownership remains independent from account/serial outcomes and reconnect session validation', async () => {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.click('settings-accounts'); await tick();
|
||||
const account = '/api/settings/account-operation'; b.queues[account].push(json({id:99,action:'role',state:'pending'})); b.click('account-change-role'); await tick(); b.click('settings-network'); await tick();
|
||||
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /stop: Accepted/);
|
||||
assert.match(b.nodes['account-operation-detail'].textContent, /pending or unknown/); assert.equal(posts(b).length, 1);
|
||||
const c = await open(), d = deferred(); c.queues['/api/session'].push(d.promise); c.click('network-refresh'); await tick(); c.click('connection-toggle'); c.click('connection-toggle'); await tick();
|
||||
d.resolve(session({role:'admin'})); await tick(); assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed); assert.equal(posts(c).length, 0);
|
||||
});
|
||||
};
|
||||
@@ -93,6 +93,7 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
(tmp / 'rendered.json').write_text(json.dumps(rendered))
|
||||
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
|
||||
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')
|
||||
print(f'Rendered response bytes: HTML={len(rendered["html"].encode())}, app.js={len(rendered["script"].encode())}, inline loader={len(rendered["loader"].encode())}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user