/** * DC-055: Host journald route smoke tests. * * Mounts the routes/logs.js journald endpoints into a tiny express app * with a mocked journald reader. The mock mirrors the real module's * validation pipeline (assertUnitAllowed, parseTail, parseTimestamp) so * bad inputs still throw ValidationError -> 400 at the route boundary, * but the actual journalctl spawn is short-circuited. */ const express = require('express'); const request = require('supertest'); const path = require('path'); const realJournaldPath = require.resolve('../../src/monitoring/journald-reader.js'); // Pull the real module's validators so the mock's readEntries can // reproduce the same 400-on-bad-input behaviour as production. const realReader = jest.requireActual(realJournaldPath); // Mocked journald reader. Variable name MUST start with "mock" so // jest.mock hoisting doesn't reject the factory closure. const mockJournald = { ALLOWED_UNITS: realReader.ALLOWED_UNITS, MAX_TAIL_LINES: realReader.MAX_TAIL_LINES, MAX_OUTPUT_BUFFER: realReader.MAX_OUTPUT_BUFFER, isAvailable: jest.fn().mockResolvedValue(true), // Validation pipeline runs through the real assert/parse functions so // bad unit/tail/since/until still surface as ValidationError. The // journalctl spawn itself is short-circuited — return canned entries. readEntries: jest.fn(async (opts) => { const unit = realReader.assertUnitAllowed(opts.unit); realReader.parseTail(opts.tail); // throws on bad tail realReader.parseTimestamp(opts.since, 'since'); realReader.parseTimestamp(opts.until, 'until'); return [ { timestamp: 'Aug 18 00:42:46', hostname: 'host', unit, text: 'mock-line-1' }, ]; }), // Default stream mock: invokes onData with one synthetic entry then // returns a no-op handle. Tests override per-case. streamEntries: jest.fn((opts, hooks = {}) => { if (hooks.onData) { hooks.onData({ timestamp: 'Aug 18 00:42:46', unit: opts.unit, text: 'stream-line-1' }); } return { kill: jest.fn(), child: {} }; }), listUnits: jest.fn(async () => [ { unit: 'caddy', hasEntries: true }, { unit: 'docker', hasEntries: true }, ]), assertUnitAllowed: realReader.assertUnitAllowed, parseTail: realReader.parseTail, parseTimestamp: realReader.parseTimestamp, parseShortLine: realReader.parseShortLine, buildArgv: realReader.buildArgv, }; jest.mock('../../src/monitoring/journald-reader.js', () => mockJournald); // Force journaldAvailable = true in routes/logs.js. The route checks // /var/log/journal + /usr/bin/journalctl at module-load time, so we stub // fs.existsSync to lie about those paths. const realFs = require('fs'); const realExists = realFs.existsSync; realFs.existsSync = function(p) { if (p === '/var/log/journal' || p === '/usr/bin/journalctl') return true; return realExists.apply(this, arguments); }; const logsRoutes = require('../../routes/logs.js'); function buildApp() { const app = express(); const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); const ok = (res, data) => res.json({ success: true, ...data }); const errorHandler = (err, req, res, next) => { const status = err.statusCode || (err.name === 'ValidationError' ? 400 : 500); res.status(status).json({ success: false, error: err.message }); }; app.use('/api/v1', logsRoutes({ asyncHandler, ok })); app.use(errorHandler); return app; } describe('routes /logs/journal', () => { let app; beforeEach(async () => { mockJournald.readEntries.mockClear(); mockJournald.streamEntries.mockClear(); mockJournald.listUnits.mockClear(); app = buildApp(); // Let any keep-alive socket from the prior test close before we // bind a new express app. await new Promise(r => setTimeout(r, 10)); }); describe('GET /logs/journal/units', () => { test('returns unit list when journald is mounted', async () => { const res = await request(app).get('/api/v1/logs/journal/units'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.available).toBe(true); expect(res.body.units.length).toBeGreaterThanOrEqual(1); }); }); describe('GET /logs/journal', () => { test('returns entries for caddy', async () => { const res = await request(app) .get('/api/v1/logs/journal') .query({ unit: 'caddy', tail: 50 }); expect(res.status).toBe(200); expect(res.body.entries.length).toBeGreaterThanOrEqual(1); expect(res.body.entries[0].unit).toBe('caddy'); expect(mockJournald.readEntries).toHaveBeenCalled(); const call = mockJournald.readEntries.mock.calls[0][0]; expect(call.unit).toBe('caddy'); expect(call.tail).toBe('50'); }); test('forwards since/until/search verbatim', async () => { await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 100, since: '2026-08-18T00:00:00Z', until: '2026-08-18T23:59:59Z', search: 'health', }); const call = mockJournald.readEntries.mock.calls[0][0]; expect(call.since).toBe('2026-08-18T00:00:00Z'); expect(call.until).toBe('2026-08-18T23:59:59Z'); expect(call.search).toBe('health'); }); test('returns 400 when unit not in allow-list', async () => { const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'nginx' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/not in allow-list/); // The reader is called and rejects; the route layer maps the // ValidationError to 400 without doing any spawn. expect(mockJournald.readEntries).toHaveBeenCalled(); }); test('returns 400 when unit contains shell metacharacters', async () => { const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy; rm -rf /' }); expect(res.status).toBe(400); expect(mockJournald.readEntries).toHaveBeenCalled(); }); test('returns 400 when tail is invalid', async () => { const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 'oops' }); expect(res.status).toBe(400); }); test('returns 500 when reader throws non-validation error', async () => { mockJournald.readEntries.mockRejectedValueOnce(new Error('journalctl exited 1: bad dir')); const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy' }); expect(res.status).toBe(500); expect(res.body.error).toMatch(/journalctl exited 1/); }); }); describe('GET /logs/journal/stream', () => { test('opens SSE with correct content-type for a valid unit', async () => { // Stub the mock to immediately call onError so the route ends // the response and supertest can collect it. Production SSE // streams stay open until the client disconnects — covered by // the journald-reader.streamEntries unit tests. mockJournald.streamEntries.mockImplementationOnce((opts, hooks) => { setTimeout(() => hooks.onError && hooks.onError(new Error('synthetic-EOF')), 5); return { kill: jest.fn(), child: {} }; }); const res = await request(app) .get('/api/v1/logs/journal/stream') .query({ unit: 'caddy' }) .timeout(2000); expect(res.status).toBe(200); expect(res.headers['content-type']).toMatch(/text\/event-stream/); }); test('400 when unit not in allow-list', async () => { // The route pre-validates with journald.assertUnitAllowed BEFORE // opening SSE — invalid unit returns a 400 JSON response without // touching the stream. const res = await request(app) .get('/api/v1/logs/journal/stream') .query({ unit: 'nginx' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/not in allow-list/); // streamEntries must NOT have been called for a bad unit. expect(mockJournald.streamEntries).not.toHaveBeenCalled(); }); }); });