diff options
Diffstat (limited to 'packages/meshbay-hub/tests/harness/session_harness.mjs')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/session_harness.mjs | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs new file mode 100644 index 0000000..f0d0cb2 --- /dev/null +++ b/packages/meshbay-hub/tests/harness/session_harness.mjs @@ -0,0 +1,165 @@ +// Run the shipped session code against a hub that behaves like the real one. +// +// The defect this guards was not subtle once seen: the hub rotates refresh +// tokens — it revokes the one presented and returns a replacement, and a +// revoked token presented again revokes the entire family — and the client kept +// only the access token out of that response. So the refresh token was spent on +// first use and the second attempt locked the account out of renewal +// altogether, leaving signing out and back in as the only way to carry on. +// +// The fake hub below enforces exactly that rule, which is the point: a stub +// that happily accepts the same refresh token twice would have passed against +// the broken client. +import { readFileSync } from 'fs'; + +const app = readFileSync(process.argv[2], 'utf8'); +const cfg = JSON.parse(process.argv[3] || '{}'); +const { scenario = 'expired-access' } = cfg; + +// ── The pieces of app.js under test, lifted as text ────────────────────────── +const between = (from, to) => { + const i = app.indexOf(from); + if (i < 0) throw new Error(`not found: ${from}`); + const j = app.indexOf(to, i); + if (j < 0) throw new Error(`not found: ${to}`); + return app.slice(i, j); +}; +const sessionBlock = between('let _auth = loadAuth();', '\n// ── Theme'); +const hubFetchFn = between('async function hubFetch(', '\n// ── Router'); + +// ── The world ──────────────────────────────────────────────────────────────── +const store = new Map(); +const localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, v), + removeItem: (k) => store.delete(k), +}; +const AUTH_KEY = 'mb_auth'; +const HUB = ''; +const TOKEN_RENEW_MARGIN_S = Number(app.match(/const TOKEN_RENEW_MARGIN_S = (\d+)/)[1]); + +const jwt = (secondsLeft) => { + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + secondsLeft, + })).toString('base64url'); + return `head.${payload}.sig`; +}; + +// The hub. One live refresh token at a time; presenting a revoked one kills the +// family, as the real endpoint does. +let live = 'RT-1'; +let issued = 1; +let familyRevoked = false; +const seen = []; +let refreshCalls = 0; + +const fakeFetch = async (url, opts = {}) => { + if (url.endsWith('/v1/users/token/refresh')) { + refreshCalls++; + const presented = JSON.parse(opts.body).refresh_token; + seen.push(presented); + if (familyRevoked || presented !== live) { + familyRevoked = true; + return { ok: false, status: 401, + json: async () => ({ detail: 'Token reuse detected — family revoked' }) }; + } + live = `RT-${++issued}`; + return { + ok: true, status: 200, + json: async () => ({ access_token: jwt(3600), refresh_token: live, + token_type: 'bearer', expires_in: 3600 }), + }; + } + // Any other endpoint: 401 unless the bearer is a token that has life left. + const auth = (opts.headers || {})['Authorization'] || ''; + const token = auth.replace('Bearer ', ''); + let alive = false; + try { + alive = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()).exp + > Math.floor(Date.now() / 1000); + } catch { alive = false; } + calls.push({ url, token, alive }); + if (!alive) { + return { ok: false, status: 401, json: async () => ({ detail: 'Token expired or invalid' }) }; + } + return { ok: true, status: 200, json: async () => ({ ok: true }) }; +}; +const calls = []; + +let _bundleKey = null; +const _clearKeyDB = () => {}; +function loadAuth() { + try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } +} +function saveAuth(auth) { + if (auth) localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); + else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); } +} + +// The session starts with an access token that expired an hour ago and a +// refresh token that is still perfectly good — a tab reopened the next morning. +const startingLife = scenario === 'fresh-access' ? 3600 + : scenario === 'nearly-expired' ? 60 + : -60; +localStorage.setItem(AUTH_KEY, JSON.stringify({ + username: 'someone', userId: 'u1', role: 'user', + token: jwt(startingLife), refreshToken: 'RT-1', +})); + +const ctx = { + localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S, + loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'), + console, +}; +const fns = new Function(...Object.keys(ctx), + sessionBlock + '\n' + hubFetchFn + + '\n return {hubFetch, refreshAccessToken, ensureFreshToken, tokenLifeLeft,' + + ' getAuth: () => _auth};')(...Object.values(ctx)); + +// ── The run ────────────────────────────────────────────────────────────────── +const out = { scenario }; +const stored = () => JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); + +if (scenario === 'concurrent') { + // Two requests hit a dead token at the same instant. If each renews on its + // own, the second presents a token the first has already spent, and the hub + // tears down the family. + const before = stored().token; + // Either may reject once the family is gone; the run still has to report. + await Promise.allSettled([ + fns.hubFetch('/v1/groups/mine', { token: before }), + fns.hubFetch('/v1/users/me', { token: before }), + ]); +} else if (scenario === 'repeat') { + // Renew several times over. Each response carries a new refresh token, and + // keeping the old one would be caught on the very next attempt. + for (let i = 0; i < 4; i++) { + await fns.refreshAccessToken(); + // Age the access token again so the next one really has to renew. A + // rejected renewal signs the session out, and there is then nothing left + // to age — which is the outcome under test, not a reason to stop. + const a = stored(); + if (!a) break; + localStorage.setItem(AUTH_KEY, JSON.stringify({ ...a, token: jwt(-60) })); + const live_ = fns.getAuth(); + if (live_) live_.token = jwt(-60); + } +} else if (scenario === 'refresh-rejected') { + localStorage.setItem(AUTH_KEY, JSON.stringify({ ...stored(), refreshToken: 'RT-STALE' })); + fns.getAuth().refreshToken = 'RT-STALE'; + try { await fns.hubFetch('/v1/groups/mine', { token: stored().token }); } + catch (e) { out.threw = String(e.message); } +} else { + await fns.ensureFreshToken(); + await fns.hubFetch('/v1/groups/mine', { token: stored() ? stored().token : null }); +} + +out.refreshCalls = refreshCalls; +out.refreshTokensPresented = seen; +out.familyRevoked = familyRevoked; +out.signedOut = stored() === null; +out.storedRefreshToken = stored() ? stored().refreshToken : null; +out.apiCalls = calls.map(c => ({ url: c.url, accepted: c.alive })); +out.everRejected = calls.some(c => !c.alive); +out.finalCallAccepted = calls.length ? calls[calls.length - 1].alive : null; +console.log(JSON.stringify(out)); |