Complete Phase 12 dual-stack networking
Add IPv6-aware Wi-Fi state, HTTPS/SSH listeners, mDNS service reconciliation, and browser Wi-Fi administration. Include a guarded build-local fix for mDNS 1.12.0 membership handling, focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
@@ -6,7 +6,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
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},
|
||||
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ipv6_linklocal: false, ipv6_routable: false, ipv6_addresses: [], 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);
|
||||
@@ -28,6 +28,90 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('IPv6-only and dual-stack availability renders in Settings and quick Network without a route claim', async () => {
|
||||
for (const quick of [false, true]) for (const ip of ['0.0.0.0', '192.0.2.8']) {
|
||||
for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false]]) {
|
||||
const v = fixture(); Object.assign(v.runtime, {state: 'online', ip, ipv6_linklocal: linklocal, ipv6_routable: routable});
|
||||
const b = quick ? await adminBrowser() : await open(v);
|
||||
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
|
||||
assert.equal(n(b, 'edit').hidden, false);
|
||||
const rows = n(b, 'summary').children;
|
||||
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||
assert.equal(values.IPv4, ip === '0.0.0.0' ? 'none' : ip);
|
||||
assert.equal(values['IPv6 link-local'], linklocal ? 'available' : 'none');
|
||||
assert.equal(values['IPv6 ULA/GUA'], routable ? 'available' : 'none');
|
||||
assert.match(values['IPv6 reporting'], /Preferred addresses; no route or Internet reachability guarantee/);
|
||||
assert.ok(!n(b, 'summary').textContent.includes('0.0.0.0'));
|
||||
if (quick) {
|
||||
assert.ok(n(b, 'detail').textContent.includes('IPv4: ' + values.IPv4));
|
||||
assert.ok(n(b, 'detail').textContent.includes('IPv6 link-local: ' + values['IPv6 link-local']));
|
||||
assert.ok(n(b, 'detail').textContent.includes('ULA/GUA: ' + values['IPv6 ULA/GUA']));
|
||||
}
|
||||
assert.equal(posts(b).length, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
const address = (prefix, tail = '0001') => prefix + ':0000:0000:0000:0000:0000:0000:' + tail;
|
||||
const groups = {'IPv6 link-local addresses': ['fe80', 'febf'], 'IPv6 ULA addresses': ['fc00', 'fd12'], 'IPv6 GUA addresses': ['2001', '3fff']};
|
||||
await test('Network preferred IPv6 addresses group every entry as safe text, including empty and same-kind slots', async () => {
|
||||
const samples = [[], ['fe80', 'fd12', '2001'], ...Object.values(groups).map(([prefix, other]) => [prefix, other, prefix])];
|
||||
for (const quick of [false, true]) for (const prefixes of samples) {
|
||||
const v = fixture(); v.runtime.ipv6_addresses = prefixes.map((p, i) => address(p, '000' + (i + 1)));
|
||||
const b = quick ? await adminBrowser() : await open(v);
|
||||
if (quick) { b.queues[path].push(json(v)); b.click('quick-network'); await tick(); }
|
||||
const rows = n(b, 'summary').children;
|
||||
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||
for (const [label, kinds] of Object.entries(groups)) {
|
||||
assert.equal(values[label], v.runtime.ipv6_addresses.filter(a => kinds.some(p => a.startsWith(p))).join(', ') || 'none');
|
||||
}
|
||||
rows.forEach(node => assert.equal(node.children.length, 0));
|
||||
assert.match(values['IPv6 reporting'], /Link-local access requires the client interface scope/);
|
||||
assert.equal(posts(b).length, 0);
|
||||
}
|
||||
});
|
||||
await test('Network rejects missing, malformed, oversized and injected IPv6 address arrays in both controllers', async () => {
|
||||
const a = address('fe80');
|
||||
const bad = [undefined, null, {}, a, 3, [a, a, a, a], [null], [1], [true], [{}], [[a]], [''],
|
||||
['fe80::1'], [a.toUpperCase()], [a + '\n'], [a + '0'], [a.slice(1)], [a.replace('0001', '000g')],
|
||||
[a + '%eth0'], ['[' + a + ']'], ['<img onerror="SECRET">'], [a, '<script>SECRET</script>']];
|
||||
for (const quick of [false, true]) {
|
||||
const b = quick ? await adminBrowser() : await open();
|
||||
if (quick) { b.queues[path].push(json(fixture())); b.click('quick-network'); await tick(); }
|
||||
const before = n(b, 'summary').textContent;
|
||||
for (const value of bad) {
|
||||
const v = fixture(); v.runtime.ipv6_addresses = value;
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('Both Network controllers accept exactly 2304 bytes and reject 2305 bytes', async () => {
|
||||
for (const quick of [false, true]) {
|
||||
const v = fixture(); v.runtime.ipv6_addresses = ['fe80', 'fd12', '2001'].map(p => address(p));
|
||||
const encoded = JSON.stringify(v);
|
||||
const b = quick ? await adminBrowser() : await open();
|
||||
b.queues[path].push(new Response(encoded.padEnd(2304, ' ')));
|
||||
b.click(quick ? 'quick-network' : 'network-refresh'); await tick();
|
||||
assert.equal(n(b, 'apply').disabled, false);
|
||||
assert.ok(n(b, 'summary').textContent.includes(address('2001')));
|
||||
b.queues[path].push(new Response(encoded.padEnd(2305, ' '))); b.click('network-refresh'); await tick();
|
||||
assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled);
|
||||
}
|
||||
});
|
||||
await test('Main status reports IPv6-only/dual-stack flags and treats malformed or missing flags as unknown', async () => {
|
||||
for (const ip of ['0.0.0.0', '192.0.2.8']) for (const [linklocal, routable] of [[true, false], [false, true], [true, true], [false, false], [1, true], [true, 'false'], [null, false], [false, undefined]]) {
|
||||
const b = browser();
|
||||
b.queues['/api/status'].push(json({wifi: {available: true, state: 'online', sta_ipv4: ip, ipv6_linklocal: linklocal, ipv6_routable: routable}}));
|
||||
b.start(); await tick(); const text = b.nodes['wifi-summary'].textContent;
|
||||
assert.ok(text.includes('IPv4: ' + (ip === '0.0.0.0' ? 'none' : ip)));
|
||||
assert.ok(!text.includes('0.0.0.0'));
|
||||
if (typeof linklocal === 'boolean' && typeof routable === 'boolean') {
|
||||
assert.ok(text.includes('IPv6 link-local: ' + (linklocal ? 'available' : 'none')));
|
||||
assert.ok(text.includes('ULA/GUA: ' + (routable ? 'available' : 'none')));
|
||||
} else assert.ok(text.includes('IPv6 availability: unknown'));
|
||||
}
|
||||
});
|
||||
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
||||
b.click('quick-network'); await tick();
|
||||
@@ -148,7 +232,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
|
||||
const b = await open(v), summary = n(b, 'summary');
|
||||
const rows = summary.children;
|
||||
assert.equal(rows.length, 58);
|
||||
assert.equal(rows.length, 70);
|
||||
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
|
||||
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
|
||||
@@ -162,7 +246,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
assert.ok(rows.every(node => node.parentNode === null));
|
||||
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
|
||||
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
||||
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
|
||||
assert.equal(summary.children.length, 70); assert.ok(!summary.textContent.includes('<img'));
|
||||
});
|
||||
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',
|
||||
@@ -176,6 +260,10 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
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];
|
||||
for (const key of ['ipv6_linklocal', 'ipv6_routable']) {
|
||||
edits.push(v => delete v.runtime[key]);
|
||||
for (const value of [null, 0, 1, 'true', 'false', [], {}]) edits.push(v => v.runtime[key] = value);
|
||||
}
|
||||
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();
|
||||
@@ -185,16 +273,16 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
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 () => {
|
||||
await test('Network snapshot 2304-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})]) {
|
||||
for (const response of [new Response(' '.repeat(2305)), 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.ok(Buffer.byteLength(encoded) < 2304); 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">')));
|
||||
|
||||
Reference in New Issue
Block a user