208 lines
12 KiB
JavaScript
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');
|