feat(api): unify logger — single source of truth for logs, errors, audit
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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)
This commit is contained in:
Krystie
2026-06-19 18:41:26 -07:00
parent 44af47d344
commit f71e5c52d4
3 changed files with 667 additions and 95 deletions
+256
View File
@@ -0,0 +1,256 @@
/**
* 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');
});
});
});