From 74770aa0431429d6f68409d2be54febb4e95ce91 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 19 May 2026 22:47:59 +0200 Subject: [PATCH 1/4] test(frontend/auth): cover cross-tab logout sync (closes #493) PR #487 added installStorageListener() in api/client.ts as the compensating control for moving tokens off sessionStorage. The behaviour is implemented but had no automated regression test; if the listener's key filter, newValue check, or idempotency guard ever regresses, the SECURITY claim in client.ts becomes silently false. Add crosstab-session.test.ts covering: - cleared auth keys (authToken, apiKey, csrfToken) zero in-memory state and call window.location.reload - non-auth keys are ignored - partial updates (newValue !== null, e.g. token refresh) are ignored - initAuth() is idempotent: storage listener installs exactly once Each test loads api/client in jest.isolateModules() so the storageListenerInstalled guard is fresh per test. --- .../src/__tests__/crosstab-session.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 frontend/src/__tests__/crosstab-session.test.ts diff --git a/frontend/src/__tests__/crosstab-session.test.ts b/frontend/src/__tests__/crosstab-session.test.ts new file mode 100644 index 000000000..14356ced9 --- /dev/null +++ b/frontend/src/__tests__/crosstab-session.test.ts @@ -0,0 +1,59 @@ +/** + * Cross-tab logout sync regression (issue #493). + * Guards installStorageListener() in api/client.ts: if its key filter, + * newValue check, install-order, or idempotency ever regresses the + * SECURITY mitigation noted in client.ts goes silently false. + */ + +function loadAuth(): { api: typeof import('../api'); handler: (e: StorageEvent) => void; reload: jest.Mock; addSpy: jest.SpyInstance } { + let out: ReturnType | undefined; + jest.isolateModules(() => { + const addSpy = jest.spyOn(window, 'addEventListener'); + const reload = jest.fn(); + Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload } }); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const api = require('../api') as typeof import('../api'); + api.initAuth(); + const call = addSpy.mock.calls.find(c => c[0] === 'storage'); + if (!call) throw new Error('initAuth() did not install a storage listener'); + out = { api, handler: call[1] as (e: StorageEvent) => void, reload, addSpy }; + }); + return out!; +} + +// jsdom rejects the jest.fn() storage mock as storageArea, so omit it +// (the listener never reads it). +const evt = (key: string | null, newValue: string | null): StorageEvent => + new StorageEvent('storage', { key, newValue }); + +describe('cross-tab logout sync (issue #493)', () => { + test.each(['authToken', 'apiKey', 'csrfToken'])('cleared %s in another tab clears in-memory auth and reloads', key => { + const { api, handler, reload } = loadAuth(); + api.setAuthToken('t'); api.setApiKey('k'); + handler(evt(key, null)); + expect(api.isAuthenticated()).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + + test('ignores non-auth keys', () => { + const { api, handler, reload } = loadAuth(); + api.setAuthToken('t'); + handler(evt('someOtherKey', null)); + expect(api.isAuthenticated()).toBe(true); + expect(reload).not.toHaveBeenCalled(); + }); + + test('ignores partial updates such as token refresh (newValue !== null)', () => { + const { api, handler, reload } = loadAuth(); + api.setAuthToken('t'); + handler(evt('authToken', 'rotated')); + expect(api.isAuthenticated()).toBe(true); + expect(reload).not.toHaveBeenCalled(); + }); + + test('initAuth() is idempotent: storage listener installs exactly once', () => { + const { api, addSpy } = loadAuth(); + api.initAuth(); api.initAuth(); + expect(addSpy.mock.calls.filter(c => c[0] === 'storage')).toHaveLength(1); + }); +}); From 8d42715d7df56934e35a1a86499dc7f835ce9c63 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 20 May 2026 00:16:17 +0200 Subject: [PATCH 2/4] test(frontend/auth): patch only window.location.reload (CR nit) Capture the original window.location reference before tests and restore it in an afterEach, so each test starts from a clean Location object. This implements CR's teardown recommendation while keeping the Object.defineProperty(window, 'location', ...) approach required by jsdom (window.location.reload is non-configurable and cannot be patched directly). --- frontend/src/__tests__/crosstab-session.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/__tests__/crosstab-session.test.ts b/frontend/src/__tests__/crosstab-session.test.ts index 14356ced9..b602e426d 100644 --- a/frontend/src/__tests__/crosstab-session.test.ts +++ b/frontend/src/__tests__/crosstab-session.test.ts @@ -5,6 +5,11 @@ * SECURITY mitigation noted in client.ts goes silently false. */ +const originalLocation = window.location; +afterEach(() => { + Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); +}); + function loadAuth(): { api: typeof import('../api'); handler: (e: StorageEvent) => void; reload: jest.Mock; addSpy: jest.SpyInstance } { let out: ReturnType | undefined; jest.isolateModules(() => { From 8446e77a35c92e8ad2b2fd09b2d8b737709f7b64 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 8 Jun 2026 12:50:45 -0700 Subject: [PATCH 3/4] test(frontend/auth): clear localStorage between cross-tab logout tests (CR nit) initAuth() seeds in-memory auth from localStorage, so without clearing it between tests a stale token from one case could leak into the next. Extend the existing afterEach to call localStorage.clear() for proper isolation. --- frontend/src/__tests__/crosstab-session.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/__tests__/crosstab-session.test.ts b/frontend/src/__tests__/crosstab-session.test.ts index b602e426d..df8f5d8eb 100644 --- a/frontend/src/__tests__/crosstab-session.test.ts +++ b/frontend/src/__tests__/crosstab-session.test.ts @@ -8,6 +8,9 @@ const originalLocation = window.location; afterEach(() => { Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); + // initAuth() seeds in-memory auth from localStorage, so clear it between + // tests to stop stale tokens from one case leaking into the next. + localStorage.clear(); }); function loadAuth(): { api: typeof import('../api'); handler: (e: StorageEvent) => void; reload: jest.Mock; addSpy: jest.SpyInstance } { From 7b19b4e0f510a6aef729504d53c7cb7ed64bc67d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 8 Jun 2026 15:51:14 -0700 Subject: [PATCH 4/4] test(frontend/auth): address CR nits on cross-tab logout test - replace the out! non-null assertion in loadAuth() with an explicit guard that throws a clear error if isolateModules never ran - add a regression case for the !e.key guard in installStorageListener: a null-key storage event (localStorage.clear()) must not clear in-memory auth or reload --- frontend/src/__tests__/crosstab-session.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/crosstab-session.test.ts b/frontend/src/__tests__/crosstab-session.test.ts index df8f5d8eb..fb03e1e1e 100644 --- a/frontend/src/__tests__/crosstab-session.test.ts +++ b/frontend/src/__tests__/crosstab-session.test.ts @@ -26,7 +26,8 @@ function loadAuth(): { api: typeof import('../api'); handler: (e: StorageEvent) if (!call) throw new Error('initAuth() did not install a storage listener'); out = { api, handler: call[1] as (e: StorageEvent) => void, reload, addSpy }; }); - return out!; + if (!out) throw new Error('loadAuth failed to capture storage listener'); + return out; } // jsdom rejects the jest.fn() storage mock as storageArea, so omit it @@ -59,6 +60,14 @@ describe('cross-tab logout sync (issue #493)', () => { expect(reload).not.toHaveBeenCalled(); }); + test('ignores events with null key (e.g. localStorage.clear())', () => { + const { api, handler, reload } = loadAuth(); + api.setAuthToken('t'); + handler(evt(null, null)); + expect(api.isAuthenticated()).toBe(true); + expect(reload).not.toHaveBeenCalled(); + }); + test('initAuth() is idempotent: storage listener installs exactly once', () => { const { api, addSpy } = loadAuth(); api.initAuth(); api.initAuth();