7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|
|
});
|