import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /** * Unit tests for the auth service (auth.svelte.ts). * * The auth module uses Svelte 5 $state runes, so we test it through * its public exported API. We mock global fetch and SvelteKit modules * to isolate the auth logic. */ // Mock $app/navigation before importing the module vi.mock('$app/navigation', () => ({ goto: vi.fn() })); // Mock $lib/api (getApiBaseUrl) vi.mock('$lib/api', () => ({ getApiBaseUrl: () => 'http://test.classeo.local:18000/api' })); // Helper: Create a valid-looking JWT token with a given payload function createTestJwt(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = btoa(JSON.stringify(payload)); const signature = 'test-signature'; return `${header}.${body}.${signature}`; } // Helper: Create a JWT with base64url encoding (- and _ instead of + and /) function createTestJwtUrlSafe(payload: Record): string { const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); const body = btoa(JSON.stringify(payload)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); const signature = 'test-signature'; return `${header}.${body}.${signature}`; } describe('auth service', () => { let authModule: typeof import('$lib/auth/auth.svelte'); const mockGoto = vi.fn(); beforeEach(async () => { vi.clearAllMocks(); vi.stubGlobal('fetch', vi.fn()); // Silence expected console.error/warn from error-handling tests vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); // Re-mock goto for each test const navModule = await import('$app/navigation'); (navModule.goto as ReturnType).mockImplementation(mockGoto); // Fresh import to reset $state vi.resetModules(); authModule = await import('$lib/auth/auth.svelte'); }); afterEach(() => { vi.restoreAllMocks(); }); // ========================================================================== // isAuthenticated / getAccessToken / getCurrentUserId // ========================================================================== describe('initial state', () => { it('should not be authenticated initially', () => { expect(authModule.isAuthenticated()).toBe(false); }); it('should return null access token initially', () => { expect(authModule.getAccessToken()).toBeNull(); }); it('should return null user ID initially', () => { expect(authModule.getCurrentUserId()).toBeNull(); }); }); // ========================================================================== // login // ========================================================================== describe('login', () => { it('should return success and set token on successful login', async () => { const token = createTestJwt({ sub: 'user@example.com', user_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); const result = await authModule.login({ email: 'user@example.com', password: 'password123' }); expect(result.success).toBe(true); expect(result.error).toBeUndefined(); expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getAccessToken()).toBe(token); expect(authModule.getCurrentUserId()).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); }); it('should send credentials with correct format', async () => { const token = createTestJwt({ sub: 'test@example.com', user_id: 'test-id' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'test@example.com', password: 'mypassword', captcha_token: 'captcha123' }); expect(fetch).toHaveBeenCalledWith( 'http://test.classeo.local:18000/api/login', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@example.com', password: 'mypassword', captcha_token: 'captcha123' }), credentials: 'include' }) ); }); it('should return invalid_credentials error on 401', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ type: '/errors/authentication', detail: 'Identifiants incorrects', attempts: 2, delay: 1, captchaRequired: false }) }); const result = await authModule.login({ email: 'user@example.com', password: 'wrong' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('invalid_credentials'); expect(result.error?.message).toBe('Identifiants incorrects'); expect(result.error?.attempts).toBe(2); expect(result.error?.delay).toBe(1); expect(authModule.isAuthenticated()).toBe(false); }); it('should return rate_limited error on 429', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 429, json: () => Promise.resolve({ type: '/errors/rate-limited', detail: 'Trop de tentatives', retryAfter: 60 }) }); const result = await authModule.login({ email: 'user@example.com', password: 'password' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('rate_limited'); expect(result.error?.retryAfter).toBe(60); }); it('should return captcha_required error on 428', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 428, json: () => Promise.resolve({ type: '/errors/captcha-required', detail: 'CAPTCHA requis', attempts: 5, captchaRequired: true }) }); const result = await authModule.login({ email: 'user@example.com', password: 'password' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('captcha_required'); expect(result.error?.captchaRequired).toBe(true); }); it('should return account_suspended error on 403', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ type: '/errors/account-suspended', detail: 'Votre compte a été suspendu' }) }); const result = await authModule.login({ email: 'suspended@example.com', password: 'password' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('account_suspended'); expect(result.error?.message).toBe('Votre compte a été suspendu'); }); it('should return captcha_invalid error on 400 with captcha-invalid type', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 400, json: () => Promise.resolve({ type: '/errors/captcha-invalid', detail: 'CAPTCHA invalide', captchaRequired: true }) }); const result = await authModule.login({ email: 'user@example.com', password: 'password', captcha_token: 'invalid-captcha' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('captcha_invalid'); expect(result.error?.captchaRequired).toBe(true); }); it('should return unknown error when fetch throws', async () => { (fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); const result = await authModule.login({ email: 'user@example.com', password: 'password' }); expect(result.success).toBe(false); expect(result.error?.type).toBe('unknown'); expect(result.error?.message).toContain('Erreur de connexion'); }); it('should extract user_id from JWT on successful login', async () => { const userId = 'b2c3d4e5-f6a7-8901-bcde-f23456789012'; const token = createTestJwt({ sub: 'user@test.com', user_id: userId }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); expect(authModule.getCurrentUserId()).toBe(userId); }); it('should handle JWT with base64url encoding', async () => { const userId = 'c3d4e5f6-a7b8-9012-cdef-345678901234'; const token = createTestJwtUrlSafe({ sub: 'urlsafe@test.com', user_id: userId }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'urlsafe@test.com', password: 'pass' }); expect(authModule.getCurrentUserId()).toBe(userId); }); it('should set currentUserId to null when token has no user_id claim', async () => { const token = createTestJwt({ sub: 'user@test.com' // no user_id claim }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Token is set but user ID extraction should return null expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getCurrentUserId()).toBeNull(); }); }); // ========================================================================== // refreshToken // ========================================================================== describe('refreshToken', () => { it('should set new token on successful refresh', async () => { const newToken = createTestJwt({ sub: 'user@test.com', user_id: 'refresh-user-id' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: newToken }) }); const result = await authModule.refreshToken(); expect(result).toBe(true); expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getCurrentUserId()).toBe('refresh-user-id'); }); it('should clear token on failed refresh', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401 }); const result = await authModule.refreshToken(); expect(result).toBe(false); expect(authModule.isAuthenticated()).toBe(false); expect(authModule.getCurrentUserId()).toBeNull(); }); it('should retry on 409 conflict (multi-tab race condition)', async () => { const newToken = createTestJwt({ sub: 'user@test.com', user_id: 'retry-user-id' }); // First call returns 409 (token already rotated) (fetch as ReturnType) .mockResolvedValueOnce({ ok: false, status: 409 }) // Second call succeeds with new cookie .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: newToken }) }); const result = await authModule.refreshToken(); expect(result).toBe(true); expect(fetch).toHaveBeenCalledTimes(2); expect(authModule.getCurrentUserId()).toBe('retry-user-id'); }); it('should fail after max retries on repeated 409', async () => { // Three consecutive 409s (max retries is 2) (fetch as ReturnType) .mockResolvedValueOnce({ ok: false, status: 409 }) .mockResolvedValueOnce({ ok: false, status: 409 }) .mockResolvedValueOnce({ ok: false, status: 409 }); const result = await authModule.refreshToken(); expect(result).toBe(false); expect(fetch).toHaveBeenCalledTimes(3); }); it('should clear state on network error during refresh', async () => { (fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); const result = await authModule.refreshToken(); expect(result).toBe(false); expect(authModule.isAuthenticated()).toBe(false); expect(authModule.getCurrentUserId()).toBeNull(); }); it('should send refresh request with correct format', async () => { (fetch as ReturnType).mockResolvedValueOnce({ ok: false, status: 401 }); await authModule.refreshToken(); expect(fetch).toHaveBeenCalledWith( 'http://test.classeo.local:18000/api/token/refresh', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', credentials: 'include' }) ); }); }); // ========================================================================== // logout // ========================================================================== describe('logout', () => { it('should clear token and redirect to login', async () => { // First login to set token const token = createTestJwt({ sub: 'user@test.com', user_id: 'logout-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); expect(authModule.isAuthenticated()).toBe(true); // Now logout (fetch as ReturnType).mockResolvedValueOnce({ ok: true }); await authModule.logout(); expect(authModule.isAuthenticated()).toBe(false); expect(authModule.getAccessToken()).toBeNull(); expect(authModule.getCurrentUserId()).toBeNull(); expect(mockGoto).toHaveBeenCalledWith('/login'); }); it('should still clear local state even if API call fails', async () => { // Login first const token = createTestJwt({ sub: 'user@test.com', user_id: 'logout-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Logout with API failure (fetch as ReturnType).mockRejectedValueOnce(new Error('Network error')); await authModule.logout(); expect(authModule.isAuthenticated()).toBe(false); expect(authModule.getAccessToken()).toBeNull(); expect(mockGoto).toHaveBeenCalledWith('/login'); }); it('should call onLogout callback when registered', async () => { const logoutCallback = vi.fn(); authModule.onLogout(logoutCallback); // Login first const token = createTestJwt({ sub: 'user@test.com', user_id: 'callback-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Logout (fetch as ReturnType).mockResolvedValueOnce({ ok: true }); await authModule.logout(); expect(logoutCallback).toHaveBeenCalledOnce(); }); }); // ========================================================================== // authenticatedFetch // ========================================================================== describe('authenticatedFetch', () => { it('should add Authorization header with Bearer token', async () => { // Login to set token const token = createTestJwt({ sub: 'user@test.com', user_id: 'auth-fetch-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Make authenticated request const mockResponse = { ok: true, status: 200 }; (fetch as ReturnType).mockResolvedValueOnce(mockResponse); await authModule.authenticatedFetch('http://test.classeo.local:18000/api/users'); // Second call should be the authenticated request (first was login) const calls = (fetch as ReturnType).mock.calls; expect(calls.length).toBeGreaterThanOrEqual(2); const lastCall = calls[1]!; expect(lastCall[0]).toBe('http://test.classeo.local:18000/api/users'); const headers = lastCall[1]?.headers as Headers; expect(headers).toBeDefined(); // Headers is a Headers object expect(headers.get('Authorization')).toBe(`Bearer ${token}`); }); it('should attempt refresh when no token is available', async () => { // No login - token is null // First fetch call will be the refresh attempt const refreshToken = createTestJwt({ sub: 'user@test.com', user_id: 'refreshed-user' }); (fetch as ReturnType) // Refresh call succeeds .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: refreshToken }) }) // Then the actual request succeeds .mockResolvedValueOnce({ ok: true, status: 200 }); await authModule.authenticatedFetch('http://test.classeo.local:18000/api/data'); // Should have made 2 calls: refresh + actual request expect(fetch).toHaveBeenCalledTimes(2); }); it('should retry with refresh on 401 response', async () => { // Login first const oldToken = createTestJwt({ sub: 'user@test.com', user_id: 'old-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: oldToken }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Request returns 401 const newToken = createTestJwt({ sub: 'user@test.com', user_id: 'new-user' }); (fetch as ReturnType) // First request returns 401 .mockResolvedValueOnce({ ok: false, status: 401 }) // Refresh succeeds .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: newToken }) }) // Retried request succeeds .mockResolvedValueOnce({ ok: true, status: 200 }); const response = await authModule.authenticatedFetch('http://test.classeo.local:18000/api/data'); expect(response.ok).toBe(true); }); it('should redirect to login if refresh fails during 401 retry', async () => { // Login first const token = createTestJwt({ sub: 'user@test.com', user_id: 'expired-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Request returns 401 and refresh also fails (fetch as ReturnType) .mockResolvedValueOnce({ ok: false, status: 401 }) .mockResolvedValueOnce({ ok: false, status: 401 }); await expect( authModule.authenticatedFetch('http://test.classeo.local:18000/api/data') ).rejects.toThrow('Session expired'); expect(mockGoto).toHaveBeenCalledWith('/login'); }); }); // ========================================================================== // JWT edge cases (tested through login) // ========================================================================== describe('JWT parsing edge cases', () => { it('should handle token with non-string user_id', async () => { // user_id is a number instead of string const token = createTestJwt({ sub: 'user@test.com', user_id: 12345 }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Should return null because user_id is not a string expect(authModule.getCurrentUserId()).toBeNull(); // But token should still be set expect(authModule.isAuthenticated()).toBe(true); }); it('should handle token with empty payload', async () => { const token = createTestJwt({}); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); expect(authModule.getCurrentUserId()).toBeNull(); expect(authModule.isAuthenticated()).toBe(true); }); it('should handle malformed token (not 3 parts)', async () => { const malformedToken = 'not.a.valid.jwt.token'; (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: malformedToken }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Token is stored but user ID extraction fails expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getCurrentUserId()).toBeNull(); }); it('should handle token with invalid base64 payload', async () => { const invalidToken = 'header.!!!invalid-base64!!!.signature'; (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: invalidToken }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getCurrentUserId()).toBeNull(); }); it('should handle token with valid base64 but invalid JSON', async () => { const header = btoa(JSON.stringify({ alg: 'HS256' })); const body = btoa('not-json-content'); const invalidJsonToken = `${header}.${body}.signature`; (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: invalidJsonToken }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); expect(authModule.isAuthenticated()).toBe(true); expect(authModule.getCurrentUserId()).toBeNull(); }); }); // ========================================================================== // onLogout callback // ========================================================================== describe('onLogout', () => { it('should allow registering a logout callback', () => { const callback = vi.fn(); // Should not throw authModule.onLogout(callback); }); it('should invoke callback before clearing state during logout', async () => { let wasAuthenticatedDuringCallback = false; const callback = vi.fn(() => { // Check auth state at the moment the callback fires wasAuthenticatedDuringCallback = authModule.isAuthenticated(); }); authModule.onLogout(callback); // Login const token = createTestJwt({ sub: 'user@test.com', user_id: 'cb-user' }); (fetch as ReturnType).mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token }) }); await authModule.login({ email: 'user@test.com', password: 'pass' }); // Logout (fetch as ReturnType).mockResolvedValueOnce({ ok: true }); await authModule.logout(); expect(callback).toHaveBeenCalledOnce(); // The callback fires before accessToken is set to null expect(wasAuthenticatedDuringCallback).toBe(true); }); }); });