/** * Smoke tests for the enhanced error-logs route (DC-052). * * Mirrors `caddy-upstreams.routes.test.js`: build the router with stubbed * deps, hit it via a tiny express app, assert the response shape and * the audit-logger interactions. * * Fixture: a synthetic error log with two ERR entries and one WARN entry, * each with a different context, IP, and stack — enough to exercise the * filter chain (level, context, search, since/until) without pulling the * real 47k-line error.log off the host. */ const express = require('express'); const path = require('path'); const fs = require('fs'); const os = require('os'); const ENTRY_SEP = '='.repeat(80); const FIXTURE_LOG = [ `[2026-08-17T10:00:00.000Z] [ERR] updater: getLocalVersion failed: no candidate package.json found`, ` at SelfUpdater.getLocalVersion (/app/src/docker/self-updater.js:128:9)`, ` request: GET /api/v1/updates/check | ip: 100.85.236.10 | ua: Mozilla/5.0 | id: a1`, ` context: {"triggeredBy":"manual"}`, ENTRY_SEP, `[2026-08-17T11:00:00.000Z] [ERR] http: GET /api/v1/templates 503`, ` at Logger.error (/app/src/utils/logging.js:258:49)`, ` request: GET /api/v1/templates | ip: 100.85.236.11 | ua: curl/8.0 | id: a2`, ` context: {"service":"templates"}`, ENTRY_SEP, `[2026-08-17T12:00:00.000Z] [WARN] ssl-monitor: cert check failed: TLS connect error for sonarr.sami:443: getaddrinfo ENOTFOUND sonarr.sami`, ` at Logger.warn (/app/src/utils/logging.js:200:10)`, ` request: GET /api/v1/ssl/check | ip: 100.121.150.22 | ua: NodeHealthCheck | id: a3`, ` context: {"service":"sonarr"}`, ENTRY_SEP, ``, ].join('\n'); function buildFakeAuditLogger() { return { clear: jest.fn(async () => {}), log: jest.fn(async () => {}), }; } function writeFixtureLog(tmpDir) { const logFile = path.join(tmpDir, 'error.log'); fs.writeFileSync(logFile, FIXTURE_LOG); return logFile; } describe('routes/errorlogs (DC-052)', () => { let tmpDir; let logFile; let auditLogger; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc052-errorlogs-')); logFile = writeFixtureLog(tmpDir); auditLogger = buildFakeAuditLogger(); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); function buildRouter() { const mod = require('../../routes/errorlogs'); return mod({ ERROR_LOG_FILE: logFile, auditLogger, asyncHandler: (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }, }); } function listen(router) { const app = express(); app.use(express.json()); app.use(router); return app.listen(0); } test('router exposes the DC-052 endpoints', () => { const router = buildRouter(); const paths = router.stack .filter((l) => l.route) .map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`)) .flat(); expect(paths).toEqual(expect.arrayContaining([ 'GET /error-logs', 'GET /error-logs/contexts', 'DELETE /error-logs', ])); }); test('GET /error-logs returns newest-first with totals', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs`); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.total).toBe(3); expect(body.logs).toHaveLength(3); expect(body.hasMore).toBe(false); expect(body.filters).toEqual({ level: null, context: null, search: null, since: null, until: null, }); // Newest first: 12:00 (WARN ssl-monitor) → 11:00 (ERR http) → 10:00 (ERR updater). expect(body.logs[0].level).toBe('WARN'); expect(body.logs[1].level).toBe('ERR'); expect(body.logs[2].level).toBe('ERR'); expect(body.logs[0].timestamp).toBe('2026-08-17T12:00:00.000Z'); }); test('GET /error-logs filters by level', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=ERR`); const body = await res.json(); server.close(); expect(body.total).toBe(2); expect(body.logs.every((e) => e.level === 'ERR')).toBe(true); }); test('GET /error-logs filters by context (substring)', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`); const body = await res.json(); server.close(); expect(body.total).toBe(1); expect(body.logs[0].context).toBe('updater'); }); test('GET /error-logs free-text search hits error / context / detail', async () => { const server = listen(buildRouter()); const { port } = server.address(); // "sonarr" appears only in the WARN stack; should still match via detail. let res = await fetch(`http://127.0.0.1:${port}/error-logs?search=sonarr`); let body = await res.json(); expect(body.total).toBe(1); expect(body.logs[0].context).toBe('ssl-monitor'); // "503" appears only in the ERR http message; should match via error. res = await fetch(`http://127.0.0.1:${port}/error-logs?search=503`); body = await res.json(); expect(body.total).toBe(1); expect(body.logs[0].context).toBe('http'); server.close(); }); test('GET /error-logs respects since/until as numeric ISO compare', async () => { const server = listen(buildRouter()); const { port } = server.address(); // Window covers only 11:00Z entry. const res = await fetch( `http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('2026-08-17T10:30:00Z')}&until=${encodeURIComponent('2026-08-17T11:30:00Z')}` ); const body = await res.json(); server.close(); expect(body.total).toBe(1); expect(body.logs[0].timestamp).toBe('2026-08-17T11:00:00.000Z'); }); test('GET /error-logs rejects invalid since with 400', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?since=not-a-date`); const body = await res.json(); server.close(); expect(res.status).toBe(400); expect(body.success).toBe(false); }); test('GET /error-logs rejects unknown level with 400', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=FATAL`); const body = await res.json(); server.close(); expect(res.status).toBe(400); }); test('GET /error-logs paginates and reports hasMore', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res1 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=0`); const body1 = await res1.json(); expect(body1.logs).toHaveLength(2); expect(body1.total).toBe(3); expect(body1.hasMore).toBe(true); const res2 = await fetch(`http://127.0.0.1:${port}/error-logs?limit=2&offset=2`); const body2 = await res2.json(); expect(body2.logs).toHaveLength(1); expect(body2.hasMore).toBe(false); server.close(); }); test('GET /error-logs clamps limit to MAX_LIMIT', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?limit=99999`); const body = await res.json(); server.close(); // 3 entries total so we still get 3, but the route didn't blow up on a // giant limit; the contract is limit <= 500 and we just clamp. expect(body.logs.length).toBeLessThanOrEqual(500); expect(body.total).toBe(3); }); test('GET /error-logs/contexts returns distinct contexts sorted by count', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`); const body = await res.json(); server.close(); expect(body.success).toBe(true); expect(body.contexts).toHaveLength(3); // updater + http + ssl-monitor — each appears once. const names = body.contexts.map((c) => c.name).sort(); expect(names).toEqual(['http', 'ssl-monitor', 'updater']); expect(body.contexts.every((c) => c.count === 1)).toBe(true); }); test('DELETE /error-logs without confirm is rejected with 400', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs`, { method: 'DELETE' }); const body = await res.json(); server.close(); expect(res.status).toBe(400); expect(body.success).toBe(false); // File still intact. expect(fs.readFileSync(logFile, 'utf8')).toBe(FIXTURE_LOG); }); test('DELETE /error-logs with confirm=CLEAR truncates and audits', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: 'CLEAR' }), }); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.success).toBe(true); expect(fs.readFileSync(logFile, 'utf8')).toBe(''); expect(auditLogger.log).toHaveBeenCalledWith(expect.objectContaining({ action: 'error-log.clear', outcome: 'success', })); }); test('GET /error-logs returns empty when log file missing', async () => { fs.unlinkSync(logFile); const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs`); const body = await res.json(); server.close(); expect(body.success).toBe(true); expect(body.logs).toEqual([]); expect(body.total).toBe(0); }); test('GET /error-logs preserves stack frames in detail field', async () => { const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs?context=updater`); const body = await res.json(); server.close(); expect(body.logs[0].detail).toContain('self-updater.js:128'); expect(body.logs[0].detail).toContain('context:'); }); test('GET /error-logs handles malformed entry as raw fallback', async () => { // The parser splits the log on ENTRY_SEP (80 equal signs). A junk block // that has no timestamp header should still surface as a raw entry so // the operator doesn't lose forensic context. Place the malformed // block AFTER the separator so it ends up in its own split segment. fs.writeFileSync(logFile, [ `[2026-08-17T13:00:00.000Z] [ERR] junk: well-formed entry`, ENTRY_SEP, `this is a malformed block with no timestamp header`, `and no level bracket at all`, ENTRY_SEP, ``, ].join('\n')); const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs`); const body = await res.json(); server.close(); expect(body.total).toBe(2); const raw = body.logs.find((e) => e.level === null); expect(raw).toBeDefined(); expect(raw.error).toContain('malformed block'); expect(raw.raw).toContain('malformed block'); }); test('GET /error-logs/contexts returns empty array when file missing', async () => { fs.unlinkSync(logFile); const server = listen(buildRouter()); const { port } = server.address(); const res = await fetch(`http://127.0.0.1:${port}/error-logs/contexts`); const body = await res.json(); server.close(); expect(body.success).toBe(true); expect(body.contexts).toEqual([]); }); test('GET /error-logs?search matches IP field', async () => { const server = listen(buildRouter()); const { port } = server.address(); // 100.85.236.11 is only on the /api/v1/templates entry. const res = await fetch(`http://127.0.0.1:${port}/error-logs?search=100.85.236.11`); const body = await res.json(); server.close(); expect(body.total).toBe(1); expect(body.logs[0].request.ip).toBe('100.85.236.11'); }); test('GET /error-logs accepts huge since/until without error', async () => { const server = listen(buildRouter()); const { port } = server.address(); // Far-future since — no entries match, but the route doesn't 500. const res = await fetch( `http://127.0.0.1:${port}/error-logs?since=${encodeURIComponent('9999-12-31T00:00:00Z')}` ); const body = await res.json(); server.close(); expect(res.status).toBe(200); expect(body.success).toBe(true); expect(body.total).toBe(0); expect(body.logs).toEqual([]); }); test('GET /error-logs combined filters compose correctly', async () => { const server = listen(buildRouter()); const { port } = server.address(); // level=WARN AND context=http: no entry matches (WARN is ssl-monitor). const res = await fetch(`http://127.0.0.1:${port}/error-logs?level=WARN&context=http`); const body = await res.json(); server.close(); expect(body.total).toBe(0); expect(body.logs).toEqual([]); expect(body.filters).toEqual({ level: 'WARN', context: 'http', search: null, since: null, until: null, }); }); });