/** * Tests for security/safe-logger.js * * Tests the SafeLogger's environment detection, data redaction * in production mode, and passthrough in development mode. */ // Load SafeLogger — it self-registers on window import '@js/security/safe-logger.js'; const SL = window.SafeLogger; describe('SafeLogger', () => { afterEach(() => { SL.resetProductionMode(); }); describe('environment detection', () => { it('detects localhost as development', () => { // happy-dom defaults to localhost SL.resetProductionMode(); expect(SL.isProduction()).toBe(false); }); it('can be forced to production mode', () => { SL.setProductionMode(true); expect(SL.isProduction()).toBe(true); }); it('can be forced to development mode', () => { SL.setProductionMode(false); expect(SL.isProduction()).toBe(false); }); it('returns to auto-detection after reset', () => { SL.setProductionMode(true); expect(SL.isProduction()).toBe(true); SL.resetProductionMode(); // localhost → development expect(SL.isProduction()).toBe(false); }); it('getProductionMode returns null when auto-detecting', () => { expect(SL.getProductionMode()).toBeNull(); }); it('getProductionMode returns forced value', () => { SL.setProductionMode(true); expect(SL.getProductionMode()).toBe(true); }); }); describe('development mode logging', () => { beforeEach(() => { SL.setProductionMode(false); }); it('passes static message through', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('test message'); expect(spy).toHaveBeenCalledWith('test message'); spy.mockRestore(); }); it('passes dynamic data through in dev mode', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('query:', 'climate change'); expect(spy).toHaveBeenCalledWith('query:', 'climate change'); spy.mockRestore(); }); it('extracts Error properties in dev mode', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const err = new Error('test error'); SL.error('Failed:', err); const secondArg = spy.mock.calls[0][1]; expect(secondArg).toHaveProperty('name', 'Error'); expect(secondArg).toHaveProperty('message', 'test error'); expect(secondArg).toHaveProperty('stack'); spy.mockRestore(); }); it('extracts .message from error-like objects in dev mode', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); SL.warn('Issue:', { message: 'not found', code: 404 }); expect(spy).toHaveBeenCalledWith('Issue:', 'not found'); spy.mockRestore(); }); it('stringifies plain objects in dev mode', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Data:', { error: 'not found' }); expect(spy).toHaveBeenCalledWith('Data:', '{"error":"not found"}'); spy.mockRestore(); }); it('debug messages are emitted in dev mode', () => { const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); SL.debug('debug info'); expect(spy).toHaveBeenCalledWith('debug info'); spy.mockRestore(); }); }); describe('production mode redaction', () => { beforeEach(() => { SL.setProductionMode(true); }); it('passes first string argument (static message) through', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('User searched:', 'sensitive query'); expect(spy.mock.calls[0][0]).toBe('User searched:'); spy.mockRestore(); }); it('redacts string arguments after the first', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('User searched:', 'sensitive query'); expect(spy.mock.calls[0][1]).toBe('[redacted]'); spy.mockRestore(); }); it('redacts numbers', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Research ID:', 42); expect(spy.mock.calls[0][1]).toBe('[redacted]'); spy.mockRestore(); }); it('preserves booleans in production', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Active:', true); expect(spy.mock.calls[0][1]).toBe(true); spy.mockRestore(); }); it('preserves null/undefined in production', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Value:', null); expect(spy.mock.calls[0][1]).toBeNull(); spy.mockRestore(); }); it('redacts Error message but keeps name in production', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); SL.error('Failed:', new TypeError('secret details')); const secondArg = spy.mock.calls[0][1]; expect(secondArg.name).toBe('TypeError'); expect(secondArg.message).toBe('[redacted]'); spy.mockRestore(); }); it('shows array structure but not contents in production', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Items:', [1, 2, 3]); expect(spy.mock.calls[0][1]).toBe('[Array(3)]'); spy.mockRestore(); }); it('shows [Object] for objects in production', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Config:', { key: 'value' }); expect(spy.mock.calls[0][1]).toBe('[Object]'); spy.mockRestore(); }); it('suppresses debug messages entirely in production', () => { const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); SL.debug('secret debug info'); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); }); describe('log methods call correct console methods', () => { beforeEach(() => { SL.setProductionMode(false); }); it('log → console.log', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('test'); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); it('info → console.info', () => { const spy = vi.spyOn(console, 'info').mockImplementation(() => {}); SL.info('test'); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); it('warn → console.warn', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); SL.warn('test'); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); it('error → console.error', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); SL.error('test'); expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); }); describe('production redaction invariants (fuzz)', () => { // Property-based test: in production mode, no string/number argument // after the first should ever appear verbatim in console output. // This is the most critical security property of SafeLogger — if // it ever regresses, user search queries / API responses leak. beforeEach(() => { SL.setProductionMode(true); }); const generateRandomString = () => { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%'; const len = Math.floor(Math.random() * 30) + 5; let s = ''; for (let i = 0; i < len; i++) { s += chars[Math.floor(Math.random() * chars.length)]; } return s; }; it('never leaks string arguments after the first (100 random inputs)', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); for (let i = 0; i < 100; i++) { const secret = generateRandomString(); SL.log('Request:', secret); const loggedArgs = spy.mock.calls[spy.mock.calls.length - 1]; // The static message should pass through expect(loggedArgs[0]).toBe('Request:'); // The secret should NOT appear in any logged argument for (let j = 1; j < loggedArgs.length; j++) { expect(loggedArgs[j]).not.toBe(secret); expect(String(loggedArgs[j])).not.toContain(secret); } } spy.mockRestore(); }); it('never leaks number arguments after the first', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); for (let i = 0; i < 50; i++) { const secretId = Math.floor(Math.random() * 1e9); SL.log('User ID:', secretId); const loggedArgs = spy.mock.calls[spy.mock.calls.length - 1]; for (let j = 1; j < loggedArgs.length; j++) { expect(loggedArgs[j]).not.toBe(secretId); } } spy.mockRestore(); }); it('never leaks object property values', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); for (let i = 0; i < 30; i++) { const secret = generateRandomString(); SL.log('Data:', { token: secret, user: secret }); const loggedArgs = spy.mock.calls[spy.mock.calls.length - 1]; // The serialized representation should not contain the secret const serialized = JSON.stringify(loggedArgs); expect(serialized).not.toContain(secret); } spy.mockRestore(); }); it('never leaks array contents', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); for (let i = 0; i < 30; i++) { const secrets = [generateRandomString(), generateRandomString()]; SL.log('Items:', secrets); const loggedArgs = spy.mock.calls[spy.mock.calls.length - 1]; const serialized = JSON.stringify(loggedArgs); for (const secret of secrets) { expect(serialized).not.toContain(secret); } } spy.mockRestore(); }); it('never leaks Error message contents', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); for (let i = 0; i < 30; i++) { const secretMsg = generateRandomString(); SL.error('Failed:', new Error(secretMsg)); const loggedArgs = spy.mock.calls[spy.mock.calls.length - 1]; const serialized = JSON.stringify(loggedArgs); expect(serialized).not.toContain(secretMsg); } spy.mockRestore(); }); }); describe('edge cases', () => { it('handles zero arguments', () => { SL.setProductionMode(false); const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log(); expect(spy).toHaveBeenCalledWith(); spy.mockRestore(); }); it('redacts error-like objects in production', () => { SL.setProductionMode(true); const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Issue:', { message: 'secret', name: 'ApiError' }); const arg = spy.mock.calls[0][1]; expect(arg.name).toBe('ApiError'); expect(arg.message).toBe('[redacted]'); spy.mockRestore(); }); it('does not misclassify data objects with .message but no .name', () => { // Regression: a summary object like {hasLogEntry, logType, message} // was being treated as an Error in production, producing the // misleading log line "Object { name: 'Error', message: '[redacted]' }". SL.setProductionMode(true); const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); SL.log('Calling handlers with data:', { hasLogEntry: true, logType: 'milestone', message: 'Phase complete...', }); const arg = spy.mock.calls[0][1]; // Should fall through to the generic-object redaction, not the // error-like branch. expect(arg).toBe('[Object]'); spy.mockRestore(); }); }); });