aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/harness/session_harness.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/harness/session_harness.mjs')
-rw-r--r--packages/meshbay-hub/tests/harness/session_harness.mjs165
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));