Fresh users copy-pasting healthcheck blocks from k8s/Docker docs need
the standard short aliases. Without /healthz and /readyz they get
connection refused. This commit:
1. Adds /healthz + /readyz as root-level aliases for /health/live +
/health/ready in src/app.js. Handler bodies DRYed into named
functions (livenessHandler, readinessHandler) so a probe semantics
change updates all five paths at once.
2. Removes the dead /api/v1/health*, /api/v1/health/live, /api/v1/health/ready
registrations from PUBLIC_ROUTES and CSRF exclusion list — those
routes were never actually mounted on the apiRouter (only root
paths existed). Anyone probing /api/v1/health now gets a clean 404
instead of being routed through to a duplicate root handler.
3. Adds bypass for the 5 probe paths in three places where it matters:
- PUBLIC_ROUTES (no auth)
- csrf-protection.js excludedPaths (no CSRF check)
- middleware.js request-logging exclusion (k8s polling every 10s
doesn't flood the audit log)
- middleware.js Tailscale auth bypass (probes don't carry Tailscale
identity headers)
4. Adds __tests__/health-probe-aliases.test.js (19 tests):
- Alias equivalence (/healthz == /health/live, /readyz == /health/ready)
- Back-compat (/health == /health/live)
- Path consolidation (all 3 /api/v1/health* return 404)
- Source-of-truth PUBLIC_ROUTES allowlist sync check
- Source-of-truth src/app.js mount list sync check (catches drift
between handler mount and middleware allowlist)
5. Documents probes in README (copy-paste docker-compose.yml +
Kubernetes blocks) and user-guide (Health Probes section + System
API table updated).
Post-fix: 941/941 tests pass (+19 new). Zero new ESLint warnings
introduced. The pre-existing warnings/errors in src/app.js line 906
('os' is not defined) and the empty blocks in logging.test.js are
not regressions from this commit.
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 = '/healthz';
|
|
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');
|
|
});
|
|
});
|
|
});
|