/** * Tests for AggregateError / .cause-chain diagnostic surfacing in * src/utils/logging.js writeErrorLog(). * * Bug fixed: writeErrorLog previously emitted `error.message` alone. * AggregateError's `.message` is "" by spec, so a real aggregate (e.g. * `await Promise.any([fetch(...), fetch(...)])` or a multi-A DNS lookup * that times out) ended up in error.log as a single empty line: * * [2026-08-18T06:49:03.345Z] [ERR] update: * context: {"imageName":"ipfs/kubo:latest"} * * Operators couldn't tell why the check failed. This file asserts the * fixed behavior: * * - AggregateError → emits a diagnostic block listing each sub-error's * .code/.message. * - Regular Error → no spurious diagnostic block. * - Plain Error with `.code` (e.g. EPIPE) → head now shows * `Error [EPIPE]: write EPIPE` (regression: `code` used to be dropped). * - Error wrapping another Error via `.cause` → lists the cause. * - AggregateError with mixed sub-errors (some Aggregate, some plain) → * recurses correctly without losing any message. * - Empty error.message is replaced with the error name so a bare * AggregateError still renders something readable. * * log.error signature on this codebase: error(ctx, err, req?, extra?) * where extra is the JSON tail (and req is the Express req if any). */ const path = require('path'); const fs = require('fs').promises; const os = require('os'); // Important: set LOG_DIR / ERROR_LOG_FILE BEFORE requiring logging.js so // the per-test temp file is used as the log target. const tmpDir = fs.realpathSync ? require('fs').realpathSync(os.tmpdir()) : os.tmpdir(); const TMP_LOG = path.join(tmpDir, `dashcaddy-error-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); process.env.LOG_DIR = tmpDir; process.env.ERROR_LOG_FILE = TMP_LOG; process.env.AUDIT_LOG_FILE = path.join(tmpDir, 'unused-audit.json'); const { log } = require('../src/utils/logging'); async function readTail(n = 1) { const raw = await fs.readFile(TMP_LOG, 'utf8').catch(() => ''); const sep = '\u2500'.repeat(72); const entries = raw.split(sep).map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean); return entries.slice(-n); } describe('writeErrorLog() — AggregateError + .cause diagnostics', () => { afterAll(async () => { try { await fs.unlink(TMP_LOG); } catch (_) {} }); beforeEach(async () => { try { await fs.unlink(TMP_LOG); } catch (_) {} }); test('plain Error: head contains name + message + stack', async () => { await log.error('plain', new Error('boom'), null, { requestId: 'r1' }); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] plain: Error: boom/); expect(entry).not.toMatch(/diagnostic:/); // no spurious diagnostic block expect(entry).toMatch(/\n {4}at /); // stack preserved (lowercase `at` from V8) expect(entry).toMatch(/context: \{.*requestId.*"r1".*\}/); }); test('plain Error with .code renders the code in the head (regression fix)', async () => { const e = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); await log.error('stream', e); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] stream: Error \[EPIPE\]: write EPIPE/); expect(entry).not.toMatch(/diagnostic:/); }); test('custom Error subclass name is preserved in the head', async () => { class WidgetError extends Error { constructor(msg) { super(msg); this.name = 'WidgetError'; } } await log.error('sub', new WidgetError('blew up')); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] sub: WidgetError: blew up/); }); test('empty error.message falls back to the bare error.name (defensive)', async () => { const empty = new Error(''); await log.error('empty', empty); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] empty: Error$/m); }); test('AggregateError with sub-errors emits a diagnostic block listing each cause', async () => { // Realistic shape: registry-1.docker.io multi-A lookup timeout returning // an AggregateError of ECONNREFUSED / Timeout / EAI_AGAIN sub-errors. const agg = new AggregateError( [ Object.assign(new Error('connect ECONNREFUSED 157.240.20.50:443'), { code: 'ECONNREFUSED' }), Object.assign(new Error('connect ETIMEDOUT 157.240.21.50:443'), { code: 'ETIMEDOUT' }), Object.assign(new Error('getaddrinfo EAI_AGAIN registry-1.docker.io'), { code: 'EAI_AGAIN' }), ], '' ); await log.error('update', agg, null, { imageName: 'ipfs/kubo:latest' }); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] update: AggregateError/); expect(entry).toMatch(/diagnostic:/); expect(entry).toMatch(/cause #1:/); expect(entry).toMatch(/cause #2:/); expect(entry).toMatch(/cause #3:/); expect(entry).toMatch(/Error \[ECONNREFUSED\]: connect ECONNREFUSED 157\.240\.20\.50:443/); expect(entry).toMatch(/Error \[ETIMEDOUT\]: connect ETIMEDOUT 157\.240\.21\.50:443/); expect(entry).toMatch(/Error \[EAI_AGAIN\]: getaddrinfo EAI_AGAIN registry-1\.docker\.io/); expect(entry).toMatch(/context: \{.*imageName.*"ipfs\/kubo:latest".*\}/); // No double header for AggregateError (we suppress the empty head line). expect(entry).not.toMatch(/diagnostic: AggregateError/); }); test('Error with .cause emits a nested diagnostic block', async () => { const inner = new Error('TLS handshake failed'); const outer = new Error('fetch failed', { cause: inner }); await log.error('net', outer); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] net: Error: fetch failed/); expect(entry).toMatch(/cause:/); expect(entry).toMatch(/Error: TLS handshake failed/); }); test('nested AggregateError (sub-error is itself an Aggregate) recurses', async () => { const inner = new AggregateError([new Error('inner-A'), new Error('inner-B')], ''); const outer = new AggregateError([new Error('outer-X'), inner], ''); await log.error('rec', outer); const [entry] = await readTail(); expect(entry).toMatch(/\[ERR\] rec: AggregateError/); expect(entry).toMatch(/cause #1:[\s\S]*Error: outer-X/); // inner is itself an Aggregate, so its child errors surface as "cause #N": expect(entry).toMatch(/inner-A/); expect(entry).toMatch(/inner-B/); }); test('separator is appended after each entry (file-format invariant)', async () => { await log.error('sep', new Error('one')); await log.error('sep', new Error('two')); const raw = await fs.readFile(TMP_LOG, 'utf8'); const sep = '\u2500'.repeat(72); // Count separator occurrences without reserved regex chars tripping us up. const re = new RegExp(sep.split('').map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join(''), 'g'); const occurrences = (raw.match(re) || []).length; expect(occurrences).toBeGreaterThanOrEqual(2); }); test('req field is still emitted when the calling site passes a request', async () => { const req = { method: 'POST', path: '/api/v1/widgets', ip: '10.0.0.5', get: () => 'curl/8', id: 'r-42' }; await log.error('withreq', new Error('widget blew up'), req); const [entry] = await readTail(); expect(entry).toMatch(/request: POST \/api\/v1\/widgets \| ip: 10\.0\.0\.5 \| ua: curl\/8 \| id: r-42/); }); test('extra context JSON is still emitted after stack (regression)', async () => { await log.error('ctx', new Error('payload'), null, { operation: 'rotate', tenantId: 7 }); const [entry] = await readTail(); expect(entry).toMatch(/context: \{"operation":"rotate","tenantId":7\}/); }); // Polish-grade hardening (per GLM round-1 B+ findings): cycle guard + depth cap. test('circular .cause references do not infinite-loop (cycle guard)', async () => { const a = new Error('top'); const b = new Error('middle'); const c = new Error('bottom'); // c.cause = b would be normal; force a CYCLE by linking back to a. b.cause = a; a.cause = c; c.cause = a; // cycle: a <-> a await expect(log.error('cycle', a, null)).resolves.not.toThrow(); const [entry] = await readTail(); expect(entry).toMatch(/top/); expect(entry).toMatch(/cycle: same Error instance seen earlier/); }); test('excessively deep .cause chains are truncated, not crashed (depth cap)', async () => { // Build a chain 50 deep ending in 'level-50' at the deepest; each layer // wraps the previous via .cause. log.error is called with the deepest // (outer) Error. let cur = new Error('level-1'); for (let i = 2; i <= 50; i++) { const parent = new Error(`level-${i}`); parent.cause = cur; cur = parent; } await expect(log.error('deep', cur)).resolves.not.toThrow(); const [entry] = await readTail(); expect(entry).toMatch(/chain truncated at depth 16/); expect(entry).toMatch(/level-50/); // the deepest/head shown in headline expect(entry).not.toMatch(/level-1/); // the leaf is too deep to render }); test('circular `.errors` array (sub-error is itself in the parent) is bounded', async () => { const sub = new Error('shared sub-error'); const agg = new AggregateError([sub, new Error('other')], ''); // pathological: sub-Aggregate references the parent sub.errors = [agg]; await expect(log.error('aggcycle', agg)).resolves.not.toThrow(); const [entry] = await readTail(); expect(entry).toMatch(/shared sub-error/); expect(entry).toMatch(/cycle: same Error instance seen earlier/); }); });