2026-07-13 19:31:40 -04:00
|
|
|
/**
|
|
|
|
|
* QA-C5: Auth storage module tests (replaces the Redux authSlice tests).
|
|
|
|
|
*
|
|
|
|
|
* Tests the sessionStorage-backed session persistence (WEB-C1):
|
|
|
|
|
* - setAuthUser/getAuthUser round-trip
|
|
|
|
|
* - getAuthUser tolerates malformed stored JSON
|
|
|
|
|
* - clearAuth removes the stored session
|
|
|
|
|
* - isTokenValid accepts unexpired JWTs and rejects expired/malformed ones
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
|
import type { AuthUser } from '@proposal-system/api-contracts';
|
|
|
|
|
import { getAuthUser, setAuthUser, clearAuth, isTokenValid } from '../authStorage';
|
|
|
|
|
import { STORAGE_KEY_TOKEN } from '../../../constants';
|
|
|
|
|
|
|
|
|
|
function createToken(expOffsetSeconds: number): string {
|
|
|
|
|
const header = btoa(JSON.stringify({ alg: 'HS256' }));
|
|
|
|
|
const payload = btoa(JSON.stringify({
|
|
|
|
|
sub: 'test-user',
|
|
|
|
|
exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
|
|
|
|
|
}));
|
|
|
|
|
return `${header}.${payload}.fake-signature`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user: AuthUser = {
|
|
|
|
|
id: 'user-1',
|
|
|
|
|
email: 'admin@test.com',
|
|
|
|
|
displayName: 'Admin',
|
|
|
|
|
role: 'Admin',
|
|
|
|
|
token: createToken(3600),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('authStorage', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
window.sessionStorage.clear();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: setAuthUser persists to sessionStorage and getAuthUser reads it back', () => {
|
|
|
|
|
setAuthUser(user);
|
|
|
|
|
|
|
|
|
|
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).not.toBeNull();
|
|
|
|
|
expect(getAuthUser()).toEqual(user);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: getAuthUser returns null when nothing is stored', () => {
|
|
|
|
|
expect(getAuthUser()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: getAuthUser returns null for malformed stored JSON', () => {
|
|
|
|
|
window.sessionStorage.setItem(STORAGE_KEY_TOKEN, 'not-valid-json{{{');
|
|
|
|
|
|
|
|
|
|
expect(getAuthUser()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: clearAuth removes the stored session', () => {
|
|
|
|
|
setAuthUser(user);
|
|
|
|
|
clearAuth();
|
|
|
|
|
|
|
|
|
|
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).toBeNull();
|
|
|
|
|
expect(getAuthUser()).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: isTokenValid accepts a token expiring in the future', () => {
|
|
|
|
|
expect(isTokenValid(createToken(3600))).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('QA-C5: isTokenValid rejects an expired token', () => {
|
|
|
|
|
expect(isTokenValid(createToken(-3600))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-13 19:43:48 -04:00
|
|
|
it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => {
|
|
|
|
|
// '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw.
|
|
|
|
|
const payload = btoa(JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 }))
|
|
|
|
|
.replace(/\+/g, '-')
|
|
|
|
|
.replace(/\//g, '_')
|
|
|
|
|
.replace(/=+$/, '');
|
|
|
|
|
expect(payload).toMatch(/[-_]/);
|
|
|
|
|
|
|
|
|
|
expect(isTokenValid(`${btoa(JSON.stringify({ alg: 'HS256' }))}.${payload}.sig`)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-13 19:31:40 -04:00
|
|
|
it('QA-C5: isTokenValid rejects missing or malformed tokens', () => {
|
|
|
|
|
expect(isTokenValid(null)).toBe(false);
|
|
|
|
|
expect(isTokenValid(undefined)).toBe(false);
|
|
|
|
|
expect(isTokenValid('')).toBe(false);
|
|
|
|
|
expect(isTokenValid('not-a-jwt')).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|