import { ACTIVITY_STORAGE_KEY, SESSION_START_STORAGE_KEY, AUTH_PRESENCE_KEY, INACTIVITY_TIMEOUT_MS, ABSOLUTE_SESSION_TIMEOUT_MS, markActivity, markSessionStart, isSessionActive, performSessionLogout } from '@/utils/session'; /** * The shared logout contract. Every logout path — the manual button and the * inactivity timer — goes through here so they cannot clear different things. * * The security-relevant property is that local state is wiped and the page * HARD-navigates even when a caller-supplied teardown throws: a half-completed * logout that leaves an authenticated page rendered is the failure mode this * module exists to prevent. */ describe('session', () => { let replace; beforeEach(() => { replace = jest.fn(); Object.defineProperty(window, 'location', { writable: true, configurable: true, value: { href: 'http://console.local/deliveries', replace } }); }); describe('timeout policy', () => { it('should expire an idle session after one hour', () => { expect(INACTIVITY_TIMEOUT_MS).toBe(60 * 60 * 1000); }); it('should cap a session at one hour since login regardless of activity', () => { expect(ABSOLUTE_SESSION_TIMEOUT_MS).toBe(60 * 60 * 1000); }); }); describe('markActivity', () => { it('should record the current time as a millisecond string', () => { jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z')); markActivity(); expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBe(String(Date.now())); jest.useRealTimers(); }); it('should overwrite the previous activity stamp', () => { jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z')); markActivity(); const first = localStorage.getItem(ACTIVITY_STORAGE_KEY); jest.setSystemTime(new Date('2026-08-27T10:05:00Z')); markActivity(); expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).not.toBe(first); expect(Number(localStorage.getItem(ACTIVITY_STORAGE_KEY))).toBeGreaterThan(Number(first)); jest.useRealTimers(); }); }); describe('markSessionStart', () => { it('should start the absolute-lifetime clock separately from the activity clock', () => { jest.useFakeTimers().setSystemTime(new Date('2026-08-27T10:00:00Z')); markSessionStart(); expect(localStorage.getItem(SESSION_START_STORAGE_KEY)).toBe(String(Date.now())); expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull(); jest.useRealTimers(); }); }); describe('isSessionActive', () => { it('should report an active session when the auth presence key is set', () => { localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com'); expect(isSessionActive()).toBe(true); }); it('should report no session when the key is absent', () => { expect(isSessionActive()).toBe(false); }); it('should report no session for an empty presence value', () => { localStorage.setItem(AUTH_PRESENCE_KEY, ''); expect(isSessionActive()).toBe(false); }); it('should return a boolean, not the stored string', () => { localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com'); expect(typeof isSessionActive()).toBe('boolean'); }); }); describe('performSessionLogout', () => { it('should wipe all local state and hard-navigate to login', () => { localStorage.setItem('doormileToken', 'jwt-abc'); localStorage.setItem(AUTH_PRESENCE_KEY, 'ops@doormile.com'); localStorage.setItem('anything-else', 'x'); performSessionLogout(); expect(localStorage.length).toBe(0); expect(replace).toHaveBeenCalledWith('/login'); }); it('should clear the react-query cache so no fetched data survives', () => { const queryClient = { clear: jest.fn() }; performSessionLogout({ queryClient }); expect(queryClient.clear).toHaveBeenCalledTimes(1); }); it('should dispatch the supplied teardown actions', () => { const dispatch = jest.fn(); const clearFcmToken = jest.fn(() => ({ type: 'fcm/clear' })); const logoutUser = jest.fn(() => ({ type: 'auth/logout' })); performSessionLogout({ dispatch, clearFcmToken, logoutUser }); expect(dispatch).toHaveBeenCalledWith({ type: 'fcm/clear' }); expect(dispatch).toHaveBeenCalledWith({ type: 'auth/logout' }); }); it('should skip the redux teardown when no dispatch is supplied', () => { const clearFcmToken = jest.fn(); expect(() => performSessionLogout({ clearFcmToken })).not.toThrow(); expect(clearFcmToken).not.toHaveBeenCalled(); }); it('should work with no arguments at all', () => { expect(() => performSessionLogout()).not.toThrow(); expect(replace).toHaveBeenCalledWith('/login'); }); it('should still wipe storage and redirect when the cache teardown throws', () => { // The security-critical guarantee: a broken teardown must not leave an // authenticated page rendered over live local state. localStorage.setItem('doormileToken', 'jwt-abc'); const queryClient = { clear: jest.fn(() => { throw new Error('cache blew up'); }) }; expect(() => performSessionLogout({ queryClient })).toThrow('cache blew up'); expect(localStorage.length).toBe(0); expect(replace).toHaveBeenCalledWith('/login'); }); it('should still wipe storage and redirect when a dispatched action throws', () => { localStorage.setItem('doormileToken', 'jwt-abc'); const dispatch = jest.fn(() => { throw new Error('reducer blew up'); }); expect(() => performSessionLogout({ dispatch, logoutUser: () => ({ type: 'auth/logout' }) }) ).toThrow('reducer blew up'); expect(localStorage.length).toBe(0); expect(replace).toHaveBeenCalledWith('/login'); }); it('should navigate rather than route, so the back button cannot resurrect the console', () => { performSessionLogout(); expect(replace).toHaveBeenCalledTimes(1); expect(replace).toHaveBeenCalledWith('/login'); }); }); });