diff --git a/tests/integration/analytics-blocked.test.ts b/tests/integration/analytics-blocked.test.ts new file mode 100644 index 00000000..3e646c28 --- /dev/null +++ b/tests/integration/analytics-blocked.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createTestDb, type TestDb } from '../helpers/db'; +import { trafficEvents } from '@/src/lib/db/schema'; +import { sql, and, gte, lte, eq, inArray } from 'drizzle-orm'; + +// Mock dependencies so we can import collectBlockedSignatures and parseLine. +// These run in the log-parser module scope on import. +vi.mock('@/src/lib/db', () => ({ + default: { + select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(null) }) }) }), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ onConflictDoUpdate: vi.fn().mockReturnValue({ run: vi.fn() }) }) }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ run: vi.fn() }) }), + run: vi.fn(), + }, +})); +vi.mock('maxmind', () => ({ default: { open: vi.fn().mockResolvedValue(null) } })); +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), + statSync: vi.fn().mockReturnValue({ size: 0 }), + createReadStream: vi.fn(), +})); + +import { collectBlockedSignatures, parseLine } from '@/src/lib/log-parser'; + +let db: TestDb; + +const NOW = Math.floor(Date.now() / 1000); + +beforeEach(() => { + db = createTestDb(); +}); + +/** Insert a traffic event row with sensible defaults. */ +function insertEvent(overrides: Partial = {}) { + db.insert(trafficEvents).values({ + ts: NOW, + clientIp: '1.2.3.4', + countryCode: 'DE', + host: 'example.com', + method: 'GET', + uri: '/', + status: 200, + proto: 'HTTP/2.0', + bytesSent: 1024, + userAgent: 'Mozilla/5.0', + isBlocked: false, + ...overrides, + }).run(); +} + +// ── Helpers that mirror analytics-db.ts queries ───────────────────────────── +// We duplicate the SQL here intentionally — if the production queries ever +// drift from what the schema supports, these tests will catch it. + +function buildWhere(from: number, to: number, hosts: string[]) { + const conditions = [gte(trafficEvents.ts, from), lte(trafficEvents.ts, to)]; + if (hosts.length === 1) { + conditions.push(eq(trafficEvents.host, hosts[0])); + } else if (hosts.length > 1) { + conditions.push(inArray(trafficEvents.host, hosts)); + } + return and(...conditions); +} + +function getSummary(from: number, to: number, hosts: string[] = []) { + const where = buildWhere(from, to, hosts); + return db + .select({ + total: sql`count(*)`, + uniqueIps: sql`count(distinct ${trafficEvents.clientIp})`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + bytes: sql`sum(${trafficEvents.bytesSent})`, + }) + .from(trafficEvents) + .where(where) + .get(); +} + +function getCountries(from: number, to: number, hosts: string[] = []) { + const where = buildWhere(from, to, hosts); + return db + .select({ + countryCode: trafficEvents.countryCode, + total: sql`count(*)`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(trafficEvents.countryCode) + .orderBy(sql`count(*) desc`) + .all(); +} + +function getTimeline(from: number, to: number, bucketSize: number, hosts: string[] = []) { + const where = buildWhere(from, to, hosts); + return db + .select({ + bucket: sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`, + total: sql`count(*)`, + blocked: sql`sum(case when ${trafficEvents.isBlocked} then 1 else 0 end)`, + }) + .from(trafficEvents) + .where(where) + .groupBy(sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`) + .orderBy(sql`(${trafficEvents.ts} / ${sql.raw(String(bucketSize))})`) + .all(); +} + +function getBlockedEvents(from: number, to: number, hosts: string[] = []) { + const where = and(buildWhere(from, to, hosts), eq(trafficEvents.isBlocked, true)); + return db + .select({ + id: trafficEvents.id, + ts: trafficEvents.ts, + clientIp: trafficEvents.clientIp, + countryCode: trafficEvents.countryCode, + host: trafficEvents.host, + status: trafficEvents.status, + }) + .from(trafficEvents) + .where(where) + .orderBy(sql`${trafficEvents.ts} desc`) + .all(); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('analytics blocked counting', () => { + const from = NOW - 3600; + const to = NOW + 3600; + + describe('summary', () => { + it('counts zero blocked when no events are blocked', () => { + insertEvent({ isBlocked: false }); + insertEvent({ isBlocked: false }); + const row = getSummary(from, to); + expect(row!.total).toBe(2); + expect(row!.blocked).toBe(0); + }); + + it('counts geo-blocked requests correctly', () => { + insertEvent({ isBlocked: true, status: 403, clientIp: '5.6.7.8', countryCode: 'CN' }); + insertEvent({ isBlocked: true, status: 403, clientIp: '9.10.11.12', countryCode: 'RU' }); + insertEvent({ isBlocked: false, status: 200 }); + const row = getSummary(from, to); + expect(row!.total).toBe(3); + expect(row!.blocked).toBe(2); + }); + + it('filters by host', () => { + insertEvent({ isBlocked: true, host: 'blocked.com' }); + insertEvent({ isBlocked: false, host: 'blocked.com' }); + insertEvent({ isBlocked: true, host: 'other.com' }); + const row = getSummary(from, to, ['blocked.com']); + expect(row!.total).toBe(2); + expect(row!.blocked).toBe(1); + }); + }); + + describe('countries', () => { + it('shows blocked count per country', () => { + insertEvent({ isBlocked: true, countryCode: 'CN' }); + insertEvent({ isBlocked: true, countryCode: 'CN' }); + insertEvent({ isBlocked: false, countryCode: 'CN' }); + insertEvent({ isBlocked: true, countryCode: 'RU' }); + insertEvent({ isBlocked: false, countryCode: 'US' }); + + const rows = getCountries(from, to); + const cn = rows.find(r => r.countryCode === 'CN'); + const ru = rows.find(r => r.countryCode === 'RU'); + const us = rows.find(r => r.countryCode === 'US'); + + expect(cn!.total).toBe(3); + expect(cn!.blocked).toBe(2); + expect(ru!.total).toBe(1); + expect(ru!.blocked).toBe(1); + expect(us!.total).toBe(1); + expect(us!.blocked).toBe(0); + }); + }); + + describe('timeline', () => { + it('shows blocked count per time bucket', () => { + const bucketSize = 3600; + const bucket1Ts = NOW; + const bucket2Ts = NOW + 3600; + + insertEvent({ ts: bucket1Ts, isBlocked: true }); + insertEvent({ ts: bucket1Ts, isBlocked: true }); + insertEvent({ ts: bucket1Ts, isBlocked: false }); + insertEvent({ ts: bucket2Ts, isBlocked: true }); + insertEvent({ ts: bucket2Ts, isBlocked: false }); + insertEvent({ ts: bucket2Ts, isBlocked: false }); + + const rows = getTimeline(from, to + 7200, bucketSize); + expect(rows.length).toBe(2); + + const b1 = rows[0]; + expect(b1.total).toBe(3); + expect(b1.blocked).toBe(2); + + const b2 = rows[1]; + expect(b2.total).toBe(3); + expect(b2.blocked).toBe(1); + }); + }); + + describe('blocked events list', () => { + it('returns only blocked events', () => { + insertEvent({ isBlocked: true, clientIp: '5.6.7.8', countryCode: 'CN', status: 403 }); + insertEvent({ isBlocked: false, clientIp: '1.2.3.4', countryCode: 'US', status: 200 }); + insertEvent({ isBlocked: true, clientIp: '9.10.11.12', countryCode: 'RU', status: 403 }); + + const rows = getBlockedEvents(from, to); + expect(rows.length).toBe(2); + expect(rows.every(r => r.status === 403)).toBe(true); + const ips = rows.map(r => r.clientIp).sort(); + expect(ips).toEqual(['5.6.7.8', '9.10.11.12']); + }); + + it('returns empty list when nothing is blocked', () => { + insertEvent({ isBlocked: false }); + insertEvent({ isBlocked: false }); + + const rows = getBlockedEvents(from, to); + expect(rows.length).toBe(0); + }); + + it('filters blocked events by host', () => { + insertEvent({ isBlocked: true, host: 'target.com' }); + insertEvent({ isBlocked: true, host: 'other.com' }); + + const rows = getBlockedEvents(from, to, ['target.com']); + expect(rows.length).toBe(1); + expect(rows[0].host).toBe('target.com'); + }); + }); + + describe('full pipeline: raw log lines → parseLine → DB → analytics queries', () => { + it('geo-blocked request flows through the entire pipeline', () => { + const ts = NOW; + + // Simulate the two log entries that Caddy writes to access.log for a + // geo-blocked request: the blocker's "request blocked" entry followed + // by the standard "handled request" entry. + const blockedLogLine = JSON.stringify({ + ts: ts + 0.01, + msg: 'request blocked', + plugin: 'caddy-blocker', + client_ip: '203.0.113.5', + method: 'GET', + uri: '/secret', + }); + const handledBlockedLine = JSON.stringify({ + ts: ts + 0.99, + msg: 'handled request', + status: 403, + size: 9, + request: { + client_ip: '203.0.113.5', + host: 'secure.example.com', + method: 'GET', + uri: '/secret', + proto: 'HTTP/2.0', + headers: { 'User-Agent': ['BlockedBot/1.0'] }, + }, + }); + + // A normal allowed request in the same log batch. + const allowedLine = JSON.stringify({ + ts: ts + 1.5, + msg: 'handled request', + status: 200, + size: 4096, + request: { + client_ip: '198.51.100.1', + host: 'secure.example.com', + method: 'GET', + uri: '/', + proto: 'HTTP/2.0', + headers: { 'User-Agent': ['GoodBot/2.0'] }, + }, + }); + + // Step 1: collectBlockedSignatures builds the blocked set from all lines + const lines = [blockedLogLine, handledBlockedLine, allowedLine]; + const blockedSet = collectBlockedSignatures(lines); + expect(blockedSet.size).toBe(1); + + // Step 2: parseLine processes each "handled request" line + const blockedRow = parseLine(handledBlockedLine, blockedSet); + const allowedRow = parseLine(allowedLine, blockedSet); + expect(blockedRow).not.toBeNull(); + expect(allowedRow).not.toBeNull(); + expect(blockedRow!.isBlocked).toBe(true); + expect(allowedRow!.isBlocked).toBe(false); + + // Step 3: Insert into DB (as the real log parser does) + db.insert(trafficEvents).values(blockedRow!).run(); + db.insert(trafficEvents).values(allowedRow!).run(); + + // Step 4: Verify all analytics queries reflect the blocked request + + // Summary + const summary = getSummary(from, to); + expect(summary!.total).toBe(2); + expect(summary!.blocked).toBe(1); + + // Countries (GeoIP is mocked so countryCode is null → grouped together) + const countries = getCountries(from, to); + const group = countries[0]; + expect(group.total).toBe(2); + expect(group.blocked).toBe(1); + + // Timeline + const timeline = getTimeline(from, to, 3600); + expect(timeline.length).toBe(1); + expect(timeline[0].total).toBe(2); + expect(timeline[0].blocked).toBe(1); + + // Blocked events list + const blocked = getBlockedEvents(from, to); + expect(blocked.length).toBe(1); + expect(blocked[0].clientIp).toBe('203.0.113.5'); + expect(blocked[0].status).toBe(403); + + // Filtered by host + const filteredSummary = getSummary(from, to, ['secure.example.com']); + expect(filteredSummary!.blocked).toBe(1); + const wrongHost = getSummary(from, to, ['other.com']); + expect(wrongHost!.total).toBe(0); + }); + + it('non-blocked request does not appear in blocked stats', () => { + const ts = NOW; + + // Only a normal "handled request" — no "request blocked" entry + const normalLine = JSON.stringify({ + ts: ts + 0.5, + msg: 'handled request', + status: 200, + size: 2048, + request: { + client_ip: '198.51.100.1', + host: 'open.example.com', + method: 'GET', + uri: '/public', + proto: 'HTTP/2.0', + }, + }); + + const lines = [normalLine]; + const blockedSet = collectBlockedSignatures(lines); + expect(blockedSet.size).toBe(0); + + const row = parseLine(normalLine, blockedSet); + expect(row!.isBlocked).toBe(false); + + db.insert(trafficEvents).values(row!).run(); + + const summary = getSummary(from, to); + expect(summary!.total).toBe(1); + expect(summary!.blocked).toBe(0); + + const blocked = getBlockedEvents(from, to); + expect(blocked.length).toBe(0); + }); + }); +});