v0.31.3
This commit is contained in:
1 parent
0facfe6f5c
commit
95c55da2ee
39 files changed
+715
-151
No files matched your search
@@ -0,0 +1,207 @@
|
||||
#!/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');
|
||||
Reference in new issue
Block a user