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

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:
2026-01-28 00:29:14 -07:00
parent 59fb6da5fb
commit d14bd724ed
2 changed files with 635 additions and 0 deletions

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