Add scraperLogger.js with credential redaction
All checks were successful
CI/CD Pipeline - Apartment API / Scan Dependencies (pull_request) Successful in 14s
CI/CD Pipeline - Apartment API / Run Tests (pull_request) Successful in 9m45s
CI/CD Pipeline - Apartment API / Build & Push Image (pull_request) Has been skipped
CI/CD Pipeline - Apartment API / Deploy to Production (pull_request) Has been skipped
All checks were successful
CI/CD Pipeline - Apartment API / Scan Dependencies (pull_request) Successful in 14s
CI/CD Pipeline - Apartment API / Run Tests (pull_request) Successful in 9m45s
CI/CD Pipeline - Apartment API / Build & Push Image (pull_request) Has been skipped
CI/CD Pipeline - Apartment API / Deploy to Production (pull_request) Has been skipped
Implement structured JSON logging module for the scraper service: - createLogger(jobId) factory function for job-scoped logging - info(), warn(), error() methods with consistent JSON output - Automatic redaction of MongoDB connection string credentials - Redaction of sensitive keys (password, secret, token, apikey, authorization) - Message truncation to prevent log bloat (1000 char limit) - Circular reference handling - Buffer object representation as [Buffer: N bytes] Includes comprehensive test suite with 35 test cases covering: - Log entry format validation (timestamp, level, message, jobId, context) - MongoDB URI redaction for both mongodb:// and mongodb+srv:// - Sensitive key redaction - Edge cases (null, undefined, nested objects, arrays) - Buffer and circular reference handling
This commit is contained in:
471
__tests__/scraper/scraperLogger.test.js
Normal file
471
__tests__/scraper/scraperLogger.test.js
Normal file
@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Tests for scraperLogger.js
|
||||
* Following TDD - these tests are written BEFORE implementation
|
||||
*/
|
||||
|
||||
const { createLogger, LEVELS } = require('../../services/scraperLogger');
|
||||
|
||||
describe('scraperLogger', () => {
|
||||
let consoleSpy;
|
||||
let logOutput;
|
||||
|
||||
beforeEach(() => {
|
||||
logOutput = [];
|
||||
consoleSpy = jest.spyOn(console, 'log').mockImplementation((output) => {
|
||||
logOutput.push(output);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('createLogger', () => {
|
||||
test('should return an object with info, warn, error methods', () => {
|
||||
const logger = createLogger('test-job-id');
|
||||
|
||||
expect(typeof logger).toBe('object');
|
||||
expect(typeof logger.info).toBe('function');
|
||||
expect(typeof logger.warn).toBe('function');
|
||||
expect(typeof logger.error).toBe('function');
|
||||
});
|
||||
|
||||
test('should include jobId in all log entries', () => {
|
||||
const jobId = 'unique-job-123';
|
||||
const logger = createLogger(jobId);
|
||||
|
||||
logger.info('Test message');
|
||||
|
||||
expect(logOutput.length).toBe(1);
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.jobId).toBe(jobId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('log entry format', () => {
|
||||
test('should include timestamp in ISO format', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const beforeTime = new Date().toISOString();
|
||||
|
||||
logger.info('Test message');
|
||||
|
||||
const afterTime = new Date().toISOString();
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
|
||||
expect(parsed.timestamp).toBeDefined();
|
||||
// Verify timestamp is in ISO format
|
||||
expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/);
|
||||
// Verify timestamp is within test window
|
||||
expect(parsed.timestamp >= beforeTime).toBe(true);
|
||||
expect(parsed.timestamp <= afterTime).toBe(true);
|
||||
});
|
||||
|
||||
test('should include log level', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Info message');
|
||||
logger.warn('Warn message');
|
||||
logger.error('Error message');
|
||||
|
||||
expect(logOutput.length).toBe(3);
|
||||
|
||||
const infoEntry = JSON.parse(logOutput[0]);
|
||||
const warnEntry = JSON.parse(logOutput[1]);
|
||||
const errorEntry = JSON.parse(logOutput[2]);
|
||||
|
||||
expect(infoEntry.level).toBe('info');
|
||||
expect(warnEntry.level).toBe('warn');
|
||||
expect(errorEntry.level).toBe('error');
|
||||
});
|
||||
|
||||
test('should include message', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Test message content');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.message).toBe('Test message content');
|
||||
});
|
||||
|
||||
test('should include context when provided', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = { key: 'value', count: 42 };
|
||||
|
||||
logger.info('Test message', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context).toEqual(context);
|
||||
});
|
||||
|
||||
test('should output valid JSON', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Test message', { data: 'test' });
|
||||
|
||||
expect(() => JSON.parse(logOutput[0])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('log levels', () => {
|
||||
test('info() should output with level "info"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Info message');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.level).toBe('info');
|
||||
});
|
||||
|
||||
test('warn() should output with level "warn"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.warn('Warning message');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.level).toBe('warn');
|
||||
});
|
||||
|
||||
test('error() should output with level "error"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.error('Error message');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.level).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LEVELS constant', () => {
|
||||
test('should export LEVELS constant with info, warn, error', () => {
|
||||
expect(LEVELS).toBeDefined();
|
||||
expect(LEVELS.info).toBe('info');
|
||||
expect(LEVELS.warn).toBe('warn');
|
||||
expect(LEVELS.error).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message truncation', () => {
|
||||
test('should truncate messages longer than 1000 characters', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const longMessage = 'x'.repeat(1500);
|
||||
|
||||
logger.info(longMessage);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.message.length).toBeLessThanOrEqual(1020); // 1000 + "... [truncated]"
|
||||
expect(parsed.message).toContain('... [truncated]');
|
||||
});
|
||||
|
||||
test('should not truncate messages of 1000 characters or less', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const shortMessage = 'x'.repeat(1000);
|
||||
|
||||
logger.info(shortMessage);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.message).toBe(shortMessage);
|
||||
expect(parsed.message).not.toContain('... [truncated]');
|
||||
});
|
||||
|
||||
test('should handle empty message', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.message).toBe('');
|
||||
});
|
||||
|
||||
test('should handle null or undefined message', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info(null);
|
||||
logger.info(undefined);
|
||||
|
||||
expect(logOutput.length).toBe(2);
|
||||
// Should not throw and should handle gracefully
|
||||
});
|
||||
});
|
||||
|
||||
describe('MongoDB connection string redaction', () => {
|
||||
test('should redact username and password from mongodb:// URIs', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
uri: 'mongodb://admin:secretpassword123@localhost:27017/mydb'
|
||||
};
|
||||
|
||||
logger.info('Database connection', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.uri).toBe('mongodb://[user]:[REDACTED]@localhost:27017/mydb');
|
||||
expect(parsed.context.uri).not.toContain('admin');
|
||||
expect(parsed.context.uri).not.toContain('secretpassword123');
|
||||
});
|
||||
|
||||
test('should redact username and password from mongodb+srv:// URIs', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
uri: 'mongodb+srv://myuser:mypass@cluster0.example.mongodb.net/testdb'
|
||||
};
|
||||
|
||||
logger.info('Database connection', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.uri).toBe('mongodb+srv://[user]:[REDACTED]@cluster0.example.mongodb.net/testdb');
|
||||
expect(parsed.context.uri).not.toContain('myuser');
|
||||
expect(parsed.context.uri).not.toContain('mypass');
|
||||
});
|
||||
|
||||
test('should handle connection string without credentials', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
uri: 'mongodb://localhost:27017/mydb'
|
||||
};
|
||||
|
||||
logger.info('Database connection', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.uri).toBe('mongodb://localhost:27017/mydb');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sensitive key redaction', () => {
|
||||
test('should redact keys containing "password"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
password: 'secret123',
|
||||
userPassword: 'anotherSecret',
|
||||
password_hash: 'hashed'
|
||||
};
|
||||
|
||||
logger.info('User data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.password).toBe('[REDACTED]');
|
||||
expect(parsed.context.userPassword).toBe('[REDACTED]');
|
||||
expect(parsed.context.password_hash).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
test('should redact keys containing "secret"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
secret: 'mysecret',
|
||||
clientSecret: 'secret123',
|
||||
SECRET_KEY: 'key'
|
||||
};
|
||||
|
||||
logger.info('Config data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.secret).toBe('[REDACTED]');
|
||||
expect(parsed.context.clientSecret).toBe('[REDACTED]');
|
||||
// Case-insensitive check
|
||||
});
|
||||
|
||||
test('should redact keys containing "token"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
token: 'abc123',
|
||||
accessToken: 'token456',
|
||||
refresh_token: 'refresh789'
|
||||
};
|
||||
|
||||
logger.info('Auth data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.token).toBe('[REDACTED]');
|
||||
expect(parsed.context.accessToken).toBe('[REDACTED]');
|
||||
expect(parsed.context.refresh_token).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
test('should redact keys containing "apikey"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
apikey: 'key123',
|
||||
apiKey: 'key456',
|
||||
api_key: 'key789' // Note: underscores may not match 'apikey'
|
||||
};
|
||||
|
||||
logger.info('API data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.apikey).toBe('[REDACTED]');
|
||||
expect(parsed.context.apiKey).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
test('should redact keys containing "authorization"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
authorization: 'Bearer token123',
|
||||
Authorization: 'Basic base64string'
|
||||
};
|
||||
|
||||
logger.info('Header data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.authorization).toBe('[REDACTED]');
|
||||
});
|
||||
|
||||
test('should not redact non-sensitive keys', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
username: 'john',
|
||||
email: 'john@example.com',
|
||||
count: 42
|
||||
};
|
||||
|
||||
logger.info('User info', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.username).toBe('john');
|
||||
expect(parsed.context.email).toBe('john@example.com');
|
||||
expect(parsed.context.count).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('circular reference handling', () => {
|
||||
test('should handle circular references in context', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = { name: 'test' };
|
||||
context.self = context; // Create circular reference
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
logger.info('Circular test', context);
|
||||
}).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.name).toBe('test');
|
||||
expect(parsed.context.self).toBe('[Circular]');
|
||||
});
|
||||
|
||||
test('should handle deeply nested circular references', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
context.level1.level2.level3.back = context.level1;
|
||||
|
||||
expect(() => {
|
||||
logger.info('Deep circular test', context);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should be valid JSON output
|
||||
expect(() => JSON.parse(logOutput[0])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer handling', () => {
|
||||
test('should represent Buffer objects as "[Buffer: N bytes]"', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
data: Buffer.from('Hello World')
|
||||
};
|
||||
|
||||
logger.info('Buffer data', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.data).toBe('[Buffer: 11 bytes]');
|
||||
});
|
||||
|
||||
test('should handle empty Buffer', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
data: Buffer.alloc(0)
|
||||
};
|
||||
|
||||
logger.info('Empty buffer', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.data).toBe('[Buffer: 0 bytes]');
|
||||
});
|
||||
|
||||
test('should handle large Buffer', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
data: Buffer.alloc(1024 * 1024) // 1MB buffer
|
||||
};
|
||||
|
||||
logger.info('Large buffer', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.data).toBe('[Buffer: 1048576 bytes]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle empty context object', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Test message', {});
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle context not provided', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Test message');
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle null context', () => {
|
||||
const logger = createLogger('test-job');
|
||||
|
||||
logger.info('Test message', null);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context).toEqual({});
|
||||
});
|
||||
|
||||
test('should handle undefined values in context', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
defined: 'value',
|
||||
undefinedValue: undefined
|
||||
};
|
||||
|
||||
logger.info('Test message', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.defined).toBe('value');
|
||||
// undefined is typically converted to null in JSON
|
||||
expect(parsed.context.undefinedValue).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle nested objects in context', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
outer: {
|
||||
inner: {
|
||||
value: 'deep'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Nested test', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.outer.inner.value).toBe('deep');
|
||||
});
|
||||
|
||||
test('should handle arrays in context', () => {
|
||||
const logger = createLogger('test-job');
|
||||
const context = {
|
||||
items: [1, 2, 3, 'four']
|
||||
};
|
||||
|
||||
logger.info('Array test', context);
|
||||
|
||||
const parsed = JSON.parse(logOutput[0]);
|
||||
expect(parsed.context.items).toEqual([1, 2, 3, 'four']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user