UNPKG

eerawlog

Version:
326 lines (302 loc) 11.2 kB
'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); const path = require('node:path'); const clc = require('cli-color'); const BIN = path.join(__dirname, '..', 'bin', 'eerawlog.js'); function runCli(input, args = []) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [BIN, ...args], { stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', d => (stdout += d)); child.stderr.on('data', d => (stderr += d)); child.on('error', reject); child.on('close', code => { resolve({ code, stdout: clc.strip(stdout), stderr: clc.strip(stderr) }); }); child.stdin.end(input); }); } // Fixed timestamp so rendered "[YYYY-MM-DD HH:MM:SS]" prefixes are stable. const FIXED_MS = Date.UTC(2024, 0, 15, 12, 30, 45); const FIXED_STR = '2024-01-15 12:30:45'; function line(obj) { return JSON.stringify(obj) + '\n'; } test('prints "input stream ended" on empty stdin', async () => { const { code, stdout } = await runCli(''); assert.equal(code, 0); assert.match(stdout, /input stream ended/); }); test('echoes malformed JSON lines verbatim', async () => { const { stdout } = await runCli('this is not json\n'); assert.match(stdout, /this is not json/); }); test('renders Hapi onPreHandler entries for /v1/ paths', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, action: 'onPreHandler', method: 'post', path: '/v1/account', account: 'acc1' }) ); assert.match(stdout, new RegExp(`\\[${FIXED_STR}\\] H: POST /v1/account \\[acc1\\]`)); }); test('skips onPreHandler entries for non-/v1/ paths', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, action: 'onPreHandler', method: 'get', path: '/health', account: 'acc1' }) ); assert.doesNotMatch(stdout, /\/health/); }); test('renders API request completion lines', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, component: 'api', msg: 'request completed', account: 'acc1', req: { method: 'get', url: '/v1/messages' }, res: { statusCode: 200 }, responseTime: 42 }) ); assert.match(stdout, new RegExp(`\\[${FIXED_STR}\\] API \\[acc1\\]: GET /v1/messages 200 \\(42ms\\)`)); }); test('renders successful OAuth2 token renewal with expires and scopes', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, action: 'renewAccessToken', account: 'acc1', msg: 'Renewed access token', expires: '2025-01-01T00:00:00Z', scopes: ['scope.one', 'scope.two'] }) ); assert.match(stdout, /OAuth2 \[acc1\]: Renewed access token/); assert.match(stdout, /Expires on 2025-01-01T00:00:00Z/); assert.match(stdout, /Provisioned scopes:/); assert.match(stdout, /- scope\.one/); assert.match(stdout, /- scope\.two/); }); test('renders failed OAuth2 token renewal with flag and response error', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, action: 'renewAccessToken', account: 'acc1', msg: 'Token renewal failed', error: true, flag: { message: 'invalid_grant' }, response: { error: 'invalid_grant', error_description: 'token expired' } }) ); assert.match(stdout, /OAuth2 \[acc1\]: Token renewal failed: invalid_grant/); assert.match(stdout, /error: invalid_grant/); assert.match(stdout, /error_description: token expired/); }); test('renders IMAP connection events with TLS info and connection header', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, src: 'connection', cid: 'conn-1', account: 'acc1', host: 'imap.example.com', port: 993, secure: true, version: 'TLSv1.3', algo: 'TLS_AES_256_GCM_SHA384', authorized: true }) ); assert.match(stdout, /^conn-1 \[acc1\]/m); assert.match(stdout, /Connection established to imap\.example\.com:993 \(TLSv1\.3 \/ TLS_AES_256_GCM_SHA384, valid certificate\)/); }); test('renders plain (non-TLS) IMAP connection events', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, src: 'connection', cid: 'conn-plain', host: 'imap.example.com', port: 143, secure: false }) ); assert.match(stdout, /Connection established to imap\.example\.com:143 \(no tls\)/); }); test('renders TLS session establishment lines', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, src: 'tls', cid: 'conn-2', account: 'acc1', version: 'TLSv1.3', algo: 'TLS_AES_256_GCM_SHA384', authorized: false }) ); assert.match(stdout, /^conn-2 \[acc1\]/m); assert.match(stdout, /TLS Session established using TLSv1\.3 \/ TLS_AES_256_GCM_SHA384, invalid certificate/); }); test('decodes base64 raw socket data and emits a connection header', async () => { const payload = 'A1 LOGIN user pass\r\n'; const { stdout } = await runCli( line({ time: FIXED_MS, src: 'c', cid: 'conn-3', account: 'acc1', secure: true, data: Buffer.from(payload).toString('base64') }) ); assert.match(stdout, /^conn-3 \[acc1\]/m); assert.match(stdout, /C: \[S ] A1 LOGIN user pass/); }); test('groups multiple raw lines under a single connection header per cid', async () => { const input = line({ time: FIXED_MS, src: 'c', cid: 'conn-A', data: Buffer.from('A1 NOOP\r\n').toString('base64') }) + line({ time: FIXED_MS, src: 's', cid: 'conn-A', data: Buffer.from('A1 OK\r\n').toString('base64') }) + line({ time: FIXED_MS, src: 'c', cid: 'conn-B', data: Buffer.from('B1 NOOP\r\n').toString('base64') }); const { stdout } = await runCli(input); const headerCount = (stdout.match(/^conn-[AB]$/gm) || []).length; // conn-A header once, conn-B header once — three raw lines total. assert.equal(headerCount, 2); assert.match(stdout, /A1 NOOP/); assert.match(stdout, /A1 OK/); assert.match(stdout, /B1 NOOP/); }); test('neutralizes terminal escape sequences embedded in decoded raw socket data', async () => { // A malicious IMAP server could embed an OSC 52 clipboard-write escape (ESC ] 52 ... BEL) // in its raw bytes; base64-decoded, these must not reach the operator's terminal verbatim. const payload = 'A1 OK\x1b]52;c;ZXZpbA\x07 done\r\n'; const { stdout } = await runCli( line({ time: FIXED_MS, src: 's', cid: 'conn-esc', data: Buffer.from(payload).toString('base64') }) ); // Control bytes are rendered as inert caret notation (ESC -> ^[, BEL -> ^G), not emitted raw. assert.match(stdout, /A1 OK\^\[\]52;c;ZXZpbA\^G done/); assert.doesNotMatch(stdout, /\x1b\]52/); }); test('--no-time hides timestamp prefixes', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, src: 'c', cid: 'conn-nt', secure: true, data: Buffer.from('A1 LIST "" "*"\r\n').toString('base64') }), ['--no-time'] ); assert.doesNotMatch(stdout, /\[2024-01-15/); assert.match(stdout, /^C: \[S ] A1 LIST "" "\*"/m); // the connection header is unaffected assert.match(stdout, /^conn-nt$/m); }); test('--no-cid hides connection header lines', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, src: 'c', cid: 'conn-nc', secure: true, data: Buffer.from('A1 NOOP\r\n').toString('base64') }), ['--no-cid'] ); assert.doesNotMatch(stdout, /^conn-nc$/m); // the timestamped raw line is unaffected assert.match(stdout, new RegExp(`\\[${FIXED_STR}\\] C: \\[S ] A1 NOOP`)); }); test('filter.account drops non-matching entries (AND-across-keys, OR-within-key)', async () => { const input = line({ time: FIXED_MS, component: 'api', msg: 'r', account: 'acc1', req: { method: 'get', url: '/v1/x' }, res: { statusCode: 200 }, responseTime: 1 }) + line({ time: FIXED_MS, component: 'api', msg: 'r', account: 'acc2', req: { method: 'get', url: '/v1/y' }, res: { statusCode: 200 }, responseTime: 1 }); const { stdout } = await runCli(input, ['--filter.account=acc1']); assert.match(stdout, /\/v1\/x/); assert.doesNotMatch(stdout, /\/v1\/y/); }); test('filter.account back-fills account from API req.url path segment', async () => { const { stdout } = await runCli( line({ time: FIXED_MS, component: 'api', msg: 'r', // intentionally no `account` field — should still match via URL req: { method: 'get', url: '/v1/account/myacc/messages' }, res: { statusCode: 200 }, responseTime: 7 }), ['--filter.account=myacc'] ); assert.match(stdout, /API \[myacc\]: GET \/v1\/account\/myacc\/messages 200 \(7ms\)/); }); test('filter.account back-fill works when multiple --filter.account values are passed', async () => { const input = line({ time: FIXED_MS, component: 'api', msg: 'r', req: { method: 'get', url: '/v1/account/alpha/messages' }, res: { statusCode: 200 }, responseTime: 1 }) + line({ time: FIXED_MS, component: 'api', msg: 'r', req: { method: 'get', url: '/v1/account/beta/messages' }, res: { statusCode: 200 }, responseTime: 2 }) + line({ time: FIXED_MS, component: 'api', msg: 'r', req: { method: 'get', url: '/v1/account/gamma/messages' }, res: { statusCode: 200 }, responseTime: 3 }); const { stdout } = await runCli(input, ['--filter.account=alpha', '--filter.account=beta']); assert.match(stdout, /API \[alpha\]: GET \/v1\/account\/alpha\/messages/); assert.match(stdout, /API \[beta\]: GET \/v1\/account\/beta\/messages/); assert.doesNotMatch(stdout, /gamma/); });