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>[] = [] 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) }) })