logger.test.ts126 lines · main
1import assert from 'node:assert/strict';
2import { test } from 'node:test';
3
4import { createLogger } from './logger.js';
5
6interface CapturedLine {
7 channel: 'stdout' | 'stderr';
8 line: Record<string, unknown>;
9}
10
11function captureWrites(fn: () => void): CapturedLine[] {
12 const captured: CapturedLine[] = [];
13 const origStdout = process.stdout.write.bind(process.stdout);
14 const origStderr = process.stderr.write.bind(process.stderr);
15 process.stdout.write = ((chunk: string | Uint8Array) => {
16 if (typeof chunk === 'string') {
17 captured.push({ channel: 'stdout', line: JSON.parse(chunk) });
18 }
19 return true;
20 }) as typeof process.stdout.write;
21 process.stderr.write = ((chunk: string | Uint8Array) => {
22 if (typeof chunk === 'string') {
23 captured.push({ channel: 'stderr', line: JSON.parse(chunk) });
24 }
25 return true;
26 }) as typeof process.stderr.write;
27 try {
28 fn();
29 } finally {
30 process.stdout.write = origStdout;
31 process.stderr.write = origStderr;
32 }
33 return captured;
34}
35
36test('emits structured JSON with service + env + level + msg + ts', () => {
37 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
38 const lines = captureWrites(() => log.info('boot complete'));
39 assert.equal(lines.length, 1);
40 assert.equal(lines[0]!.channel, 'stdout');
41 const line = lines[0]!.line;
42 assert.equal(line.level, 'info');
43 assert.equal(line.msg, 'boot complete');
44 assert.equal(line.service, 'api');
45 assert.equal(line.env, 'test');
46 assert.equal(typeof line.ts, 'string');
47});
48
49test('warn + error go to stderr; info + debug go to stdout', () => {
50 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
51 const lines = captureWrites(() => {
52 log.debug('d');
53 log.info('i');
54 log.warn('w');
55 log.error('e');
56 });
57 assert.deepEqual(
58 lines.map((l) => [l.channel, (l.line as { level: string }).level]),
59 [
60 ['stdout', 'debug'],
61 ['stdout', 'info'],
62 ['stderr', 'warn'],
63 ['stderr', 'error'],
64 ],
65 );
66});
67
68test('respects min level — debug suppressed when level is info', () => {
69 const log = createLogger({ service: 'api', env: 'test', level: 'info' });
70 const lines = captureWrites(() => {
71 log.debug('d');
72 log.info('i');
73 });
74 assert.equal(lines.length, 1);
75 assert.equal((lines[0]!.line as { msg: string }).msg, 'i');
76});
77
78test('redacts emails in msg and field strings', () => {
79 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
80 const lines = captureWrites(() =>
81 log.info('login from user@example.com', { last: 'j@example.com' }),
82 );
83 const line = lines[0]!.line as { msg: string; last: string };
84 assert.equal(line.msg, 'login from [REDACTED:email]');
85 assert.equal(line.last, '[REDACTED:email]');
86});
87
88test('redacts IPv4 addresses in nested fields', () => {
89 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
90 const lines = captureWrites(() =>
91 log.warn('peer event', {
92 peers: [{ note: 'connected from 10.0.0.1' }, { note: 'ok' }],
93 meta: { remote: '192.168.1.5' },
94 }),
95 );
96 const line = lines[0]!.line as {
97 peers: { note: string }[];
98 meta: { remote: string };
99 };
100 assert.equal(line.peers[0]!.note, 'connected from [REDACTED:ip]');
101 assert.equal(line.peers[1]!.note, 'ok');
102 assert.equal(line.meta.remote, '[REDACTED:ip]');
103});
104
105test('passes non-string scalars through unchanged', () => {
106 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
107 const lines = captureWrites(() =>
108 log.info('counts', { n: 42, ok: true, missing: null }),
109 );
110 const line = lines[0]!.line as { n: number; ok: boolean; missing: unknown };
111 assert.equal(line.n, 42);
112 assert.equal(line.ok, true);
113 assert.equal(line.missing, null);
114});
115
116test('caps redaction depth at 16 to refuse pathological input', () => {
117 const log = createLogger({ service: 'api', env: 'test', level: 'debug' });
118 // Build a deeply nested object beyond the cap.
119 let deep: Record<string, unknown> = { leaf: 'user@example.com' };
120 for (let i = 0; i < 20; i++) deep = { next: deep };
121 const lines = captureWrites(() => log.info('deep', deep));
122 // Should not throw; deep inner values become the sentinel rather than
123 // recursing further. We don't assert the exact tree shape — just that
124 // the logger emitted exactly one line.
125 assert.equal(lines.length, 1);
126});