135 lines
5.3 KiB
JavaScript
135 lines
5.3 KiB
JavaScript
// Unit tests for the fixed /api/v1/error-logs parser
|
|
// (route /opt/dashcaddy/dashcaddy-api/routes/errorlogs.js)
|
|
//
|
|
// Background: the prior implementation split on '='.repeat(80) but the
|
|
// unified logger writes \u2500 horizontal-rule separators. As a result
|
|
// every modal-open returned ZERO entries — same class of silent bug as
|
|
// DC-050 (audit log). These tests pin the new behavior so future refactors
|
|
// can't reintroduce it.
|
|
|
|
const { parseEntries, readTailBytes, MAX_TAIL } = require('../routes/errorlogs');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const os = require('os');
|
|
|
|
const SEP = '\n' + '\u2500'.repeat(72) + '\n';
|
|
|
|
function buildLog(entries) {
|
|
return entries.map((e, i) => {
|
|
const head = `[${e.timestamp}] [${e.level}] ${e.context}: ${e.message}`;
|
|
return head + (e.details ? '\n' + e.details : '') + SEP;
|
|
}).join('');
|
|
}
|
|
|
|
describe('errorlogs parser (DC-051)', () => {
|
|
test('parses single entry with U+2500 separator', () => {
|
|
const text = buildLog([{
|
|
timestamp: '2026-08-16T23:13:14.123Z',
|
|
level: 'ERR',
|
|
context: '/api/v1/templates',
|
|
message: 'Route GET /v1/templates not found',
|
|
details: 'NotFoundError: Route GET /v1/templates not found\n at notFoundHandler (/app/src/utilities/error-handler.js:71:8)',
|
|
}]);
|
|
const out = parseEntries(text);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0]).toMatchObject({
|
|
timestamp: '2026-08-16T23:13:14.123Z',
|
|
level: 'ERR',
|
|
context: '/api/v1/templates',
|
|
message: 'Route GET /v1/templates not found',
|
|
});
|
|
expect(out[0].details).toContain('notFoundHandler');
|
|
expect(out[0].details).not.toContain('\u2500');
|
|
});
|
|
|
|
test('returns multiple entries in order, ignoring separator residue', () => {
|
|
const text = buildLog([
|
|
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'first' },
|
|
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'WRN', context: 'b', message: 'second' },
|
|
{ timestamp: '2026-08-16T23:02:00.000Z', level: 'INF', context: 'c', message: 'third', details: 'extra' },
|
|
]);
|
|
const out = parseEntries(text);
|
|
expect(out.map(e => e.context)).toEqual(['a', 'b', 'c']);
|
|
expect(out[1].level).toBe('WRN');
|
|
expect(out[2].details).toBe('extra');
|
|
});
|
|
|
|
test('skips malformed lines without throwing', () => {
|
|
const text = 'this is not a log entry\n' + SEP + '[2026-08-16T23:00:00.000Z] [ERR] x: y\n' + SEP;
|
|
const out = parseEntries(text);
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].message).toBe('y');
|
|
});
|
|
|
|
test('empty input returns empty array', () => {
|
|
expect(parseEntries('')).toEqual([]);
|
|
expect(parseEntries(' \n\n ')).toEqual([]);
|
|
});
|
|
|
|
test('regression: would have returned 0 entries under the OLD splitter', () => {
|
|
// Old impl: text.split('='.repeat(80)).filter(...). That produced one
|
|
// big block, parser rejected all headers, returned ZERO entries. New
|
|
// impl must NOT regress to that behavior on a real-format log.
|
|
const text = buildLog([
|
|
{ timestamp: '2026-08-16T23:00:00.000Z', level: 'ERR', context: 'a', message: 'm' },
|
|
{ timestamp: '2026-08-16T23:01:00.000Z', level: 'ERR', context: 'b', message: 'm' },
|
|
]);
|
|
// Sanity: the old split would produce 1 block (no '=' in the text).
|
|
expect(text.split('='.repeat(80))).toHaveLength(1);
|
|
// New parser must surface both entries.
|
|
expect(parseEntries(text)).toHaveLength(2);
|
|
});
|
|
|
|
test('MAX_TAIL is bounded (>=100, <=1000) — prevents unbounded read', () => {
|
|
expect(MAX_TAIL).toBeGreaterThanOrEqual(100);
|
|
expect(MAX_TAIL).toBeLessThanOrEqual(1000);
|
|
});
|
|
});
|
|
|
|
describe('errorlogs readTailBytes (DC-051)', () => {
|
|
let tmpFile;
|
|
beforeAll(async () => {
|
|
tmpFile = path.join(os.tmpdir(), `dc-051-errorlog-${process.pid}.log`);
|
|
const entries = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
entries.push({
|
|
timestamp: `2026-08-16T23:${String(i % 60).padStart(2,'0')}:00.000Z`,
|
|
level: i % 2 === 0 ? 'ERR' : 'WRN',
|
|
context: `ctx-${i}`,
|
|
message: `message body ${i}`,
|
|
details: i % 3 === 0 ? `stack for ${i}` : null,
|
|
});
|
|
}
|
|
await fsp.writeFile(tmpFile, buildLog(entries));
|
|
});
|
|
afterAll(async () => {
|
|
try { await fsp.unlink(tmpFile); } catch {}
|
|
});
|
|
|
|
test('returns parsed entries within the byte budget', async () => {
|
|
const { text, totalSize, truncated } = await readTailBytes(tmpFile, 4 * 1024);
|
|
expect(typeof totalSize).toBe('number');
|
|
expect(typeof truncated).toBe('boolean');
|
|
const parsed = parseEntries(text);
|
|
expect(parsed.length).toBeGreaterThan(0);
|
|
// Should never include partial first line — every parsed entry has a real timestamp.
|
|
for (const e of parsed) {
|
|
expect(e.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
}
|
|
});
|
|
|
|
test('truncates when file exceeds byte budget', async () => {
|
|
const stat = await fsp.stat(tmpFile);
|
|
const smallBudget = Math.floor(stat.size / 4);
|
|
const { truncated } = await readTailBytes(tmpFile, smallBudget);
|
|
expect(truncated).toBe(true);
|
|
});
|
|
|
|
test('does not truncate when file fits within byte budget', async () => {
|
|
const stat = await fsp.stat(tmpFile);
|
|
const bigBudget = stat.size * 2;
|
|
const { truncated } = await readTailBytes(tmpFile, bigBudget);
|
|
expect(truncated).toBe(false);
|
|
});
|
|
}); |