/** * DC-055: Host journald reader unit tests * * The reader is a security-sensitive shell-out — every test below exists * to prevent a regression that would let a caller pass a tainted unit * name or since/until/search string to journalctl. We never call the real * binary; every spawn is mocked by injecting an `exec` function (the * module accepts exec as the second argument specifically for testability). */ const path = require('path'); const { EventEmitter } = require('events'); const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js'); // Construct a fake child process that matches the interface journald-reader // uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand. function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = killFn || (() => {}); process.nextTick(() => { if (failOnSpawn) { const err = new Error('spawn fail'); err.code = failOnSpawn; child.emit('error', err); return; } if (stdout) child.stdout.emit('data', Buffer.from(stdout)); if (stderr) child.stderr.emit('data', Buffer.from(stderr)); child.emit('exit', code, signal); }); return child; } // Factory for an `exec` function that returns the given fake child. function fakeExec(child) { return jest.fn().mockReturnValue(child); } describe('journald-reader', () => { describe('assertUnitAllowed', () => { const { assertUnitAllowed } = require(MODULE_PATH); test('accepts allow-listed bare names', () => { expect(assertUnitAllowed('caddy')).toBe('caddy'); expect(assertUnitAllowed('docker')).toBe('docker'); expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api'); }); test('strips .service suffix', () => { expect(assertUnitAllowed('caddy.service')).toBe('caddy'); expect(assertUnitAllowed('docker.service')).toBe('docker'); }); test('rejects units not on the allow-list', () => { expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/); expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/); expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/); }); test('rejects shell metacharacters and path traversal', () => { expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/); expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/); expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/); expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/); expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/); }); test('rejects empty / non-string', () => { expect(() => assertUnitAllowed('')).toThrow(/unit is required/); expect(() => assertUnitAllowed(null)).toThrow(/unit is required/); expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/); expect(() => assertUnitAllowed(42)).toThrow(/unit is required/); }); test('throws ValidationError specifically (route layer keys on .name)', () => { try { assertUnitAllowed('nginx'); } catch (e) { expect(e.name).toBe('ValidationError'); } }); }); describe('parseTail', () => { const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH); test('returns fallback on undefined', () => { expect(parseTail(undefined)).toBe(200); expect(parseTail(undefined, 50)).toBe(50); }); test('clamps to MAX_TAIL_LINES', () => { expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES); expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES); }); test('rejects non-positive and non-integer', () => { expect(() => parseTail('0')).toThrow(/positive integer/); expect(() => parseTail('-5')).toThrow(/positive integer/); expect(() => parseTail('abc')).toThrow(/positive integer/); expect(() => parseTail('1.5')).toThrow(/positive integer/); expect(() => parseTail(NaN)).toThrow(/positive integer/); }); test('accepts valid integers', () => { expect(parseTail('1')).toBe(1); expect(parseTail('500')).toBe(500); expect(parseTail(200)).toBe(200); }); }); describe('parseTimestamp', () => { const { parseTimestamp } = require(MODULE_PATH); test('returns null on undefined/empty', () => { expect(parseTimestamp(undefined, 'since')).toBeNull(); expect(parseTimestamp('', 'since')).toBeNull(); expect(parseTimestamp(null, 'since')).toBeNull(); }); test('parses ISO 8601 timestamps', () => { const out = parseTimestamp('2026-08-18T07:00:00Z', 'since'); expect(out).toBe('2026-08-18T07:00:00.000Z'); }); test('parses ISO date-only', () => { const out = parseTimestamp('2026-08-18', 'since'); expect(out).toMatch(/^2026-08-18/); }); test('parses unix epoch in seconds and ms', () => { // Use a known epoch so the test isn't sensitive to "now". The // expected ISO output is computed at runtime so this stays correct. const epochSec = 1787038846; // 2026-08-18T07:00:46Z const expected = new Date(epochSec * 1000).toISOString(); expect(parseTimestamp(String(epochSec), 'since')).toBe(expected); expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected); }); test('passes through journalctl relative syntax', () => { expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago'); expect(parseTimestamp('today', 'until')).toBe('today'); }); test('rejects shell metacharacters in relative syntax', () => { expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/); expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/); }); test('rejects strings >1024 chars', () => { const huge = 'a'.repeat(1025); expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/); }); test('rejects invalid ISO', () => { // 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or // safe relative-syntax — falls through to the relative branch but // doesn't contain forbidden chars either, so it would pass through // to journalctl. Use a string with shell metacharacters instead // to prove the path actually rejects. expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow(); // Numbers that overflow Date.parse expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow(); }); }); describe('buildArgv', () => { const { buildArgv } = require(MODULE_PATH); test('always emits --directory + unit + --no-pager', () => { const argv = buildArgv({ unit: 'caddy', tail: 100 }); expect(argv).toContain('--directory'); expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal'); expect(argv).toContain('--no-pager'); expect(argv).toContain('-u'); expect(argv[argv.indexOf('-u') + 1]).toBe('caddy'); expect(argv).not.toContain('--follow'); }); test('follow flag is set when requested', () => { const argv = buildArgv({ unit: 'caddy', follow: true }); expect(argv).toContain('--follow'); }); test('emits -n for numeric tail', () => { const argv = buildArgv({ unit: 'caddy', tail: 500 }); const idx = argv.indexOf('-n'); expect(idx).toBeGreaterThan(-1); expect(argv[idx + 1]).toBe('500'); }); test('emits --since/--until/search when provided', () => { const argv = buildArgv({ unit: 'caddy', tail: 100, since: '2026-08-18T00:00:00Z', until: '2026-08-18T23:59:59Z', search: 'health', }); expect(argv).toContain('--since'); expect(argv).toContain('--until'); expect(argv).toContain('-S'); expect(argv[argv.indexOf('-S') + 1]).toBe('health'); }); test('emits argv as a flat string array (no shell)', () => { const argv = buildArgv({ unit: 'caddy', tail: 1 }); expect(argv.every(a => typeof a === 'string')).toBe(true); }); }); describe('readEntries', () => { const reader = require(MODULE_PATH); test('parses short-output lines into structured entries', async () => { const child = makeFakeChild({ stdout: [ 'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}', 'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}', '', ].join('\n'), }); const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) }); expect(entries).toHaveLength(2); expect(entries[0].timestamp).toBe('Aug 18 00:42:46'); expect(entries[0].hostname).toBe('vmi3080415'); expect(entries[0].unit).toBe('caddy'); expect(entries[0].text).toBe('{"level":"info","msg":"hello"}'); }); test('throws on ValidationError for bad unit', async () => { await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({ name: 'ValidationError', }); }); test('throws on ValidationError for bad tail', async () => { await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({ name: 'ValidationError', }); }); test('throws on ValidationError for shell-meta since', async () => { await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' })) .rejects.toMatchObject({ name: 'ValidationError' }); }); test('surfaces ENOENT as Error("journalctl unavailable")', async () => { const child = makeFakeChild({ failOnSpawn: 'ENOENT' }); const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) }) .then(() => null, e => e); expect(err.message).toBe('journalctl unavailable'); }); test('surfaces non-zero exit with stderr snippet', async () => { const child = makeFakeChild({ stdout: '', stderr: 'Failed to open directory: /var/log/journal/foo\n', code: 1, }); const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) }) .then(() => null, e => e); expect(err.message).toMatch(/exited 1/); expect(err.message).toMatch(/Failed to open directory/); }); test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => { // Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap. // Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB. const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = jest.fn(); const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER; const first = Math.floor(cap * 0.4); // 40% const second = Math.floor(cap * 0.7); // 70% more — total 110% process.nextTick(() => { child.stdout.emit('data', Buffer.alloc(first, 'x')); child.stdout.emit('data', Buffer.alloc(second, 'x')); // Don't emit exit — the overflow rejection doesn't depend on it. // Kill the child eventually so Jest can exit cleanly. setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50); }); const execSpy = jest.fn().mockReturnValue(child); const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy }) .then(() => null, e => e); expect(err).not.toBeNull(); expect(err.message).toMatch(/exceeded/); expect(child.kill).toHaveBeenCalledWith('SIGKILL'); }); }); describe('streamEntries', () => { const reader = require(MODULE_PATH); test('emits parsed data + completes on exit', async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.kill = jest.fn(); process.nextTick(() => { child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n')); child.emit('exit', 0, null); }); const seen = []; const execSpy = jest.fn().mockReturnValue(child); reader.streamEntries({ unit: 'caddy' }, { exec: execSpy, onData: (e) => seen.push(e), onError: () => {}, }); // Drain microtasks so the nextTick callback fires. await new Promise((r) => setTimeout(r, 30)); expect(execSpy).toHaveBeenCalledTimes(1); expect(seen.length).toBeGreaterThanOrEqual(1); expect(seen[0].unit).toBe('caddy'); expect(seen[0].text).toBe('hello'); }); test('rejects bad unit before opening stream', () => { expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} })) .toThrow(/not in allow-list/); }); }); });