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('port ACL + host firewall snapshot', () => { const appPromise = buildApp({ memory: true, config: testConfig }) afterAll(async () => { const app = await appPromise await app.close() }) it('CRUD port-rules bumps generation and expands in policy', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'port-acl-01', 'evofw_port_acl_token_abcdefghij', ) const before = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}`, }) const genBefore = (before.json() as { policy_generation: number }) .policy_generation const create = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/port-rules`, payload: { action: 'open', protocol: 'tcp', port_start: 443, src_kind: 'cidr', src_cidr: '10.0.0.0/8', }, }) expect(create.statusCode).toBe(200) const rule = create.json() as { id: string; action: string; port_start: number } expect(rule.action).toBe('open') expect(rule.port_start).toBe(443) const after = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}`, }) expect( (after.json() as { policy_generation: number }).policy_generation, ).toBe(genBefore + 1) const list = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/port-rules`, }) expect(list.statusCode).toBe(200) expect((list.json() as { items: unknown[] }).items).toHaveLength(1) const policy = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers: { authorization: `Bearer ${token}` }, }) expect(policy.statusCode).toBe(200) const body = policy.json() as { apply_version: number port_rules: { action: string protocol: string port_start: number src_cidrs: string[] }[] } expect(body.apply_version).toBe(3) expect(body.port_rules).toHaveLength(1) expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8']) expect(body.port_rules[0]?.protocol).toBe('tcp') const patch = await app.inject({ method: 'PATCH', url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`, payload: { enabled: false }, }) expect(patch.statusCode).toBe(200) expect((patch.json() as { enabled: boolean }).enabled).toBe(false) const policyOff = await app.inject({ method: 'GET', url: '/v1/agent/policy', headers: { authorization: `Bearer ${token}` }, }) expect( (policyOff.json() as { port_rules: unknown[] }).port_rules, ).toHaveLength(0) const del = await app.inject({ method: 'DELETE', url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`, }) expect(del.statusCode).toBe(200) }) it('imports port-rules from IP list', async () => { const app = await appPromise await app.ready() const { agentId } = await enrollApprovedLinux( app, 'port-acl-import', 'evofw_port_import_token_abcdefgh', ) const listRes = await app.inject({ method: 'POST', url: '/api/v1/lists', payload: { name: 'port-src-list', type: 'static', entries: ['203.0.113.0/24'], }, }) expect(listRes.statusCode).toBe(200) const listId = (listRes.json() as { id: string }).id const before = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}`, }) const genBefore = (before.json() as { policy_generation: number }) .policy_generation const imp = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/port-rules/import`, payload: { from: 'list', list_id: listId, action: 'close', protocol: 'both', ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }], }, }) expect(imp.statusCode).toBe(200) const items = (imp.json() as { items: { src_kind: string; list_id: string }[] }) .items expect(items).toHaveLength(2) expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe( true, ) const after = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}`, }) expect( (after.json() as { policy_generation: number }).policy_generation, ).toBe(genBefore + 1) }) it('upserts host_firewall snapshot from apply-report', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'host-fw-snap', 'evofw_host_fw_token_abcdefghij', ) const report = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', kernel_method: 'nft', host_firewall: { rules: [ { ownership: 'evofw', backend: 'nft', table: 'evofw', chain: 'input', action: 'drop', protocol: 'tcp', dport: '22', raw: 'tcp dport 22 drop comment "evofw-port-x"', }, { ownership: 'foreign', backend: 'iptables', chain: 'INPUT', action: 'ACCEPT', // Agent historically sent JSON null for missing fields — must accept. protocol: null, dport: null, saddr: null, raw: '-A INPUT -p tcp --dport 80 -j ACCEPT', }, ], listeners: [ { protocol: 'tcp', port: 22, address: '0.0.0.0' }, ], }, }, }) expect(report.statusCode).toBe(200) const snap = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/host-firewall`, }) expect(snap.statusCode).toBe(200) const body = snap.json() as { collected_at: string | null rules: { ownership: string }[] listeners: { port: number }[] } expect(body.collected_at).toBeTruthy() expect(body.rules).toHaveLength(2) expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true) expect(body.listeners[0]?.port).toBe(22) }) it('does not wipe host_firewall snapshot with empty apply-report payload', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'host-fw-empty', 'evofw_host_fw_empty_token_ab', ) const filled = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', host_firewall: { rules: [ { ownership: 'evofw', backend: 'nft', action: 'drop', raw: 'ip saddr @deny_v4 drop', }, ], listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }], }, }, }) expect(filled.statusCode).toBe(200) const empty = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', host_firewall: { rules: [], listeners: [] }, }, }) expect(empty.statusCode).toBe(200) const snap = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/host-firewall`, }) const body = snap.json() as { rules: unknown[] listeners: unknown[] } expect(body.rules).toHaveLength(1) expect(body.listeners).toHaveLength(1) }) it('filters invalid host_firewall rules without failing apply-report', async () => { const app = await appPromise await app.ready() const { agentId, token } = await enrollApprovedLinux( app, 'host-fw-filter', 'evofw_host_fw_filter_token_a', ) const report = await app.inject({ method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', }, payload: { status: 'ok', host_firewall: { rules: [ { ownership: 'evofw', backend: 'nft', raw: 'ok rule' }, { ownership: 'nope', backend: 'nft', raw: 'bad ownership' }, { not: 'a rule' }, ], listeners: [ { protocol: 'tcp', port: 443, address: '::' }, { protocol: 'tcp', port: 99999, address: '1.1.1.1' }, ], }, }, }) expect(report.statusCode).toBe(200) const snap = await app.inject({ method: 'GET', url: `/api/v1/agents/${agentId}/host-firewall`, }) const body = snap.json() as { rules: unknown[] listeners: { port: number }[] } expect(body.rules).toHaveLength(1) expect(body.listeners).toHaveLength(1) expect(body.listeners[0]?.port).toBe(443) }) it('rejects port ACL on non-linux agents', async () => { const app = await appPromise await app.ready() const created = await app.inject({ method: 'POST', url: '/api/v1/install-links', payload: { name: 'mt-no-acl', platform: 'mikrotik' }, }) const agentId = (created.json() as { agent_id: string }).agent_id await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/approve`, }) const create = await app.inject({ method: 'POST', url: `/api/v1/agents/${agentId}/port-rules`, payload: { action: 'open', protocol: 'tcp', port_start: 443, src_kind: 'all', }, }) expect(create.statusCode).toBe(400) }) })