feat: Liaison parents-enfants avec gestion des tuteurs
Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes, emploi du temps, devoirs). Cela nécessite un lien formalisé entre le compte parent et le compte élève, géré par les administrateurs. Le lien est établi soit manuellement via l'interface d'administration, soit automatiquement lors de l'activation du compte parent lorsque l'invitation inclut un élève cible. Ce lien conditionne l'accès aux données scolaires de l'enfant (autorisations vérifiées par un voter dédié).
This commit is contained in:
698
frontend/tests/unit/lib/auth/auth.test.ts
Normal file
698
frontend/tests/unit/lib/auth/auth.test.ts
Normal file
@@ -0,0 +1,698 @@
|
||||
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, unknown>): 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, unknown>): 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());
|
||||
|
||||
// Re-mock goto for each test
|
||||
const navModule = await import('$app/navigation');
|
||||
(navModule.goto as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.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<typeof vi.fn>)
|
||||
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token })
|
||||
});
|
||||
await authModule.login({ email: 'user@test.com', password: 'pass' });
|
||||
|
||||
// Logout with API failure
|
||||
(fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token })
|
||||
});
|
||||
await authModule.login({ email: 'user@test.com', password: 'pass' });
|
||||
|
||||
// Logout
|
||||
(fetch as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
// 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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
// 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<typeof vi.fn>).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<typeof vi.fn>)
|
||||
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token })
|
||||
});
|
||||
await authModule.login({ email: 'user@test.com', password: 'pass' });
|
||||
|
||||
// Logout
|
||||
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
|
||||
await authModule.logout();
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
// The callback fires before accessToken is set to null
|
||||
expect(wasAuthenticatedDuringCallback).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user