207 lines
9.7 KiB
JavaScript
207 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* DC-120: perimeter aggregation endpoint tests.
|
|
*
|
|
* GET /api/v1/security/events/perimeter — caddy-source perimeter
|
|
* aggregation (per-IP + per-vhost breakdowns) for the Log Insights panel.
|
|
*
|
|
* Fixture shape mirrors the live caddy-source event schema:
|
|
* {source_type: 'caddy', actor: '<ip>', target: 'GET /',
|
|
* action: 'http.200', outcome: 'success'|'denied'|'error',
|
|
* metadata: {host: 'req.sami-flix.com', status, user_agent, ...}}
|
|
*
|
|
* What these tests pin:
|
|
* 1. Aggregation correctness — counts, denied/error splits, host sets.
|
|
* 2. Window filtering — only events inside ?hours are counted.
|
|
* 3. Input clamping — hours out of [1,720] falls back to 24; limit out
|
|
* of [1,50] falls back to 15. No 500s, no crashes.
|
|
* 4. Ordering — count desc, tie-break by IP asc (deterministic output).
|
|
* 5. Empty store — valid zero-response, not an error.
|
|
* 6. NON-caddy events (api-source) are EXCLUDED — the perimeter view
|
|
* must only reflect reverse-proxy traffic, not dashboard activity.
|
|
* 7. filterEvents()/query() filter parity — the new store primitive
|
|
* applies the same predicates as the paged API (no drift).
|
|
*/
|
|
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
const { SecurityEventStore } = require('../../src/security/event-store');
|
|
|
|
function tmpdir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc120-perimeter-'));
|
|
}
|
|
|
|
// Drive requests through real http so we exercise the full stack.
|
|
function listen(app) {
|
|
return new Promise((resolve) => {
|
|
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
|
});
|
|
}
|
|
|
|
function get(server, path) {
|
|
return new Promise((resolve, reject) => {
|
|
http.get({ host: server.address().address, port: server.address().port, path }, (res) => {
|
|
let body = '';
|
|
res.on('data', (c) => (body += c));
|
|
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
describe('DC-120 GET /api/v1/security/events/perimeter', () => {
|
|
let dir;
|
|
let server;
|
|
|
|
beforeAll(async () => {
|
|
dir = tmpdir();
|
|
// Seed the SINGLETON (routes/security.js factory calls getStore()
|
|
// internally and getStore memoizes) — so the router reads our fixtures
|
|
// from memory with zero disk-timing races. Jest isolates module
|
|
// registries per test file, so this doesn't leak to other suites.
|
|
const { getStore } = require('../../src/security/event-store');
|
|
const store = getStore({ filePath: path.join(dir, 'security-events.jsonl'), log: console });
|
|
|
|
// Fixture set (all 10 minutes old unless noted):
|
|
// 1.1.1.1 — 3 requests, 1 denied, hosts {a.example, b.example} (TOP by count)
|
|
// 9.9.9.9 — 2 requests, 2 errors, host {c.example}
|
|
// 8.8.8.8 — 2 requests, all success, host {a.example} (tie with 9.9.9.9 → IP asc wins)
|
|
// api-source event — MUST be excluded
|
|
// old caddy event (47h ago) — excluded by the 24h window, included by 48h
|
|
// (47h not 48h: a same-instant fixture vs route `since` races the
|
|
// inclusive boundary — keep it unambiguous on both sides)
|
|
const now = Date.now();
|
|
const T = (minAgo) => new Date(now - minAgo * 60000).toISOString();
|
|
[
|
|
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /wp-login.php', action: 'http.401', outcome: 'denied', severity: 'warn', metadata: { host: 'b.example', status: 401 } },
|
|
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /x', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /y', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
|
|
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /z', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
|
|
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /health', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
|
|
{ source_type: 'api', actor: '127.0.0.1', target: 'GET /api/v1/services', action: 'services.list', outcome: 'success', severity: 'info', metadata: { host: 'status.sami' } },
|
|
{ ts: T(47 * 60), source_type: 'caddy', actor: '5.5.5.5', target: 'GET /old', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'old.example', status: 200 } },
|
|
].forEach((partial) => {
|
|
store.append(Object.assign({ source_host: 'testhost', ts: T(10) }, partial));
|
|
});
|
|
|
|
const app = express().use('/api/v1/security', require('../../routes/security')({ log: console }));
|
|
server = await listen(app);
|
|
});
|
|
|
|
afterAll((done) => {
|
|
server.close(done);
|
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('aggregates per-IP counts, denied/error splits, host sets; excludes api-source + old events', async () => {
|
|
const res = await get(server, '/api/v1/security/events/perimeter?hours=24');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const { summary, topIPs, byHost } = res.body;
|
|
// 8 in-window events minus the api-source one = 7 caddy events
|
|
expect(summary.events).toBe(7);
|
|
expect(summary.uniqueIPs).toBe(3);
|
|
expect(summary.denied).toBe(1);
|
|
expect(summary.error).toBe(2);
|
|
|
|
// Ordering: count desc, tie-break IP asc → 1.1.1.1 (3), 8.8.8.8 (2), 9.9.9.9 (2)
|
|
expect(topIPs.map((t) => t.ip)).toEqual(['1.1.1.1', '8.8.8.8', '9.9.9.9']);
|
|
const top = topIPs[0];
|
|
expect(top.count).toBe(3);
|
|
expect(top.denied).toBe(1);
|
|
expect(top.error).toBe(0);
|
|
expect(top.hosts).toEqual(['a.example', 'b.example']);
|
|
|
|
const nine = topIPs[2];
|
|
expect(nine.error).toBe(2);
|
|
|
|
// byHost: a.example=4, c.example=2, b.example=1
|
|
const hostByName = Object.fromEntries(byHost.map((h) => [h.host, h]));
|
|
expect(hostByName['a.example'].count).toBe(4);
|
|
expect(hostByName['c.example'].count).toBe(2);
|
|
expect(hostByName['c.example'].error).toBe(2);
|
|
expect(hostByName['b.example'].count).toBe(1);
|
|
expect(hostByName['b.example'].denied).toBe(1);
|
|
// old.example (48h) and status.sami (api-source) absent
|
|
expect(hostByName['old.example']).toBeUndefined();
|
|
expect(hostByName['status.sami']).toBeUndefined();
|
|
});
|
|
|
|
test('clamps invalid hours/limit instead of erroring', async () => {
|
|
const res = await get(server, '/api/v1/security/events/perimeter?hours=-5&limit=9999');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.window.hours).toBe(24);
|
|
expect(res.body.topIPs.length).toBeLessThanOrEqual(15);
|
|
});
|
|
|
|
test('hours window filters correctly (48h includes the old event)', async () => {
|
|
const res = await get(server, '/api/v1/security/events/perimeter?hours=48');
|
|
expect(res.status).toBe(200);
|
|
// 7 in-window + 1 old caddy event = 8 (api-source still excluded)
|
|
expect(res.body.summary.events).toBe(8);
|
|
expect(res.body.summary.uniqueIPs).toBe(4);
|
|
});
|
|
|
|
test('empty store returns valid zero-response', async () => {
|
|
// Fresh jest module registry → fresh getStore() memo → empty store.
|
|
jest.resetModules();
|
|
const dir2 = tmpdir();
|
|
process.env.SECURITY_EVENT_LOG_FILE = path.join(dir2, 'empty.jsonl');
|
|
const securityRoutesFresh = require('../../routes/security');
|
|
const app = express().use('/api/v1/security', securityRoutesFresh({ log: console }));
|
|
const server2 = await listen(app);
|
|
try {
|
|
const res = await get(server2, '/api/v1/security/events/perimeter');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.summary.events).toBe(0);
|
|
expect(res.body.summary.uniqueIPs).toBe(0);
|
|
expect(res.body.topIPs).toEqual([]);
|
|
expect(res.body.byHost).toEqual([]);
|
|
} finally {
|
|
server2.close();
|
|
fs.rmSync(dir2, { recursive: true, force: true });
|
|
delete process.env.SECURITY_EVENT_LOG_FILE;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('DC-120 event-store filterEvents()/query() parity', () => {
|
|
test('filterEvents returns exactly what query() totals (same predicate)', () => {
|
|
const dir = tmpdir();
|
|
const store = new SecurityEventStore({ filePath: path.join(dir, 's.jsonl'), log: console });
|
|
const now = Date.now();
|
|
for (let i = 0; i < 30; i++) {
|
|
store.append({
|
|
source_type: i % 2 ? 'caddy' : 'api',
|
|
actor: `10.0.0.${i % 5}`,
|
|
target: 'GET /',
|
|
action: `http.${200 + (i % 3) * 100}`,
|
|
outcome: i % 7 === 0 ? 'denied' : 'success',
|
|
severity: i % 7 === 0 ? 'warn' : 'info',
|
|
ts: new Date(now - (i % 10) * 60000).toISOString(),
|
|
});
|
|
}
|
|
const since = new Date(now - 15 * 60000).toISOString();
|
|
const q = { source_type: 'caddy', since };
|
|
const filtered = store.filterEvents(q);
|
|
const paged = store.query(Object.assign({ limit: 1000 }, q));
|
|
expect(filtered.length).toBe(paged.total);
|
|
// newest-first order preserved by both
|
|
expect(filtered.map((e) => e.id)).toEqual(paged.events.map((e) => e.id));
|
|
|
|
// Multi-value filter parity (comma string form)
|
|
const q2 = { source_type: 'caddy', outcome: 'denied,error', since };
|
|
expect(store.filterEvents(q2).length).toBe(store.query(Object.assign({ limit: 1000 }, q2)).total);
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|