// @vitest-environment jsdom import { describe, it, expect, beforeEach, vi } from 'vitest'; // auth.ts guards every localStorage access behind `browser`; force it true so the // jsdom localStorage is actually used (the shared mock stubs it to false). vi.mock('$app/environment', () => ({ browser: true, dev: false, building: false, version: 'test' })); import { setAuth, setAdminAuth, getToken, getPin, getExpiry, getRole, getUserId, clearAuth, clearPin } from './auth'; /** Build a JWT-shaped string (header.payload.sig) with the given claims. */ function makeJwt(claims: object): string { const seg = (o: object) => btoa(JSON.stringify(o)); return `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(claims)}.sig`; } beforeEach(() => { localStorage.clear(); sessionStorage.clear(); }); describe('auth — token storage', () => { it('setAuth then getToken/getPin round-trips', () => { setAuth('a.b.c', '1234', 'uid-1', 'Alice'); expect(getToken()).toBe('a.b.c'); expect(getPin()).toBe('1234'); }); it('setAuth without a PIN leaves the PIN unset', () => { setAuth('a.b.c', null, 'uid-1'); expect(getToken()).toBe('a.b.c'); expect(getPin()).toBeNull(); }); it('clearAuth removes the token but KEEPS the PIN (needed for recovery)', () => { setAuth('a.b.c', '1234', 'uid'); clearAuth(); expect(getToken()).toBeNull(); expect(getPin()).toBe('1234'); }); it('clearPin removes the cached PIN', () => { setAuth('a.b.c', '1234', 'uid'); clearPin(); expect(getPin()).toBeNull(); }); }); describe('auth — admin (sessionStorage) vs guest (localStorage) identity displacement', () => { // Regression guard for a privilege-escalation: reads are sessionStorage-first, so a guest // login MUST displace any resident admin token (else the guest silently runs as admin on a // shared device), and symmetrically an admin login must displace a resident guest token. it('a guest login displaces a resident admin session (no privilege escalation)', () => { setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin'); expect(getRole()).toBe('admin'); setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest'); expect(getRole()).toBe('guest'); // NOT admin expect(getUserId()).toBe('guest-uid'); expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); // admin token cleared }); it('an admin login displaces a resident guest session but keeps the guest PIN', () => { setAuth(makeJwt({ role: 'guest' }), '1234', 'guest-uid', 'Guest'); setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin'); expect(getRole()).toBe('admin'); expect(getUserId()).toBe('admin-uid'); expect(localStorage.getItem('eventsnap_jwt')).toBeNull(); // guest token cleared expect(getPin()).toBe('1234'); // PIN preserved for later recovery }); it('clearAuth wipes both stores', () => { setAdminAuth(makeJwt({ role: 'admin' }), 'admin-uid', 'Admin'); clearAuth(); expect(getToken()).toBeNull(); expect(sessionStorage.getItem('eventsnap_jwt')).toBeNull(); }); }); describe('auth — JWT claim decode', () => { it('getExpiry decodes the exp claim (seconds → ms Date)', () => { const exp = 2_000_000_000; // far-future unix seconds setAuth(makeJwt({ exp }), null, 'u'); expect(getExpiry()?.getTime()).toBe(exp * 1000); }); it('getExpiry is null with no token, no exp claim, or a malformed token', () => { expect(getExpiry()).toBeNull(); // no token setAuth(makeJwt({ role: 'guest' }), null, 'u'); // token without exp expect(getExpiry()).toBeNull(); localStorage.setItem('eventsnap_jwt', 'not-a-jwt'); // unparseable expect(getExpiry()).toBeNull(); }); it('getRole extracts the role claim; null when absent or malformed', () => { setAuth(makeJwt({ role: 'host' }), null, 'u'); expect(getRole()).toBe('host'); setAuth(makeJwt({ exp: 1 }), null, 'u'); // no role claim expect(getRole()).toBeNull(); localStorage.setItem('eventsnap_jwt', 'x.y.z'); // unparseable payload expect(getRole()).toBeNull(); }); });