Files
RustProxy/tools/checks/origin_resume_0313_ui_test.mjs
T
2026-10-11 12:21:26 +02:00

208 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Real-source simulation of mobile tab restore, BFCache, Origin 403 and retry.
// This does not replace a real Safari/Chrome session or a compiled Rust backend.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const read = path => readFileSync(resolve(root, path), 'utf8');
const core = read('src/admin/web/app/core.js');
const refresh = read('src/admin/web/app/refresh.js');
const resume = read('src/admin/web/app/session-resume.js');
const runtime = read('src/admin/web/app/runtime.js');
const html = read('src/admin/web/index.html');
const server = read('src/admin/session.rs');
assert.match(html, /id="app-session-retry"[^>]*data-i18n="sessionRetry"/);
assert.match(server, /origin_mismatch/);
assert.match(server, /cross_site_fetch/);
assert.match(server, /administration origin rejected/);
function sourceBetween(source, start, end) {
const a = source.indexOf(start), b = source.indexOf(end, a);
assert(a >= 0 && b > a, `Missing source slice: ${start}`);
return source.slice(a, b);
}
const actualApi = sourceBetween(core, 'function apiArea(path)', 'function setPanel(name');
const actualLive = sourceBetween(core, 'function renderLiveStatus(value)', 'async function refreshCertificateInventory()');
const actualResume = resume;
const recovery = sourceBetween(core, 'const ORIGIN_RECOVERY_KEY', 'const PANEL_PATHS');
const startup = sourceBetween(runtime, 'function verifyAdminModules()', 'function certificateUsesExternalFiles');
function fixture() {
const events = new Map(), windows = new Map(), pending = [], streams = [], nodes = new Map(), timers = new Map();
const classes = new Set(), originMarker = new Map();
let timerId = 0, reloads = 0, lockCount = 0, renderSessionCalls = 0;
const node = id => {
if (!nodes.has(id)) {
const handlers = new Map();
nodes.set(id, { hidden: id !== 'workspace', disabled: false, textContent: '',
addEventListener(type, fn) { handlers.set(type, fn); },
click() { handlers.get('click')?.(); },
});
}
return nodes.get(id);
};
class FakeEventSource {
constructor(url) { this.url = url; this.closed = false; streams.push(this); }
addEventListener() {}
close() { this.closed = true; }
}
const classList = {
contains(k) { return classes.has(k); },
add(...keys) { keys.forEach(k => classes.add(k)); },
remove(...keys) { keys.forEach(k => classes.delete(k)); },
toggle(k, forced) { if (forced) classes.add(k); else classes.delete(k); },
};
const document = {
hidden: false, body: {}, documentElement: { classList },
addEventListener(name, handler) { assert(!events.has(name)); events.set(name, handler); },
removeEventListener(name, handler) { assert.equal(events.get(name), handler); events.delete(name); },
};
const window = {
rustProxySettings: { current: () => 'listeners', render() {} },
addEventListener(name, handler) { assert(!windows.has(name)); windows.set(name, handler); },
removeEventListener(name, handler) { assert.equal(windows.get(name), handler); windows.delete(name); },
};
const ctx = {
document, window, $: node, AbortController, EventSource: FakeEventSource,
MutationObserver: class { observe() {} },
location: { origin: 'https://panel.example', pathname: '/overview', search: '',
reload() { reloads++; }, replace() { throw new Error('Unexpected replace'); } },
sessionStorage: { getItem: key => originMarker.get(key), setItem: (key, value) => originMarker.set(key, value), removeItem: key => originMarker.delete(key) },
performance: { now: () => 1 },
fetch: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
setTimeout(fn, ms) { const id = ++timerId; timers.set(id, { fn, ms }); return id; },
clearTimeout(id) { timers.delete(id); },
console: { warn() {} },
csrf: 'old', authenticated: true, authEpoch: 1, sessionInfo: null, sessionCheckPromise: null,
languagePacks: new Map([['en_US', {}]]), latestStatus: null,
currentPanel: 'overview', liveEvents: null, liveInterval: null, liveConnected: false, liveFallback: false,
t: key => key === 'sessionValidationFailed' ? 'Could not verify your session' : key,
renderSession() { renderSessionCalls++; },
renderFooter() {}, renderStatus() {}, renderLogs() {}, renderDiagnostics() {},
toast() { throw new Error('Resume errors must not be transient toasts'); },
loadTunnels: async () => {},
lock() { lockCount++; ctx.authEpoch++; ctx.authenticated = false; ctx.window.rustProxyRefresh.stop(); },
};
runInNewContext(`${recovery}\n${actualApi}\n${actualLive}\n${actualResume}\n${refresh}`, ctx,
{ filename: 'actual-rustproxy-admin-source.js' });
const tick = async () => { for (let i = 0; i < 25; i++) await Promise.resolve(); };
const answer = (status, value) => {
const request = pending.shift();
assert(request && request.path === '/api/auth/session', `Unexpected request: ${request?.path}`);
request.resolve({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(value) });
return request;
};
const visibility = hidden => { document.hidden = hidden; events.get('visibilitychange')?.(); };
const pagehide = () => windows.get('pagehide')?.({});
const pageshow = persisted => windows.get('pageshow')?.({ persisted });
const activeStreams = () => streams.filter(stream => !stream.closed);
return { ctx, events, windows, pending, streams, timers, originMarker, nodes, classes, tick, answer,
visibility, pagehide, pageshow, activeStreams, node, get reloads() { return reloads; },
get lockCount() { return lockCount; }, get renderSessionCalls() { return renderSessionCalls; } };
}
const normal = fixture();
normal.ctx.window.rustProxyRefresh.start();
assert.equal(normal.activeStreams().length, 1, 'authenticated shell starts one SSE stream');
normal.visibility(true);
assert.equal(normal.activeStreams().length, 0, 'hidden document pauses SSE');
normal.visibility(false);
normal.pageshow(true);
assert.equal(normal.pending.length, 1, 'pageshow and visibilitychange share one session GET');
assert.equal(normal.activeStreams().length, 0, 'SSE cannot resume before session verification');
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
normal.answer(403, { error: 'auth.invalid_origin' }); await normal.tick();
assert.equal(normal.reloads, 0, '403 during resume must not reload');
assert.equal(normal.originMarker.size, 0, 'resume must not update startup reload limit');
assert(normal.classes.has('app-loading') && normal.classes.has('app-startup-failed'));
assert(normal.classes.has('app-session-failed'), 'show an actionable resume failure');
assert.equal(normal.node('workspace').hidden, true);
assert.equal(normal.node('app-session-retry').hidden, false);
assert.equal(normal.node('app-startup-reload').hidden, true);
assert.equal(normal.node('app-startup-title').textContent, 'Could not verify your session');
assert.equal(normal.activeStreams().length, 0);
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
normal.pageshow(true);
assert.equal(normal.pending.length, 0, 'no automatic error-loop retries on duplicate resume events');
normal.node('app-session-retry').click();
assert.equal(normal.pending.length, 1, 'user may explicitly retry without a reload');
assert.equal(normal.node('app-session-retry').disabled, true);
normal.answer(200, { username: 'admin', csrf_token: 'fresh' }); await normal.tick();
assert.equal(normal.ctx.csrf, 'fresh');
assert.equal(normal.node('workspace').hidden, false);
assert.equal(normal.classes.has('app-loading'), false);
assert.equal(normal.node('app-session-retry').hidden, true);
assert.equal(normal.node('app-session-retry').disabled, false);
assert.equal(normal.activeStreams().length, 1, 'SSE resumes only after verified session');
assert.equal(normal.reloads, 0);
// A real 401 follows the existing lock/redirect flow, never displaying stale data.
normal.visibility(true);
normal.visibility(false);
assert.equal(normal.pending.length, 1);
normal.answer(401, { error: 'auth.required' }); await normal.tick();
assert.equal(normal.lockCount, 1);
assert.equal(normal.activeStreams().length, 0);
assert.equal(normal.classes.has('app-session-failed'), false);
const restored = fixture();
restored.ctx.window.rustProxyRefresh.start();
restored.pagehide();
assert.equal(restored.activeStreams().length, 0);
restored.pageshow(true);
restored.visibility(false);
assert.equal(restored.pending.length, 1, 'BFCache pagehide/pageshow + visibility triggers one session check');
restored.answer(200, { username: 'admin', csrf_token: 'bfcache' }); await restored.tick();
assert.equal(restored.activeStreams().length, 1);
// Ten tab cycles must not add listeners or duplicate SSE sessions.
for (let i = 0; i < 10; i++) {
restored.visibility(true); restored.visibility(false); restored.pageshow(true);
assert.equal(restored.pending.length, 1);
assert.equal(restored.activeStreams().length, 0);
restored.answer(200, { username: 'admin', csrf_token: `cycle-${i}` }); await restored.tick();
assert.equal(restored.activeStreams().length, 1);
assert.equal(restored.windows.size, 2);
assert.equal(restored.events.size, 1);
}
// A response from a prior auth epoch may not restore the dashboard.
restored.visibility(true); restored.visibility(false);
assert.equal(restored.pending.length, 1);
restored.ctx.authEpoch++;
restored.ctx.authenticated = false;
restored.ctx.window.rustProxyRefresh.stop();
restored.answer(200, { username: 'stale', csrf_token: 'stale' }); await restored.tick();
assert.equal(restored.activeStreams().length, 0);
assert.equal(restored.ctx.csrf, 'cycle-9');
assert.equal(restored.windows.size, 0);
assert.equal(restored.events.size, 0);
// Startup reload-marker is released only after the complete panel activation,
// never merely when /api/auth/session happened to return successfully.
async function checkStartup(activationResult, failLanguage) {
let bootstrap, clears = 0, failures = 0;
const modules = Object.fromEntries([
['rustProxyIntegrations', 'bindings'], ['rustProxyVhosts', 'render'], ['rustProxySettings', 'render'],
['rustProxyCachePanel', 'refresh'], ['rustProxyTrafficPanel', 'refresh'],
['rustProxyPreferences', 'load'], ['rustProxyRefresh', 'start'],
].map(([name, method]) => [name, { [method]() {} }]));
const ctx = {
window: modules, document: { readyState: 'loading', addEventListener(_name, cb) { bootstrap = cb; } },
api: async () => ({ csrf_token: 'valid' }), loadLanguages: async () => { if (failLanguage) throw new Error('Language assets missing'); },
translate() {}, setupRequired: false, syncAuthForm() {}, activate: async () => activationResult,
clearInvalidOriginRecovery() { clears++; }, recoverInvalidOrigin: () => false,
showStartupFailure() { failures++; },
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
};
runInNewContext(startup, ctx);
bootstrap(); for (let i = 0; i < 12; i++) await Promise.resolve();
return { clears, failures };
}
assert.deepEqual(await checkStartup(true, false), { clears: 1, failures: 0 });
assert.deepEqual(await checkStartup(false, false), { clears: 0, failures: 0 });
assert.deepEqual(await checkStartup(true, true), { clears: 0, failures: 1 });
console.log('PASS: 0.31.3 real-source resume race, 403 retry, BFCache, 401 lock, SSE pause, stale epochs, startup recovery limit and repeated cycles');