Files
EvoFirewall/apps/api/src/services/ui-summary.test.ts
T
Denozordec 9fa6269174
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
feat: harden firewall policies telemetry and operations
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.
2026-10-26 13:10:58 +07:00

136 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import { buildApp } from '../app.js'
import { loadConfig } from '../config.js'
import { repos } from '@evofw/db'
import { uiSummarySchema } from '@evofw/shared'
import { hashToken } from '../plugins/auth.js'
const apps: Awaited<ReturnType<typeof buildApp>>[] = []
afterEach(async () => { for (const app of apps.splice(0)) await app.close() })
async function fixture() {
const config = { ...loadConfig(), authRequired: true, jwtSecret: 'test-secret-key', authIssuer: 'https://auth.test', staticDir: null, logLevel: 'error' }
const app = await buildApp({ memory: true, config }); apps.push(app); await app.ready()
repos.insertAgent(app.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: hashToken('token-a'), status: 'approved', lastSeenAt: new Date().toISOString(), totalPacketsDropped: 15 })
repos.insertAgent(app.db, { id: 'b', name: 'b', tokenPrefix: 'b', tokenHash: 'token-b', status: 'approved' })
repos.insertAgent(app.db, { id: 'c', name: 'c', tokenPrefix: 'c', tokenHash: 'token-c', status: 'pending' })
repos.insertIpList(app.db, { id: 'l', name: 'l', type: 'static', lastError: 'upstream failed' })
const headers = (permissions: string[], allowedApps = ['fw']) => ({ authorization: `Bearer ${app.jwt.sign({ sub: 'viewer', apps: allowedApps, permissions, iss: config.authIssuer })}` })
return { app, headers }
}
describe('UI summary and telemetry routes', () => {
it('exposes explain under agents permission and records bounded override TTL/reason with server-owned author', async () => {
const { app, headers } = await fixture()
const write = headers(['fw:agents:write']), read = headers(['fw:agents:read'])
const create = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'deny', reason: 'Investigation', ttl_seconds: 60, created_by_user_id: 'spoofed' } })
expect(create.statusCode).toBe(200)
expect(create.json()).toMatchObject({ cidr: '8.8.8.8/32', created_by_user_id: 'viewer', reason: 'Investigation', expired: false, expiration_enforcement: 'next_policy_sync' })
expect(Date.parse(create.json().expires_at) - Date.now()).toBeGreaterThan(55_000)
const explain = await app.inject({ url: '/api/v1/agents/a/explain?ip=8.8.8.8', headers: read })
expect(explain.statusCode).toBe(200)
expect(explain.json().desired).toMatchObject({ decision: 'drop', stage: 'deny', match: { override_id: create.json().id } })
expect((await app.inject({ url: '/api/v1/agents/a/explain?ip=8.8.8.8', headers: headers(['fw:stats:read']) })).statusCode).toBe(403)
const both = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '1.1.1.1', action: 'deny', ttl_seconds: 60, expires_at: new Date(Date.now() + 60_000).toISOString() } })
expect(both.statusCode).toBe(400)
const duplicate = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'allow' } })
expect(duplicate.statusCode).toBe(409)
app.sqlite.prepare('UPDATE ip_overrides SET expires_at=? WHERE id=?').run(new Date(Date.now() - 1000).toISOString(), create.json().id)
const before = repos.getAgent(app.db, 'a')!.policyGeneration
const policy = await app.inject({ url: '/v1/agent/policy', headers: { authorization: 'Bearer token-a' } })
expect(policy.statusCode).toBe(200)
expect(policy.json().deny_cidrs).not.toContain('8.8.8.8/32')
expect(policy.json().generation).toBe(before + 1)
expect((await app.inject({ url: '/api/v1/agents/a/overrides', headers: read })).json().items[0].expired).toBe(true)
const replacement = await app.inject({ method: 'POST', url: '/api/v1/agents/a/overrides', headers: write, payload: { cidr: '8.8.8.8', action: 'deny', reason: 'New investigation', ttl_seconds: 60 } })
expect(replacement.statusCode).toBe(200)
})
it('accepts a real >1MiB 100k-prefix import with a scoped body limit and rejects excess elements', async () => {
const { app, headers } = await fixture()
const entries = Array.from({ length: 100_000 }, (_, i) => `10.${i >> 16}.${(i >> 8) & 255}.${i & 255}`)
const payload = { name: 'large-manual', type: 'static', entries }
expect(Buffer.byteLength(JSON.stringify(payload))).toBeGreaterThan(1024 * 1024)
const result = await app.inject({ method: 'POST', url: '/api/v1/lists', headers: headers(['fw:lists:write']), payload })
expect(result.statusCode).toBe(200)
expect(repos.countEntriesByListIds(app.db, [result.json().id]).get(result.json().id)).toBe(100_000)
const excessive = await app.inject({ method: 'POST', url: `/api/v1/lists/${result.json().id}/entries`, headers: headers(['fw:lists:write']), payload: { values: [Array(100_001).fill('8.8.8.8').join(' ')] } })
expect(excessive.statusCode).toBe(400)
const unrelated = await app.inject({ method: 'PATCH', url: '/api/v1/agents/a', headers: headers(['fw:agents:write']), payload: { name: 'x'.repeat(1024 * 1024 + 1) } })
expect(unrelated.statusCode).toBe(413)
}, 15_000)
it('protects bounded analytics/CSV/raw endpoints with stats permission and paginates tied raw timestamps without loss', async () => {
const { app, headers } = await fixture()
const from = '2026-01-01T00:00:00.000Z', to = '2026-01-01T01:00:00.000Z'
const query = `from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&resolution=1m&agent_ids=a`
expect((await app.inject({ url: `/api/v1/stats/timeseries?${query}`, headers: headers(['fw:agents:read']) })).statusCode).toBe(403)
const statsHeaders = headers(['fw:stats:read'])
const response = await app.inject({ url: `/api/v1/stats/timeseries?${query}`, headers: statsHeaders })
expect(response.statusCode).toBe(200)
expect(response.json().items).toHaveLength(60)
expect(response.json().items.every((item: { packets_dropped: unknown }) => item.packets_dropped === null)).toBe(true)
const csv = await app.inject({ url: `/api/v1/stats/timeseries.csv?${query}`, headers: statsHeaders })
expect(csv.statusCode).toBe(200)
expect(csv.headers['content-type']).toContain('text/csv')
expect(csv.body.split('\r\n')).toHaveLength(62)
for (const id of ['raw-a', 'raw-b', 'raw-c']) repos.insertStatsSample(app.db, { id, agentId: 'a', recordedAt: from, packetsDropped: 100 })
const path = `/api/v1/agents/a/stats/raw?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=2`
const page1 = await app.inject({ url: path, headers: statsHeaders })
expect(page1.statusCode).toBe(200)
expect(page1.json().items.map((item: { id: string }) => item.id)).toEqual(['raw-a', 'raw-b'])
expect(page1.json().has_more).toBe(true)
const page2 = await app.inject({ url: `${path}&cursor=${page1.json().next_cursor}`, headers: statsHeaders })
expect(page2.json().items.map((item: { id: string }) => item.id)).toEqual(['raw-c'])
expect(page2.json().next_cursor).toBeNull()
expect((await app.inject({ url: `${path}&cursor=invalid`, headers: statsHeaders })).statusCode).toBe(400)
expect((await app.inject({ url: `${path.replace('limit=2', 'limit=501')}`, headers: statsHeaders })).statusCode).toBe(400)
})
it('requires authentication/fw membership and independently omits unauthorized sections', async () => {
const { app, headers } = await fixture()
expect((await app.inject({ url: '/api/v1/ui/summary' })).statusCode).toBe(401)
expect((await app.inject({ url: '/api/v1/ui/summary', headers: headers([], []) })).statusCode).toBe(403)
const empty = await app.inject({ url: '/api/v1/ui/summary', headers: headers([]) })
expect(empty.statusCode).toBe(200)
expect(Object.keys(empty.json())).toEqual(['updated_at'])
for (const [scope, field] of [['agents', 'agents'], ['lists', 'lists'], ['policies', 'policies'], ['dashboard', 'dashboard']] as const) {
const response = await app.inject({ url: '/api/v1/ui/summary', headers: headers([`fw:${scope}:read`]) })
expect(response.statusCode).toBe(200)
expect(Object.keys(response.json()).sort()).toEqual([field, 'updated_at'].sort())
expect(uiSummarySchema.safeParse(response.json()).success).toBe(true)
}
const all = await app.inject({ url: '/api/v1/ui/summary', headers: headers(['fw:agents:write', 'fw:lists:write', 'fw:policies:write', 'fw:dashboard:read']) })
expect(all.json().agents).toEqual({ approved: 2, online: 1, pending: 1, offline: 1 })
expect(all.json().lists).toEqual({ total: 1, failed: 1 })
expect(all.json().dashboard.packets_dropped).toBe(15)
expect(all.body).not.toContain('token-a')
expect(all.body).not.toContain('upstream failed')
})
it('acknowledges a committed v2 report ID and preserves history/baselines through stats reset', async () => {
const { app, headers } = await fixture()
const post = async (sequence: number, value: number, reportId = `r-${sequence}`) => app.inject({
method: 'POST', url: '/v1/agent/apply-report', headers: { authorization: 'Bearer token-a' },
payload: { telemetry_version: 2, report_id: reportId, sequence, boot_id: 'boot', observed_at: new Date().toISOString(), interval_ms: 60_000,
counter_epoch: { traffic: 'e' }, capabilities: ['policy_ipv4'], quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 }, status: 'ok', packets_dropped: value, packets_accepted: 0 },
})
const first = await post(1, 100)
expect(first.statusCode).toBe(200)
expect(first.json()).toMatchObject({ ok: true, accepted: true, report_id: 'r-1', sequence: 1 })
const replay = await post(1, 100)
expect(replay.json()).toMatchObject({ ok: true, duplicate: true, report_id: 'r-1' })
const reset = await app.inject({ method: 'POST', url: '/api/v1/agents/a/stats/reset', headers: headers(['fw:agents:write']) })
expect(reset.statusCode).toBe(200)
await post(2, 100)
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(0)
await post(3, 105)
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(5)
expect(repos.listStatsSamples(app.db, 'a')).toHaveLength(3)
const stale = await post(2, 999, 'restored-flash')
expect(stale.json()).toMatchObject({ ok: true, accepted: false, reason: 'stale_sequence', report_id: 'restored-flash', sequence: 2, sequence_floor: 3 })
expect(repos.getAgent(app.db, 'a')!.totalPacketsDropped).toBe(5)
app.sqlite.prepare('UPDATE agent_telemetry_state SET sequence=? WHERE agent_id=?').run(Number.MAX_SAFE_INTEGER, 'a')
expect((await post(1, 999, 'exhausted')).json().sequence_floor).toBe(Number.MAX_SAFE_INTEGER)
})
})