172 lines
6.0 KiB
JavaScript
172 lines
6.0 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|