quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
524 lines
14 KiB
TypeScript
524 lines
14 KiB
TypeScript
import { describe, it, expect, afterAll } from 'vitest'
|
|
import { buildApp } from '../app.js'
|
|
import type { AppConfig } from '../config.js'
|
|
import { repos } from '@evofw/db'
|
|
import { linkEnrollmentCredential } from './enrollment.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-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
|
|
},
|
|
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-enrollment-credential': linkEnrollmentCredential(app.db, repos.getInstallLink(app.db, link.id)!, testConfig.enrollSeed),
|
|
},
|
|
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`
|
|
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)
|
|
})
|
|
})
|