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
523 lines
14 KiB
TypeScript
523 lines
14 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('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)
|
|
})
|
|
})
|