Add browser Serial/Admin terminal switching
Keep the serial connection and lease intact while providing a separate, bounded admin terminal with explicit open and close controls. Fence retained terminal state across sessions and add fit-readiness retries with regression coverage.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# Existing serial app cookie-session cutover tests
|
||||
# Browser Session and Terminal Selector Tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
@@ -32,21 +32,32 @@ Coverage:
|
||||
- Authentication/ticket response cap 512 bytes, existing status cap 3,072 bytes,
|
||||
15-second request deadline, single status request in flight, bounded retry delay,
|
||||
and unchanged 5,000-line terminal scrollback.
|
||||
- Admin-only navigation, explicit protected-ticket open, 20 switches preserving
|
||||
serial socket/client/writer IDs, separate output and selected-only input.
|
||||
- Independent 64 KiB pending output limits, visible dropped-byte counters and
|
||||
resumed hidden output draining; 512-byte admin input frames and overflow close.
|
||||
- Admin close/reopen/remote exit isolation, 401/logout/expiry/pagehide cancellation,
|
||||
late tickets/callbacks, handshake timeout, selected resize and three listener
|
||||
cleanup/restore cycles.
|
||||
- Session identity changes (username, role or session-stable CSRF) force a clean
|
||||
document before view adoption, close old admin and prevent replacement-session
|
||||
logout; same-session restore retains both scrollbacks behind validation gating.
|
||||
- Undefined initial dimensions recover at unchanged bounds; failed fits never
|
||||
populate the cache, readiness retries stop at three, and teardown fences stale
|
||||
callbacks even after restore. Sixteen Node groups total.
|
||||
|
||||
## Integration and known gaps
|
||||
|
||||
This is only the existing application browser portion of Phase 8D.3. It requires
|
||||
the simultaneous server cookie/Origin/CSRF cutover for every route. The renderer
|
||||
This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer
|
||||
still relies on its caller to authenticate resources; protected asset failures
|
||||
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
|
||||
implemented here. No server, auth-store, transport, admin UI, or generated asset
|
||||
changes are included.
|
||||
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
|
||||
|
||||
These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||
integrity. Full firmware build and mandatory M1 browser/target checks remain the
|
||||
integrator's responsibility. The full build was deliberately not run in this
|
||||
restricted-write subtask. No target resource reserve is claimed. Browser secret
|
||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||
The 8D.6 firmware build and pending target checklist are recorded separately in
|
||||
`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
references are dropped and never persisted/logged, but JavaScript cannot securely
|
||||
wipe engine-managed strings.
|
||||
|
||||
@@ -9,36 +9,41 @@ const ticket = () => json({ticket: 't'.repeat(32)});
|
||||
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false} = {}) {
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/logout': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': []};
|
||||
const fits = [];
|
||||
let serial = 0;
|
||||
const on = (key, fn) => { (events[key] ||= []).push(fn); };
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
|
||||
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
constructor(url) { this.url = url; this.readyState = 0; this.events = {}; this.sent = []; sockets.push(this); }
|
||||
constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.events = {}; this.sent = []; sockets.push(this); }
|
||||
addEventListener(k, fn) { this.events[k] = fn; }
|
||||
emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); }
|
||||
emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); this['on' + k]?.(event); }
|
||||
close() { this.closed = true; this.readyState = 3; this.emit('close'); }
|
||||
send(value) { this.sent.push(value); }
|
||||
}
|
||||
class Terminal {
|
||||
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
|
||||
loadAddon() {} open() {} resize() {} onData(fn) { this.input = fn; }
|
||||
write(bytes) { this.writes.push([...bytes]); }
|
||||
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
|
||||
}
|
||||
const window = {addEventListener: on, removeEventListener() {},
|
||||
const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
setTimeout: timeout, clearTimeout: id => timers.delete(id),
|
||||
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
|
||||
const context = vm.createContext({window, document: {getElementById(id) {
|
||||
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
|
||||
setAttribute(k, v) { this[k] = v; },
|
||||
getBoundingClientRect: () => ({width: 100, height: 100}),
|
||||
addEventListener(k, fn) { this[k] = fn; }};
|
||||
}}, Terminal, FitAddon: {FitAddon: class {proposeDimensions() { return null; }}},
|
||||
}}, Terminal, FitAddon: {FitAddon: class {
|
||||
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, WebSocket: Socket,
|
||||
fetch: async (url, options) => {
|
||||
// Apply the Origin regression guard to every mutation, including logout.
|
||||
@@ -48,9 +53,9 @@ function browser({onlyLoader = false, withLoader = false} = {}) {
|
||||
calls.push({url, ...options});
|
||||
const next = queues[url].shift();
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session();
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/ws-ticket') return ticket();
|
||||
if (url === '/api/ws-ticket' || url === '/api/admin/ws-ticket') return ticket();
|
||||
throw new Error('network unavailable');
|
||||
}});
|
||||
if (withLoader || onlyLoader) vm.runInContext(loader, context);
|
||||
@@ -59,7 +64,7 @@ function browser({onlyLoader = false, withLoader = false} = {}) {
|
||||
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, calls, redirects, timers, sockets, terminals, queues, emit, start, fire,
|
||||
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
click: id => nodes[id].click(), window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
@@ -191,5 +196,164 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
c.queues['/api/session'].push(failure(401)); c.emit('error', {target: {tagName: 'IMG'}}); await tick();
|
||||
assert.deepEqual(c.redirects, ['/login']); assert.ok(c.sockets[0].closed); assert.equal(c.timers.size, 0);
|
||||
});
|
||||
async function adminBrowser() {
|
||||
const b = browser({role: 'admin'}); b.queues['/api/session'].push(session({role: 'admin'}));
|
||||
b.start(); await tick(); const ws = b.sockets[0]; ws.emit('open');
|
||||
ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick();
|
||||
assert.equal(b.sockets.length, 2); b.sockets[1].emit('open'); return b;
|
||||
}
|
||||
await test('ordinary user is serial-only; admin selection preserves socket/lease and isolates input/output', async () => {
|
||||
const u = await connected(); assert.equal(u.nodes['terminal-selector'].hidden, true);
|
||||
u.click('select-admin'); u.click('admin-toggle'); await tick(); assert.equal(u.terminals.length, 1); assert.equal(u.sockets.length, 1);
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals;
|
||||
assert.match(admin.url, /\/ws\/admin\?ticket=/);
|
||||
assert.equal(b.calls.find(c => c.url === '/api/admin/ws-ticket').headers['X-CSRF-Token'], token);
|
||||
st.input('WRONG'); at.input('x'.repeat(1025)); assert.equal(serial.sent.length, 0);
|
||||
assert.deepEqual(admin.sent.map(x => x.length), [512, 512, 1]);
|
||||
serial.emit('message', {data: Uint8Array.of(0, 255).buffer});
|
||||
admin.emit('message', {data: Uint8Array.of(27, 91).buffer});
|
||||
assert.deepEqual(st.writes, [[0, 255]]); assert.deepEqual(at.writes, [[27, 91]]);
|
||||
b.click('release-control'); assert.equal(serial.sent.at(-1), 'release-writer');
|
||||
for (let i = 0; i < 20; ++i) { b.click('select-serial'); at.input('WRONG'); b.click('select-admin'); }
|
||||
assert.equal(b.sockets.length, 2); assert.equal(b.terminals.length, 2); assert.ok(!serial.closed);
|
||||
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
|
||||
assert.equal(b.nodes['release-control'].disabled, false); assert.equal(admin.sent.length, 3);
|
||||
b.click('admin-toggle'); assert.ok(admin.closed); assert.equal(admin.onmessage, null); assert.ok(!serial.closed);
|
||||
b.click('admin-toggle'); await tick(); const replacement = b.sockets[2]; replacement.emit('open'); replacement.emit('close');
|
||||
assert.ok(!serial.closed); assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin');
|
||||
b.click('select-serial'); st.input('ok'); assert.deepEqual([...serial.sent.at(-1)], [111, 107]);
|
||||
});
|
||||
await test('bounded hidden output continues draining, separate scrollback and input overflow closes only admin', async () => {
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals;
|
||||
assert.equal(st.options.scrollback, 5000); assert.equal(at.options.scrollback, 5000);
|
||||
st.holdWrites = true;
|
||||
for (let i = 0; i < 65; ++i) serial.emit('message', {data: new Uint8Array(1024).buffer});
|
||||
assert.equal(st.writes.length, 64); assert.match(b.nodes['output-detail'].textContent, /serial 1024 B, admin 0 B/);
|
||||
st.pending.shift()(); serial.emit('message', {data: Uint8Array.of(42).buffer}); assert.equal(st.writes.at(-1)[0], 42);
|
||||
at.input('x'.repeat(4097)); assert.ok(admin.closed); assert.ok(!serial.closed); assert.equal(admin.sent.length, 0);
|
||||
});
|
||||
await test('admin failures and cancellation are isolated; 401/logout/expiry/pagehide close both', async () => {
|
||||
for (const status of [403, 503, 401]) {
|
||||
const b = await adminBrowser(), serial = b.sockets[0]; b.click('admin-toggle');
|
||||
b.queues['/api/admin/ws-ticket'].push(failure(status)); b.click('admin-toggle'); await tick();
|
||||
assert.equal(!!serial.closed, status === 401);
|
||||
assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []);
|
||||
assert.equal(b.sockets.length, 2);
|
||||
}
|
||||
for (const action of ['pagehide', 'expiry', 'logout']) {
|
||||
const b = await adminBrowser();
|
||||
if (action === 'pagehide') b.emit('pagehide');
|
||||
if (action === 'expiry') b.window.sakSessionExpired();
|
||||
if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); }
|
||||
await tick(); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.sockets[1].onmessage, null);
|
||||
if (action === 'pagehide') {
|
||||
b.queues['/api/session'].push(session({role: 'admin'})); b.emit('pageshow', {persisted: true}); await tick();
|
||||
assert.equal(b.sockets.length, 3); assert.match(b.sockets[2].url, /\/ws\/serial/);
|
||||
assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin');
|
||||
}
|
||||
}
|
||||
const b = await adminBrowser(); b.click('admin-toggle'); const d = deferred();
|
||||
b.queues['/api/admin/ws-ticket'].push(d.promise); b.click('admin-toggle'); await tick();
|
||||
b.click('admin-toggle'); d.resolve(failure(401)); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.ok(!b.sockets[0].closed); assert.equal(b.sockets.length, 2);
|
||||
});
|
||||
await test('selected resize, listener cleanup, admin handshake deadline and stale callback fencing', async () => {
|
||||
const b = await adminBrowser(), [serial, old] = b.sockets;
|
||||
b.fire(-1); assert.equal(b.terminals[1].cols, 80); assert.equal(b.terminals[0].cols, undefined);
|
||||
b.click('select-serial'); b.fire(-1); assert.equal(b.terminals[0].rows, 24);
|
||||
const stale = old.onmessage;
|
||||
b.click('select-admin'); b.click('admin-toggle'); b.click('admin-toggle'); await tick();
|
||||
stale({data: Uint8Array.of(99).buffer}); assert.equal(b.terminals[1].writes.length, 0);
|
||||
b.fire(15000); assert.ok(b.sockets[2].closed); assert.ok(!serial.closed);
|
||||
assert.match(b.nodes['admin-detail'].textContent, /timed out/);
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
b.emit('pagehide'); assert.equal(b.events.resize.length, 0);
|
||||
assert.ok(b.sockets.every(s => s.onmessage === null));
|
||||
b.queues['/api/session'].push(session({role: 'admin'}));
|
||||
b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.events.resize.length, 1);
|
||||
}
|
||||
b.window.sakSessionExpired(); assert.equal(b.events.resize.length, 0);
|
||||
});
|
||||
await test('changed session identity replaces document before adoption, including live admin and paused restore', async () => {
|
||||
const identities = [
|
||||
{username: 'other-admin', role: 'admin', csrf: 'b'.repeat(64)},
|
||||
{username: 'other-user', role: 'user', csrf: 'b'.repeat(64)},
|
||||
{role: 'admin', csrf: 'b'.repeat(64)}, // Same account, different login session.
|
||||
{username: 'other-admin', role: 'admin'}, // Principal fields are checked independently.
|
||||
{role: 'user'}
|
||||
];
|
||||
for (const identity of identities) for (const route of ['restore', 'paused-restore', 'live', 'logout']) {
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets;
|
||||
serial.emit('message', {data: Uint8Array.of(65).buffer});
|
||||
admin.emit('message', {data: Uint8Array.of(66).buffer});
|
||||
const stale = admin.onmessage, info = b.nodes['session-info'].textContent;
|
||||
const count = b.calls.length;
|
||||
b.queues['/api/session'].push(session(identity));
|
||||
if (route.includes('restore')) {
|
||||
if (route === 'paused-restore') b.click('connection-toggle');
|
||||
b.emit('pagehide');
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
b.emit('pageshow', {persisted: true}); b.click('select-admin');
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
} else if (route === 'live') {
|
||||
b.click('connection-toggle'); assert.ok(!admin.closed); b.click('connection-toggle');
|
||||
} else await b.click('sign-out');
|
||||
await tick();
|
||||
assert.deepEqual(b.redirects, ['/']); assert.ok(serial.closed && admin.closed);
|
||||
assert.equal(b.nodes['session-info'].textContent, info); // B was never adopted into A's document.
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
assert.deepEqual(b.calls.slice(count).map(c => c.url), ['/api/session']);
|
||||
assert.equal(b.timers.size, 0); assert.equal(b.events.resize.length, 0);
|
||||
stale({data: Uint8Array.of(67).buffer}); b.terminals[1].input('WRONG');
|
||||
b.click('select-admin'); b.click('admin-toggle'); b.emit('pageshow', {persisted: true});
|
||||
assert.deepEqual(b.terminals[1].writes, [[66]]); assert.equal(admin.sent.length, 0);
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, true); assert.equal(b.sockets.length, 2);
|
||||
}
|
||||
});
|
||||
await test('same-session restore preserves both scrollbacks; pending validation never reveals them', async () => {
|
||||
for (const paused of [false, true]) {
|
||||
const b = await adminBrowser();
|
||||
b.sockets[0].emit('message', {data: Uint8Array.of(65).buffer});
|
||||
b.sockets[1].emit('message', {data: Uint8Array.of(66).buffer});
|
||||
if (paused) b.click('connection-toggle');
|
||||
b.emit('pagehide'); const d = deferred(); b.queues['/api/session'].push(d.promise);
|
||||
b.emit('pageshow', {persisted: true}); await tick(); b.click('select-serial'); b.click('select-admin');
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
d.resolve(session({role: 'admin', expires_in: 3500})); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.equal(b.terminals.length, 2);
|
||||
assert.deepEqual(b.terminals.map(t => t.writes), [[[65]], [[66]]]);
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, false); assert.equal(b.sockets.length, paused ? 2 : 3);
|
||||
}
|
||||
const b = await adminBrowser(), admin = b.sockets[1];
|
||||
admin.emit('message', {data: Uint8Array.of(66).buffer});
|
||||
b.click('connection-toggle'); b.click('connection-toggle'); await tick();
|
||||
assert.ok(!admin.closed); assert.deepEqual(b.redirects, []); assert.deepEqual(b.terminals[1].writes, [[66]]);
|
||||
});
|
||||
await test('undefined first fit retries unchanged bounds, stops after three retries and fences stale work', async () => {
|
||||
const b = await adminBrowser(), fit = b.fits[1];
|
||||
fit.measurements.push(undefined); b.fire(-1);
|
||||
assert.equal(b.terminals[1].cols, undefined); b.fire(-1);
|
||||
assert.equal(b.terminals[1].cols, 80); assert.equal(fit.calls, 2);
|
||||
b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 2); // Successful measurement is cached.
|
||||
b.click('select-serial'); b.click('select-admin');
|
||||
fit.measurements.push(undefined, undefined, undefined, undefined);
|
||||
for (let i = 0; i < 4; ++i) b.fire(-1);
|
||||
assert.equal(fit.calls, 6); assert.ok(![...b.timers.values()].some(t => t.ms === -1));
|
||||
b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 7); // Failed bounds were never cached.
|
||||
for (const action of ['pagehide', 'expiry', 'logout']) {
|
||||
const c = await adminBrowser(); c.fits[1].measurements.push(undefined); c.fire(-1);
|
||||
const stale = [...c.timers.values()].find(t => t.ms === -1).fn;
|
||||
if (action === 'pagehide') c.emit('pagehide');
|
||||
if (action === 'expiry') c.window.sakSessionExpired();
|
||||
if (action === 'logout') { c.queues['/api/logout'].push(failure(403)); await c.click('sign-out'); }
|
||||
await tick(); stale(); assert.equal(c.fits[1].calls, 1);
|
||||
assert.ok(![...c.timers.values()].some(t => t.ms === -1));
|
||||
if (action === 'pagehide') {
|
||||
c.emit('pageshow', {persisted: true}); await tick(); stale();
|
||||
assert.equal(c.fits[1].calls, 1); c.fire(-1); assert.equal(c.fits[1].calls, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
Reference in New Issue
Block a user