Files
EvoFirewall/apps/api/src/services/port-acl.test.ts
T
Denozordec 15b5cce8cc
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
quality / docker-check (push) Skipped
CD / publish (push) Skipped
chore(ci): request-id, RBAC stats, web-тесты и гигиена CI
- genReqId (uuid) + x-request-id в каждом ответе и request_id в error
  envelope — корреляция ошибок между клиентом и логами
- RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются
  как fw:stats:read (reset остаётся под fw:agents:write)
- web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav)
- typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck
  всех пакетов
- гигиена: .node-version (22), актуальный .dockerignore, drizzle out →
  ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test
2026-09-20 20:08:14 +07:00

420 lines
11 KiB
TypeScript

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<ReturnType<typeof buildApp>>,
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)
})
})