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