import test from 'brittle' import fs from 'fs' import os from 'os' import path from 'path' import { Roles } from '../shared/protocol.js' import { validateMethodArgs } from '../shared/schema.js' import { assertLogSourceAllowed, buildJournalArgv, clampLimit, isJournalEnabled, matchesFilters, normalizeAnomaly, normalizeAudit, normalizeJournal, parseTimeBound, queryLogs, readAuditFileLines, } from '../server/services/logs.js' test('clampLimit bounds', (t) => { t.is(clampLimit(50), 50) t.is(clampLimit(99999), 2000) t.is(clampLimit(0), 200) t.is(clampLimit('x'), 200) }) test('parseTimeBound absolute and relative', (t) => { const sec = 1_700_000_000 t.is(parseTimeBound(sec), sec * 1000) t.ok(Math.abs((parseTimeBound('-1h') || 0) - (Date.now() - 3_600_000)) < 2000) t.ok(parseTimeBound('2020-01-01T00:00:00.000Z') === Date.parse('2020-01-01T00:00:00.000Z')) }) test('assertLogSourceAllowed roles', (t) => { t.ok(assertLogSourceAllowed(Roles.viewer, 'anomaly').ok) t.absent(assertLogSourceAllowed(Roles.viewer, 'audit').ok) t.absent(assertLogSourceAllowed(Roles.operator, 'journal').ok) t.ok(assertLogSourceAllowed(Roles.admin, 'audit').ok) t.ok(assertLogSourceAllowed(Roles.admin, 'journal').ok) t.is(assertLogSourceAllowed(Roles.viewer, 'audit').code, 'PERMISSION_DENIED') }) test('buildJournalArgv fixed flags', (t) => { const argv = buildJournalArgv({ limit: 50, sinceMs: Date.parse('2024-01-01T00:00:00.000Z'), untilMs: Date.parse('2024-01-01T01:00:00.000Z'), priority: '3', unit: 'peardata.service', q: 'error', }) t.ok(argv.includes('--output=json')) t.ok(argv.includes('--no-pager')) t.ok(argv.includes('-n')) t.ok(argv.includes('50')) t.ok(argv.includes('--since')) t.ok(argv.includes('--until')) t.ok(argv.includes('-p')) t.ok(argv.includes('3')) t.ok(argv.includes('-u')) t.ok(argv.includes('peardata.service')) t.ok(argv.includes('--grep')) t.ok(argv.includes('error')) t.absent(argv.some((a) => String(a).includes(';'))) }) test('normalize helpers', (t) => { const a = normalizeAnomaly({ ts: 1_700_000_000_000, chart: 'system.cpu', severity: 'critical', message: 'cpu high', }) t.is(a.source, 'anomaly') t.is(a.unit, 'system.cpu') t.ok(a.message.includes('cpu')) const audit = normalizeAudit({ ts: '2024-06-01T12:00:00.000Z', method: 'mintInvite', peerId: 'abcd', role: 'admin', ok: true, }) t.is(audit.source, 'audit') t.ok(audit.message.includes('mintInvite')) const j = normalizeJournal({ __REALTIME_TIMESTAMP: String(1_700_000_000_000_000), PRIORITY: '3', _SYSTEMD_UNIT: 'sshd.service', MESSAGE: 'Failed password', }) t.is(j.source, 'journal') t.is(j.severity, 'err') t.is(j.unit, 'sshd.service') }) test('matchesFilters q and window', (t) => { const row = { ts: 1000, message: 'Hello World', unit: 'cpu', severity: 'warning' } t.ok(matchesFilters({ q: 'hello' }, row)) t.absent(matchesFilters({ q: 'nope' }, row)) t.absent(matchesFilters({ since: 2000 }, row)) t.ok(matchesFilters({ since: 500, until: 1500 }, row)) }) test('queryLogs anomaly with mock engine', async (t) => { const anomalies = { listRecent() { return [ { ts: Date.now() - 1000, chart: 'system.cpu', severity: 'warning', message: 'cpu warn' }, { ts: Date.now() - 500, chart: 'system.ram', severity: 'critical', message: 'ram crit' }, ] }, } const res = await queryLogs( { source: 'anomaly', q: 'ram', role: Roles.viewer, limit: 50 }, { anomalies } ) t.ok(res.ok) t.is(res.entries.length, 1) t.is(res.entries[0].unit, 'system.ram') }) test('queryLogs audit from temp file', async (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardata-audit-')) const file = path.join(dir, 'audit.log') const lines = [ JSON.stringify({ ts: new Date(Date.now() - 60_000).toISOString(), method: 'mintInvite', peerId: 'aabb', role: 'admin', ok: true, error: null, }), JSON.stringify({ ts: new Date().toISOString(), method: 'runJob', peerId: 'ccdd', role: 'operator', ok: false, error: 'boom', }), ] fs.writeFileSync(file, lines.join('\n') + '\n') t.is(readAuditFileLines(file).length, 2) const denied = await queryLogs({ source: 'audit', role: Roles.viewer, auditPath: file }) t.absent(denied.ok) t.is(denied.code, 'PERMISSION_DENIED') const res = await queryLogs( { source: 'audit', role: Roles.admin, q: 'runJob', auditPath: file, limit: 20 }, {} ) t.ok(res.ok) t.is(res.entries.length, 1) t.ok(res.entries[0].message.includes('runJob')) }) test('queryLogs journal default on / disable / unsupported', async (t) => { const prev = process.env.PEARDATA_JOURNAL delete process.env.PEARDATA_JOURNAL t.ok(isJournalEnabled(), 'journal enabled by default') process.env.PEARDATA_JOURNAL = '0' t.absent(isJournalEnabled()) const off = await queryLogs({ source: 'journal', role: Roles.admin }) t.absent(off.ok) t.is(off.error, 'journal_disabled') process.env.PEARDATA_JOURNAL = '1' t.ok(isJournalEnabled()) if (os.platform() !== 'linux') { const uns = await queryLogs({ source: 'journal', role: Roles.admin }) t.absent(uns.ok) t.is(uns.error, 'unsupported') } else { const res = await queryLogs( { source: 'journal', role: Roles.admin, limit: 5 }, { spawnJournal: async () => ({ stdout: '', stderr: 'No journal files were found', code: 1, }), } ) t.absent(res.ok) t.is(res.error, 'journalctl_failed') } if (prev == null) delete process.env.PEARDATA_JOURNAL else process.env.PEARDATA_JOURNAL = prev }) test('validateMethodArgs queryLogs', (t) => { t.ok(validateMethodArgs('queryLogs', {}).ok) t.ok(validateMethodArgs('queryLogs', { source: 'anomaly', limit: 10 }).ok) t.absent(validateMethodArgs('queryLogs', { source: 'nope' }).ok) t.absent(validateMethodArgs('queryLogs', { limit: 0 }).ok) })