Couverture des processors (RefreshToken, RequestPasswordReset, ResetPassword, SwitchRole, UpdateUserRoles), des query handlers (HasGradesInPeriod, HasStudentsInClass), des messaging handlers (SendActivationConfirmation, SendPasswordResetEmail), et côté frontend des modules auth, roles, monitoring, types et E2E tokens.
390 lines
11 KiB
TypeScript
390 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
/**
|
|
* Unit tests for the Sentry/GlitchTip monitoring module (sentry.ts).
|
|
*
|
|
* Tests initialization, user context management, error capture, and
|
|
* breadcrumb recording. Verifies RGPD compliance: no PII is sent to
|
|
* GlitchTip (Authorization, Cookie headers are scrubbed, emails are
|
|
* redacted from breadcrumbs).
|
|
*/
|
|
|
|
// Mock @sentry/sveltekit before importing the module
|
|
const mockInit = vi.fn();
|
|
const mockSetUser = vi.fn();
|
|
const mockSetTag = vi.fn();
|
|
const mockCaptureException = vi.fn();
|
|
const mockAddBreadcrumb = vi.fn();
|
|
|
|
vi.mock('@sentry/sveltekit', () => ({
|
|
init: mockInit,
|
|
setUser: mockSetUser,
|
|
setTag: mockSetTag,
|
|
captureException: mockCaptureException,
|
|
addBreadcrumb: mockAddBreadcrumb
|
|
}));
|
|
|
|
describe('sentry monitoring', () => {
|
|
let sentryModule: typeof import('$lib/monitoring/sentry');
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
vi.resetModules();
|
|
sentryModule = await import('$lib/monitoring/sentry');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ==========================================================================
|
|
// initSentry
|
|
// ==========================================================================
|
|
describe('initSentry', () => {
|
|
it('should call Sentry.init with correct configuration', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
expect(mockInit).toHaveBeenCalledOnce();
|
|
expect(mockInit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production',
|
|
sampleRate: 1.0,
|
|
tracesSampleRate: 0.0,
|
|
sendDefaultPii: false
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should not initialize Sentry when DSN is empty', () => {
|
|
sentryModule.initSentry({
|
|
dsn: '',
|
|
environment: 'test'
|
|
});
|
|
|
|
expect(mockInit).not.toHaveBeenCalled();
|
|
expect(console.warn).toHaveBeenCalledWith(
|
|
'[Sentry] DSN not configured, error tracking disabled'
|
|
);
|
|
});
|
|
|
|
it('should set user context when userId is provided', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production',
|
|
userId: 'user-abc-123'
|
|
});
|
|
|
|
expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' });
|
|
});
|
|
|
|
it('should not set user context when userId is not provided', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
expect(mockSetUser).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set tenant tag when tenantId is provided', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'staging',
|
|
tenantId: 'ecole-alpha'
|
|
});
|
|
|
|
expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'ecole-alpha');
|
|
});
|
|
|
|
it('should not set tenant tag when tenantId is not provided', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
expect(mockSetTag).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set both user and tenant when both are provided', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production',
|
|
userId: 'user-xyz',
|
|
tenantId: 'lycee-beta'
|
|
});
|
|
|
|
expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-xyz' });
|
|
expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'lycee-beta');
|
|
});
|
|
|
|
it('should disable performance tracing (tracesSampleRate = 0)', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
expect(mockInit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tracesSampleRate: 0.0
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should disable sendDefaultPii for RGPD compliance', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
expect(mockInit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sendDefaultPii: false
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should configure ignoreErrors for common non-errors', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const initConfig = mockInit.mock.calls[0]![0];
|
|
expect(initConfig.ignoreErrors).toEqual(
|
|
expect.arrayContaining([
|
|
'ResizeObserver loop',
|
|
'ResizeObserver loop limit exceeded',
|
|
'NetworkError',
|
|
'Failed to fetch',
|
|
'Load failed',
|
|
'AbortError'
|
|
])
|
|
);
|
|
});
|
|
|
|
// PII scrubbing via beforeSend
|
|
describe('beforeSend PII scrubbing', () => {
|
|
it('should remove Authorization header from event request', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = {
|
|
request: {
|
|
headers: {
|
|
Authorization: 'Bearer secret-token',
|
|
'Content-Type': 'application/json'
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result.request.headers.Authorization).toBeUndefined();
|
|
expect(result.request.headers['Content-Type']).toBe('application/json');
|
|
});
|
|
|
|
it('should remove Cookie header from event request', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = {
|
|
request: {
|
|
headers: {
|
|
Cookie: 'session=abc123',
|
|
Accept: 'text/html'
|
|
}
|
|
}
|
|
};
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result.request.headers.Cookie).toBeUndefined();
|
|
expect(result.request.headers.Accept).toBe('text/html');
|
|
});
|
|
|
|
it('should redact email-like strings from breadcrumb messages', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = {
|
|
breadcrumbs: [
|
|
{ message: 'Login failed for user@example.com', category: 'auth' },
|
|
{ message: 'Page loaded', category: 'navigation' },
|
|
{ message: 'Error with admin@school.fr account', category: 'auth' }
|
|
]
|
|
};
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result.breadcrumbs[0].message).toBe('[EMAIL_REDACTED]');
|
|
expect(result.breadcrumbs[1].message).toBe('Page loaded');
|
|
expect(result.breadcrumbs[2].message).toBe('[EMAIL_REDACTED]');
|
|
});
|
|
|
|
it('should pass through events without request or breadcrumbs', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = { message: 'Simple error' };
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result).toEqual({ message: 'Simple error' });
|
|
});
|
|
|
|
it('should handle event with request but no headers', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = { request: { url: 'https://example.com' } };
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result).toEqual({ request: { url: 'https://example.com' } });
|
|
});
|
|
|
|
it('should handle breadcrumbs without message', () => {
|
|
sentryModule.initSentry({
|
|
dsn: 'https://key@glitchtip.classeo.fr/1',
|
|
environment: 'production'
|
|
});
|
|
|
|
const beforeSend = mockInit.mock.calls[0]![0].beforeSend;
|
|
const event = {
|
|
breadcrumbs: [
|
|
{ category: 'http', data: { url: '/api/test' } }
|
|
]
|
|
};
|
|
|
|
const result = beforeSend(event);
|
|
|
|
expect(result.breadcrumbs[0].category).toBe('http');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// setUserContext
|
|
// ==========================================================================
|
|
describe('setUserContext', () => {
|
|
it('should set user with ID only (no PII)', () => {
|
|
sentryModule.setUserContext('user-abc-123');
|
|
|
|
expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' });
|
|
});
|
|
|
|
it('should set tenant tag when tenantId is provided', () => {
|
|
sentryModule.setUserContext('user-abc-123', 'ecole-alpha');
|
|
|
|
expect(mockSetUser).toHaveBeenCalledWith({ id: 'user-abc-123' });
|
|
expect(mockSetTag).toHaveBeenCalledWith('tenant_id', 'ecole-alpha');
|
|
});
|
|
|
|
it('should not set tenant tag when tenantId is not provided', () => {
|
|
sentryModule.setUserContext('user-abc-123');
|
|
|
|
expect(mockSetTag).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// clearUserContext
|
|
// ==========================================================================
|
|
describe('clearUserContext', () => {
|
|
it('should set user to null', () => {
|
|
sentryModule.clearUserContext();
|
|
|
|
expect(mockSetUser).toHaveBeenCalledWith(null);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// captureError
|
|
// ==========================================================================
|
|
describe('captureError', () => {
|
|
it('should call Sentry.captureException with the error', () => {
|
|
const error = new Error('Something went wrong');
|
|
|
|
sentryModule.captureError(error);
|
|
|
|
expect(mockCaptureException).toHaveBeenCalledWith(error, undefined);
|
|
});
|
|
|
|
it('should pass extra context when provided', () => {
|
|
const error = new Error('API error');
|
|
const context = { endpoint: '/api/users', statusCode: 500 };
|
|
|
|
sentryModule.captureError(error, context);
|
|
|
|
expect(mockCaptureException).toHaveBeenCalledWith(error, {
|
|
extra: { endpoint: '/api/users', statusCode: 500 }
|
|
});
|
|
});
|
|
|
|
it('should handle non-Error objects', () => {
|
|
const errorString = 'string error';
|
|
|
|
sentryModule.captureError(errorString);
|
|
|
|
expect(mockCaptureException).toHaveBeenCalledWith(errorString, undefined);
|
|
});
|
|
});
|
|
|
|
// ==========================================================================
|
|
// addBreadcrumb
|
|
// ==========================================================================
|
|
describe('addBreadcrumb', () => {
|
|
it('should add breadcrumb with category and message', () => {
|
|
sentryModule.addBreadcrumb('navigation', 'User navigated to /dashboard');
|
|
|
|
expect(mockAddBreadcrumb).toHaveBeenCalledWith({
|
|
category: 'navigation',
|
|
message: 'User navigated to /dashboard',
|
|
level: 'info'
|
|
});
|
|
});
|
|
|
|
it('should include data when provided', () => {
|
|
sentryModule.addBreadcrumb('api', 'API call', {
|
|
url: '/api/students',
|
|
method: 'GET'
|
|
});
|
|
|
|
expect(mockAddBreadcrumb).toHaveBeenCalledWith({
|
|
category: 'api',
|
|
message: 'API call',
|
|
level: 'info',
|
|
data: { url: '/api/students', method: 'GET' }
|
|
});
|
|
});
|
|
|
|
it('should not include data key when data is not provided', () => {
|
|
sentryModule.addBreadcrumb('ui', 'Button clicked');
|
|
|
|
const call = mockAddBreadcrumb.mock.calls[0]![0];
|
|
expect(call).not.toHaveProperty('data');
|
|
});
|
|
});
|
|
});
|