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
- 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
420 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|