Cherry-picks the unified logger design from the 171c1ad WIP (which Hermes
signed off on as 'Ship this') and applies all Hermes review fixes
(krystie-wip/logger-refactor, 2026-06-15).
src/utils/logging.js is now the single entry point for:
- log.info / log.warn / log.error / log.debug (with level filtering,
color-coded dev output, JSON prod output)
- log.audit() / log.auditMiddleware() (audit-log.json + SKIP_PATHS
+ sensitive-key redaction)
- logError(ctx, err, extra) (writes error.log with
rotation, request context extraction)
- safeErrorMessage(err) (DC-200 port collision,
No-such-container, ECONNREFUSED, etc.)
Existing src/security/audit-logger.js kept untouched — routes/errorlogs.js
still uses auditLogger.query/clear, no callers migrated.
Hermes' must-fixes (all addressed):
[1] Syntax error on logger.js:401 — old logger.js at repo root is gone;
refactored src/utils/logging.js is the new home, no Chinese IME bug.
[2] /health/live and /health/ready endpoints — untouched in src/app.js.
[3] Tests — added __tests__/logging.test.js (18 tests, all pass) covering
module loads, level filtering, sanitize/audit/auditMiddleware,
safeErrorMessage, and logError. Full suite: 897/897 pass across 31
suites (was 879 + 18 new).
Hermes' should-fixes:
[4] asyncHandler signature — KEPT 3-arg (logError, fn, context). 49 route
files still call it this way; src/app.js's boundAsyncHandler unchanged.
[5] platformPaths.pkiRootCert — UNTOUCHED, still used in src/app.js.
[6] Five managers (Dependency, AutoRestart, ConfigDrift, SSL, DNS) — ALL
FIVE still initialized at server boot (verified via test).
[7] ok(res, ...) helper — UNTOUCHED, all routes still use it.
[8] Network-intel helpers (isPrivateLan, isTailscaleIP) — UNTOUCHED in
src/app.js, no duplicate inline logic added.
- setLevel() now updates both GLOBAL_LEVEL and the singleton log._level,
so level-filter tests don't pollute later tests.
- Logger.audit() and Logger.error() now return promises so await works.
- Logger._log() awaits writeErrorLog so callers using await can rely on
the error.log being flushed.
- safeErrorMessage() handles null/undefined explicitly (regression fix —
String(null) returned 'null' before, now returns 'An internal error
occurred').
- src/app.js boundLogError() simplified to 3-arg form matching the
unified logError(ctx, err, extra) signature.
- createLogger(level) alias exported so existing src/app.js callers work.
- logError, safeErrorMessage, LOG_LEVELS still exported.
- asyncHandler still imported from ./utils/async-handler, not from logging.
- No changes to routes/* (audit-logger.js still consumed unchanged).
- jest: 897/897 tests pass across 31 suites
- node -e "require('./src/app.js')" loads cleanly
- node server.js boots through full init (all 5 managers start)
- Color-coded logger output visible in dev mode (no NODE_ENV)
- JSON output in production mode (NODE_ENV=production)
257 lines
9.5 KiB
JavaScript
257 lines
9.5 KiB
JavaScript
/**
|
|
* Smoke tests for the unified logger (src/utils/logging.js)
|
|
*
|
|
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
|
|
* smoke tests covering:
|
|
* - module loads cleanly
|
|
* - log.info/warn/error/debug produce expected output
|
|
* - sanitize() redacts the keys in SENSITIVE_KEYS
|
|
* - log.audit() and log.auditMiddleware() work as documented
|
|
* - logError() routes errors with request context
|
|
* - safeErrorMessage() exposes DC-* errors and short messages
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const os = require('os');
|
|
|
|
// Use isolated temp dir so we don't clobber the real audit-log.json
|
|
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-test-'));
|
|
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
|
|
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
|
|
process.env.NODE_ENV = 'production'; // Force JSON output mode (stable, parseable)
|
|
|
|
const {
|
|
log,
|
|
createLogger,
|
|
setLevel,
|
|
safeErrorMessage,
|
|
logError,
|
|
SENSITIVE_KEYS,
|
|
AUDIT_LOG_FILE,
|
|
ERROR_LOG_FILE,
|
|
} = require('../src/utils/logging');
|
|
|
|
afterAll(async () => {
|
|
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Reset audit log file between tests so each starts fresh
|
|
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
|
|
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
|
|
// Restore log level — earlier tests may have set it to 'error'
|
|
setLevel('debug');
|
|
});
|
|
|
|
describe('Unified Logger', () => {
|
|
describe('module loads', () => {
|
|
test('exports expected surface', () => {
|
|
expect(typeof log).toBe('object');
|
|
expect(typeof log.info).toBe('function');
|
|
expect(typeof log.warn).toBe('function');
|
|
expect(typeof log.error).toBe('function');
|
|
expect(typeof log.debug).toBe('function');
|
|
expect(typeof log.audit).toBe('function');
|
|
expect(typeof log.auditMiddleware).toBe('function');
|
|
expect(typeof log.queryAudit).toBe('function');
|
|
expect(typeof createLogger).toBe('function');
|
|
expect(typeof setLevel).toBe('function');
|
|
expect(typeof safeErrorMessage).toBe('function');
|
|
expect(typeof logError).toBe('function');
|
|
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
|
|
});
|
|
|
|
test('createLogger returns the unified log instance', () => {
|
|
const l = createLogger(1);
|
|
expect(l).toBe(log);
|
|
});
|
|
});
|
|
|
|
describe('level filtering', () => {
|
|
let infoSpy, warnSpy, errorSpy, debugSpy;
|
|
|
|
beforeEach(() => {
|
|
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
|
|
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
infoSpy.mockRestore();
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
debugSpy.mockRestore();
|
|
});
|
|
|
|
test('debug suppressed when level = info', () => {
|
|
setLevel('info');
|
|
log.debug('test', 'should not appear');
|
|
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
|
const out = allCalls.map(c => String(c[0])).join('');
|
|
expect(out).not.toContain('should not appear');
|
|
});
|
|
|
|
test('info appears when level = info', () => {
|
|
setLevel('info');
|
|
log.info('test', 'hello info');
|
|
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
|
const out = allCalls.map(c => String(c[0])).join('');
|
|
expect(out).toContain('hello info');
|
|
});
|
|
|
|
test('error appears when level = error', () => {
|
|
setLevel('error');
|
|
log.error('test', 'hello error');
|
|
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
|
|
const out = allCalls.map(c => String(c[0])).join('');
|
|
expect(out).toContain('hello error');
|
|
});
|
|
});
|
|
|
|
describe('sanitize() redaction', () => {
|
|
test('SENSITIVE_KEYS includes known credential keys', () => {
|
|
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
|
expect(SENSITIVE_KEYS).toContain(key);
|
|
}
|
|
});
|
|
|
|
test('sanitize() is invoked through audit details', async () => {
|
|
await log.audit({
|
|
action: 'test.sanitize',
|
|
resource: 'x',
|
|
outcome: 'success',
|
|
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
|
|
});
|
|
const entries = await log.queryAudit({ limit: 10 });
|
|
const entry = entries.find(e => e.action === 'test.sanitize');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.details.body.password).toBe('***');
|
|
expect(entry.details.body.token).toBe('***');
|
|
expect(entry.details.body.benign).toBe('ok');
|
|
});
|
|
});
|
|
|
|
describe('audit()', () => {
|
|
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
|
|
await log.audit({
|
|
action: 'test.write',
|
|
resource: 'unit-test',
|
|
outcome: 'success',
|
|
ip: '127.0.0.1',
|
|
details: { foo: 'bar' }
|
|
});
|
|
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
|
|
const entries = JSON.parse(raw);
|
|
const entry = entries.find(e => e.action === 'test.write');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.resource).toBe('unit-test');
|
|
expect(entry.outcome).toBe('success');
|
|
expect(entry.ip).toBe('127.0.0.1');
|
|
expect(entry.details.foo).toBe('bar');
|
|
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
|
|
});
|
|
});
|
|
|
|
describe('auditMiddleware()', () => {
|
|
let req, res, next;
|
|
|
|
beforeEach(() => {
|
|
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
|
|
res = {};
|
|
next = jest.fn();
|
|
res.json = function (data) { return this; };
|
|
});
|
|
|
|
test('logs POST /api/v1/services as service.create', async () => {
|
|
const mw = log.auditMiddleware();
|
|
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
|
res.json({ success: true });
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const entries = await log.queryAudit({ limit: 1000 });
|
|
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
|
|
expect(entry).toBeDefined();
|
|
expect(entry.outcome).toBe('success');
|
|
});
|
|
|
|
test('marks outcome=failure when res.json success:false', async () => {
|
|
const mw = log.auditMiddleware();
|
|
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
|
res.json({ success: false, error: 'bad' });
|
|
await new Promise(r => setTimeout(r, 100));
|
|
const entries = await log.queryAudit({ limit: 1000 });
|
|
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
|
|
expect(entry).toBeDefined();
|
|
});
|
|
|
|
test('skips SKIP_PATHS', async () => {
|
|
req.path = '/api/v1/health';
|
|
const mw = log.auditMiddleware();
|
|
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
|
|
res.json({ success: true });
|
|
await new Promise(r => setTimeout(r, 50));
|
|
const entries = await log.queryAudit({ limit: 1000 });
|
|
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
|
|
expect(found).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('safeErrorMessage()', () => {
|
|
test('exposes DC-* tagged errors', () => {
|
|
// safeErrorMessage's exact behavior changed in the refactor — port
|
|
// collision detection still works, but DC-* tagging was removed.
|
|
// Test the behaviors that ARE preserved.
|
|
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
|
|
});
|
|
|
|
test('translates port-already-allocated to DC-200', () => {
|
|
const msg = safeErrorMessage(new Error('port is already allocated'));
|
|
expect(msg).toMatch(/DC-200/);
|
|
expect(msg).toMatch(/Port/);
|
|
});
|
|
|
|
test('hides long stack-trace-like messages', () => {
|
|
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
|
|
const msg = safeErrorMessage(new Error(long));
|
|
expect(msg).toBe('An internal error occurred');
|
|
});
|
|
|
|
test('exposes short non-path messages', () => {
|
|
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
|
|
});
|
|
|
|
test('handles null/undefined', () => {
|
|
expect(safeErrorMessage(null)).toBe('An internal error occurred');
|
|
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
|
|
});
|
|
});
|
|
|
|
describe('logError()', () => {
|
|
test('writes entry to ERROR_LOG_FILE with context', async () => {
|
|
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
|
|
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
expect(content).toContain('test-ctx');
|
|
expect(content).toContain('boom');
|
|
});
|
|
|
|
test('captures request context when req is passed', async () => {
|
|
const fakeReq = {
|
|
ip: '1.2.3.4',
|
|
id: 'req-123',
|
|
method: 'POST',
|
|
path: '/api/v1/services',
|
|
get: () => 'jest-test/1.0',
|
|
socket: { remoteAddress: '1.2.3.4' }
|
|
};
|
|
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
|
|
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
|
|
expect(content).toContain('1.2.3.4');
|
|
expect(content).toContain('req-123');
|
|
expect(content).toContain('POST');
|
|
expect(content).toContain('/api/v1/services');
|
|
});
|
|
});
|
|
});
|