import { describe, it, expect, afterAll } from 'vitest' import { buildApp } from '../app.js' import type { AppConfig } from '../config.js' const testConfig: AppConfig = { databaseUrl: 'sqlite::memory:', jwtSecret: 'test', jwtTtlHours: 24, serverPort: 8080, staticDir: null, logLevel: 'error', authRequired: false, authIssuer: 'https://auth.test', authPortalUrl: 'http://localhost:5175', publicBaseUrl: 'https://fw.example.com', enrollSeed: 'test-seed', corsOrigins: [], authAuditIngestSecret: null, secretKey: null, statsRetentionDays: 30, } async function enrollApprovedLinux( app: Awaited>, name: string, token: string, ) { const created = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name, platform: 'linux' }, }) expect(created.statusCode).toBe(201) const link = created.json() as { id: string; agent_id: string } const enroll = await app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'content-type': 'application/json', 'x-evofw-seed': 'test-seed', }, payload: { name, platform: 'linux', token, install_link_id: link.id, }, }) expect(enroll.statusCode).toBe(201) await app.inject({ method: 'POST', url: `/api/v1/agents/${link.agent_id}/approve`, }) return { agentId: link.agent_id, token } } describe('apply-report ip_hits / blocked-ips', () => { const appPromise = buildApp({ memory: true, config: testConfig }) afterAll(async () => { const app = await appPromise await app.close() }) it('upserts ip_hits with delta accumulation and clears on reset', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'ip-hits-01', 'evofw_ip_hits_token_abcdefghij', ) const report1 = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', prefix_count: 2, packets_dropped: 15, packets_accepted: 1, kernel_method: 'nft', source: 'agent', ip_hits: [ { ip: '203.0.113.10', packets: 10 }, { ip: '198.51.100.0/24', packets: 5 }, ], }, }) expect(report1.statusCode).toBe(200) const list1 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ips`, }) expect(list1.statusCode).toBe(200) const body1 = list1.json() as { items: { ip: string; packets: number; last_seen_at: string }[] } expect(body1.items).toHaveLength(2) expect(body1.items[0]?.ip).toBe('203.0.113.10') expect(body1.items[0]?.packets).toBe(10) expect(body1.items[1]?.ip).toBe('198.51.100.0/24') expect(body1.items[1]?.packets).toBe(5) const report2 = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', prefix_count: 2, packets_dropped: 25, packets_accepted: 1, kernel_method: 'nft', source: 'agent', ip_hits: [ { ip: '203.0.113.10', packets: 18 }, { ip: '198.51.100.0/24', packets: 5 }, ], }, }) expect(report2.statusCode).toBe(200) const list2 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ips`, }) const body2 = list2.json() as { items: { ip: string; packets: number }[] } // 10 + (18-10) = 18; /24 unchanged (delta 0) stays 5 expect(body2.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(18) expect(body2.items.find((i) => i.ip === '198.51.100.0/24')?.packets).toBe(5) // Simulate nft flush: Traffic absolute drops; ip_hits omit zeros → baselines must reset const reportReset = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', prefix_count: 2, packets_dropped: 0, packets_accepted: 0, kernel_method: 'nft', source: 'agent', ip_hits: [], }, }) expect(reportReset.statusCode).toBe(200) const report3 = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', prefix_count: 2, packets_dropped: 7, packets_accepted: 0, kernel_method: 'nft', source: 'agent', ip_hits: [ { ip: '203.0.113.10', packets: 4 }, { ip: '198.51.100.0/24', packets: 3 }, ], }, }) expect(report3.statusCode).toBe(200) const listAfterFlush = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ips`, }) const bodyFlush = listAfterFlush.json() as { items: { ip: string; packets: number }[] } // First epoch 18+5 plus second epoch 4+3 expect(bodyFlush.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe( 22, ) expect( bodyFlush.items.find((i) => i.ip === '198.51.100.0/24')?.packets, ).toBe(8) const agentAfter = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}`, }) const agentBody = agentAfter.json() as { total_packets_dropped?: number } // Traffic: 25 + 0 + 7 = 32 expect(agentBody.total_packets_dropped).toBe(32) const reset = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/stats/reset`, }) expect(reset.statusCode).toBe(200) const list3 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ips`, }) expect( (list3.json() as { items: unknown[] }).items, ).toEqual([]) }) it('rejects ip_hits longer than 200', async () => { const app = await appPromise await app.ready() const { token } = await enrollApprovedLinux( app, 'ip-hits-max', 'evofw_ip_hits_max_token_abcdef', ) const hits = Array.from({ length: 201 }, (_, i) => ({ ip: `203.0.113.${(i % 254) + 1}`, packets: 1, })) const report = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', packets_dropped: 201, ip_hits: hits, }, }) expect(report.statusCode).toBeGreaterThanOrEqual(400) }) it('mikrotik presence: continuous sync refreshes last_seen without +1; rehit after stale gap', async () => { const app = await appPromise await app.ready() const created = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name: 'mt-hits', platform: 'mikrotik' }, }) const link = created.json() as { id: string; agent_id: string } const token = 'evofw_mt_hits_token_abcdefghijk' await app.inject({ method: 'POST', url: '/v1/agent/enroll', headers: { 'content-type': 'application/json', 'x-evofw-seed': 'test-seed', }, payload: { name: 'mt-hits', platform: 'mikrotik', token, install_link_id: link.id, }, }) await app.inject({ method: 'POST', url: `/api/v1/agents/${link.agent_id}/approve`, }) const reportPayload = (dropped: number) => ({ status: 'ok', packets_dropped: dropped, kernel_method: 'address-list', source: 'mikrotik', ip_hits: [{ ip: '203.0.113.50', packets: 1 }], }) expect( ( await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: reportPayload(3), }) ).statusCode, ).toBe(200) const list1 = await app.inject({ method: 'GET', url: `/api/v1/agents/${link.agent_id}/blocked-ips`, }) const body1 = list1.json() as { items: { ip: string; packets: number; last_seen_at: string }[] } expect(body1.items).toHaveLength(1) expect(body1.items[0]?.packets).toBe(1) const firstSeen = body1.items[0]!.last_seen_at await new Promise((r) => setTimeout(r, 5)) expect( ( await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: reportPayload(5), }) ).statusCode, ).toBe(200) const list2 = await app.inject({ method: 'GET', url: `/api/v1/agents/${link.agent_id}/blocked-ips`, }) const body2 = list2.json() as { items: { ip: string; packets: number; last_seen_at: string }[] } expect(body2.items[0]?.packets).toBe(1) expect(body2.items[0]!.last_seen_at >= firstSeen).toBe(true) // Re-entry after stale gap (> PRESENCE_REHIT_STALE_MS): simulate via future `now` const { repos } = await import('@evofw/db') repos.upsertIpBlockStats( app.db, link.agent_id, [{ ip: '203.0.113.50', packets: 1 }], new Date(Date.now() + 200_000).toISOString(), { mode: 'presence' }, ) const list3 = await app.inject({ method: 'GET', url: `/api/v1/agents/${link.agent_id}/blocked-ips`, }) expect( (list3.json() as { items: { packets: number }[] }).items[0]?.packets, ).toBe(2) }) }) describe('apply-report port_hits / blocked-ports', () => { const appPromise = buildApp({ memory: true, config: testConfig }) afterAll(async () => { const app = await appPromise await app.close() }) it('upserts port_hits with delta accumulation, aggregate, per-IP ports, reset', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'port-hits-01', 'evofw_port_hits_token_abcdefg', ) const report1 = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', prefix_count: 1, packets_dropped: 30, packets_accepted: 0, kernel_method: 'nft', source: 'agent', ip_hits: [{ ip: '203.0.113.10', packets: 20 }], port_hits: [ { ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 12 }, { ip: '203.0.113.10', port: 53, protocol: 'udp', packets: 8 }, { ip: '198.51.100.7', port: 22, protocol: 'tcp', packets: 5 }, ], }, }) expect(report1.statusCode).toBe(200) const ports1 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ports`, }) expect(ports1.statusCode).toBe(200) const agg1 = ports1.json() as { items: { port: number protocol: string packets: number last_seen_at: string }[] } expect(agg1.items[0]?.port).toBe(22) expect(agg1.items[0]?.protocol).toBe('tcp') expect(agg1.items[0]?.packets).toBe(17) // 12+5 expect(agg1.items.find((i) => i.port === 53)?.packets).toBe(8) const ips1 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ips`, }) const ipBody = ips1.json() as { items: { ip: string ports?: { port: number; protocol: string; packets: number }[] }[] } const row10 = ipBody.items.find((i) => i.ip === '203.0.113.10') expect(row10?.ports?.map((p) => `${p.protocol}/${p.port}`)).toEqual([ 'tcp/22', 'udp/53', ]) const report2 = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', packets_dropped: 40, kernel_method: 'nft', source: 'agent', port_hits: [ { ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 15 }, ], }, }) expect(report2.statusCode).toBe(200) const ports2 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ports`, }) const agg2 = ports2.json() as { items: { port: number; protocol: string; packets: number }[] } // tcp/22: 17 + (15-12) = 20 expect( agg2.items.find((i) => i.port === 22 && i.protocol === 'tcp')?.packets, ).toBe(20) const reset = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/stats/reset`, }) expect(reset.statusCode).toBe(200) const ports3 = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/blocked-ports`, }) expect((ports3.json() as { items: unknown[] }).items).toEqual([]) }) it('rejects port_hits longer than 500', async () => { const app = await appPromise await app.ready() const { token } = await enrollApprovedLinux( app, 'port-hits-max', 'evofw_port_hits_max_token_abc', ) const hits = Array.from({ length: 501 }, (_, i) => ({ ip: `203.0.113.${(i % 254) + 1}`, port: (i % 65535) + 1, protocol: i % 2 === 0 ? 'tcp' : 'udp', packets: 1, })) const report = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', packets_dropped: 501, port_hits: hits, }, }) expect(report.statusCode).toBeGreaterThanOrEqual(400) }) })