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:
2026-02-12 08:38:19 +01:00
parent e930c505df
commit 44ebe5e511
91 changed files with 10071 additions and 39 deletions

View 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);
});
});
});