import { test } from 'node:test'; import assert from 'node:assert/strict'; import { setFallbackSession, removeFallbackSession, getFallbackSession, subscribeSessionChanges, } from './sessions'; // The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts). const SESSION_BLOB_KEY = 'cinny_session_v1'; // The fallback-session helpers read/write specific `cinny_*` keys directly on // `localStorage`. node has none, so install a controllable in-memory mock per // case backed by a Map. const installStorage = (): Map => { const store = new Map(); (globalThis as { localStorage?: unknown }).localStorage = { getItem: (key: string) => (store.has(key) ? store.get(key) : null), setItem: (key: string, value: string) => { store.set(key, String(value)); }, removeItem: (key: string) => { store.delete(key); }, }; return store; }; test('setFallbackSession writes the cinny_* keys', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); assert.equal(store.get('cinny_access_token'), 'token-1'); assert.equal(store.get('cinny_device_id'), 'DEVICE1'); assert.equal(store.get('cinny_user_id'), '@alice:example.org'); assert.equal(store.get('cinny_hs_base_url'), 'https://hs.example.org'); }); test('getFallbackSession round-trips a full session and flags fallback stores', () => { installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); assert.deepEqual(getFallbackSession(), { baseUrl: 'https://hs.example.org', userId: '@alice:example.org', deviceId: 'DEVICE1', accessToken: 'token-1', fallbackSdkStores: true, }); }); test('getFallbackSession returns undefined when nothing is stored', () => { installStorage(); assert.equal(getFallbackSession(), undefined); }); test('legacy path: undefined when a single legacy key is missing (no blob)', () => { // With no atomic blob, every one of the four legacy keys is required; missing // any one yields undefined (the pre-blob behaviour). const keys = [ 'cinny_access_token', 'cinny_device_id', 'cinny_user_id', 'cinny_hs_base_url', ] as const; keys.forEach((missing) => { installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); localStorage.removeItem(SESSION_BLOB_KEY); localStorage.removeItem(missing); assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`); }); }); test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => { installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); // Simulate a torn legacy write — the authoritative blob must still resolve. localStorage.removeItem('cinny_access_token'); assert.deepEqual(getFallbackSession(), { baseUrl: 'https://hs.example.org', userId: '@alice:example.org', deviceId: 'DEVICE1', accessToken: 'token-1', fallbackSdkStores: true, }); }); test('removeFallbackSession clears all keys', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); removeFallbackSession(); assert.equal(store.size, 0); assert.equal(getFallbackSession(), undefined); }); test('round-trips an OIDC session (refresh token, expiry, oidc metadata)', () => { installStorage(); setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://matrix-client.mozilla.org', { refreshToken: 'refresh-xyz', expiresInMs: 3_600_000, oidc: { issuer: 'https://chat.mozilla.org/', clientId: 'client-123', redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback', idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' }, }, }); const s = getFallbackSession(); assert.ok(s); assert.equal(s.refreshToken, 'refresh-xyz'); // stored as absolute expiry, read back as remaining lifetime assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000); assert.deepEqual(s.oidc, { issuer: 'https://chat.mozilla.org/', clientId: 'client-123', redirectUri: 'https://chat.lotusguild.org/auth/oidc/callback', idTokenClaims: { sub: '@bob:mozilla.org', aud: 'client-123' }, }); }); test('a password session carries no OIDC fields, and re-saving clears stale OIDC keys', () => { installStorage(); // first an OIDC session... setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'r', oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, }); assert.ok(getFallbackSession()?.oidc); // ...overwritten by a plain password session must drop the OIDC state setFallbackSession('tok2', 'DEV', '@alice:example.org', 'https://hs'); const s = getFallbackSession(); assert.ok(s); assert.equal(s.oidc, undefined); assert.equal(s.refreshToken, undefined); // The overwritten blob must not retain the stale OIDC state either. const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!); assert.equal(blob.oidc, undefined); assert.equal(blob.refreshToken, undefined); }); // --------------------------------------------------------------------------- // Atomic blob: write/read round-trip // --------------------------------------------------------------------------- test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); const raw = store.get(SESSION_BLOB_KEY); assert.ok(raw, 'blob key must be written'); assert.deepEqual(JSON.parse(raw!), { accessToken: 'token-1', deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs.example.org', }); }); test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => { const store = installStorage(); setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'refresh-xyz', expiresInMs: 3_600_000, oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb', idTokenClaims: { sub: '@bob:mozilla.org' }, }, }); const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); assert.equal(blob.refreshToken, 'refresh-xyz'); assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now()); assert.deepEqual(blob.oidc, { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb', idTokenClaims: { sub: '@bob:mozilla.org' }, }); const s = getFallbackSession(); assert.ok(s); assert.equal(s.refreshToken, 'refresh-xyz'); assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000); assert.deepEqual(s.oidc, { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb', idTokenClaims: { sub: '@bob:mozilla.org' }, }); }); // --------------------------------------------------------------------------- // Migration: legacy-only storage → transparent read → blob persisted on write // --------------------------------------------------------------------------- test('legacy-only storage (no blob) is read transparently', () => { const store = installStorage(); // Simulate an older build: legacy keys present, no blob. store.set('cinny_access_token', 'tok'); store.set('cinny_device_id', 'DEV'); store.set('cinny_user_id', '@carol:example.org'); store.set('cinny_hs_base_url', 'https://hs'); assert.equal(store.has(SESSION_BLOB_KEY), false); assert.deepEqual(getFallbackSession(), { baseUrl: 'https://hs', userId: '@carol:example.org', deviceId: 'DEV', accessToken: 'tok', fallbackSdkStores: true, }); }); test('first write after a legacy-only read persists the blob (migration)', () => { const store = installStorage(); store.set('cinny_access_token', 'old'); store.set('cinny_device_id', 'DEV'); store.set('cinny_user_id', '@carol:example.org'); store.set('cinny_hs_base_url', 'https://hs'); // Reads are side-effect free — no blob yet. getFallbackSession(); assert.equal(store.has(SESSION_BLOB_KEY), false); // The next write (e.g. a token refresh) persists the atomic blob. setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs'); assert.ok(store.has(SESSION_BLOB_KEY)); assert.equal(getFallbackSession()?.accessToken, 'new'); }); // --------------------------------------------------------------------------- // Corruption / partial blob → legacy fallback; blob wins on disagreement // --------------------------------------------------------------------------- test('corrupt blob (bad JSON) falls back to the legacy keys', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); // Corrupt the blob but keep the legacy keys intact. store.set(SESSION_BLOB_KEY, '{not valid json'); assert.deepEqual(getFallbackSession(), { baseUrl: 'https://hs.example.org', userId: '@alice:example.org', deviceId: 'DEVICE1', accessToken: 'token-1', fallbackSdkStores: true, }); }); test('partial blob (missing a required field) falls back to the legacy keys', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); // A blob missing accessToken is treated as absent. store.set( SESSION_BLOB_KEY, JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }), ); assert.equal(getFallbackSession()?.accessToken, 'token-1'); }); test('blob wins when blob and legacy keys disagree', () => { const store = installStorage(); setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); // Legacy keys drift to a stale token; the blob is authoritative. store.set('cinny_access_token', 'stale-legacy-token'); assert.equal(getFallbackSession()?.accessToken, 'blob-token'); }); // --------------------------------------------------------------------------- // Dual-write keeps blob + legacy in sync; removal clears both // --------------------------------------------------------------------------- test('dual-write keeps the legacy keys in sync with the blob', () => { const store = installStorage(); setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'r', expiresInMs: 1000, oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, }); // Legacy credential keys assert.equal(store.get('cinny_access_token'), 'tok'); assert.equal(store.get('cinny_device_id'), 'DEV'); assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org'); assert.equal(store.get('cinny_hs_base_url'), 'https://hs'); // Legacy OIDC keys assert.equal(store.get('cinny_refresh_token'), 'r'); assert.ok(store.has('cinny_expires_at')); assert.equal(store.get('cinny_oidc_issuer'), 'https://i'); assert.equal(store.get('cinny_oidc_client_id'), 'c'); assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb'); // Blob agrees const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); assert.equal(blob.accessToken, 'tok'); assert.equal(blob.refreshToken, 'r'); assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt)); }); test('removeFallbackSession clears BOTH the blob and every legacy key', () => { const store = installStorage(); setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'r', expiresInMs: 1000, oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, }); assert.ok(store.size > 0); removeFallbackSession(); assert.equal(store.size, 0, 'no session key may survive removal'); assert.equal(getFallbackSession(), undefined); }); // --------------------------------------------------------------------------- // Token-refresh update path (the path LotusOidcTokenRefresher uses) // --------------------------------------------------------------------------- test('token refresh via setFallbackSession updates blob + legacy atomically', () => { const store = installStorage(); // Initial OIDC session. setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'refresh-1', oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, }); // LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the // rotated tokens and the same identity/oidc refs. setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', { refreshToken: 'refresh-2', oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, }); // Blob updated const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); assert.equal(blob.accessToken, 'access-2'); assert.equal(blob.refreshToken, 'refresh-2'); // Legacy keys updated in lockstep assert.equal(store.get('cinny_access_token'), 'access-2'); assert.equal(store.get('cinny_refresh_token'), 'refresh-2'); // Reader sees the fresh token const s = getFallbackSession(); assert.equal(s?.accessToken, 'access-2'); assert.equal(s?.refreshToken, 'refresh-2'); }); // --------------------------------------------------------------------------- // Cross-tab sync: subscribeSessionChanges // --------------------------------------------------------------------------- // Minimal window/storage-event harness: node has neither. const installWindow = (): ((evt: { key: string | null }) => void)[] => { const listeners: ((evt: { key: string | null }) => void)[] = []; (globalThis as { window?: unknown }).window = { addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => { if (type === 'storage') listeners.push(cb); }, removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => { if (type !== 'storage') return; const i = listeners.indexOf(cb); if (i !== -1) listeners.splice(i, 1); }, }; return listeners; }; test('subscribeSessionChanges fires with the session when a session key changes', () => { installStorage(); const listeners = installWindow(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); let received: unknown = 'unset'; const unsub = subscribeSessionChanges((s) => { received = s; }); // Simulate another tab writing a new token, then dispatch the storage event. setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs'); listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY })); assert.notEqual(received, 'unset'); assert.equal((received as { accessToken?: string })?.accessToken, 'token-2'); unsub(); assert.equal(listeners.length, 0, 'unsubscribe removes the listener'); }); test('subscribeSessionChanges fires with null when the session is removed', () => { installStorage(); const listeners = installWindow(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); subscribeSessionChanges((s) => { assert.equal(s, null); }); removeFallbackSession(); listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY })); }); test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => { const store = installStorage(); const listeners = installWindow(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); let fired = false; subscribeSessionChanges(() => { fired = true; }); store.clear(); listeners.forEach((cb) => cb({ key: null })); assert.equal(fired, true); }); test('subscribeSessionChanges ignores unrelated storage keys', () => { installStorage(); const listeners = installWindow(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); let fired = false; subscribeSessionChanges(() => { fired = true; }); listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' })); assert.equal(fired, false); });