#!/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');