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.
176 lines
12 KiB
TypeScript
176 lines
12 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
import { createServer } from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import { createMemoryDb, runMigrations, repos } from '@evofw/db'
|
|
import * as safe from './safe-fetch.js'
|
|
import { fetchEvobgpPages } from './evobgp.js'
|
|
import { refreshIpList } from './lists/refresh.js'
|
|
import { publishListCascade } from './lists/entries.js'
|
|
|
|
const server = createServer((req, res) => {
|
|
const path = req.url!
|
|
if (path === '/large') { res.write('['); res.end(' '.repeat(1024) + ']'); return }
|
|
if (path === '/declared') { res.setHeader('Content-Length', '100000'); res.end('[]'); return }
|
|
if (path === '/headers') { res.setHeader('X-Large', 'x'.repeat(20_000)); res.end('{}'); return }
|
|
if (path === '/slow') { res.write('['); setTimeout(() => res.end(']'), 150); return }
|
|
if (path === '/invalid') { res.end('not JSON'); return }
|
|
if (path === '/compressed') { res.setHeader('Content-Encoding', 'gzip'); res.end('[]'); return }
|
|
if (path === '/redirect') { res.writeHead(302, { Location: '/echo' }); res.end(); return }
|
|
if (path === '/loop') { res.writeHead(302, { Location: '/loop' }); res.end(); return }
|
|
if (path === '/private') { res.writeHead(302, { Location: 'http://127.0.0.2/secret' }); res.end(); return }
|
|
if (path === '/cross') { res.writeHead(302, { Location: `${numericOrigin}/echo` }); res.end(); return }
|
|
if (path === '/cross-back') { res.writeHead(302, { Location: `${numericOrigin}/back` }); res.end(); return }
|
|
if (path === '/back') { res.writeHead(302, { Location: `${origin}/echo` }); res.end(); return }
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({ authorization: req.headers.authorization || null, cookie: req.headers.cookie || null }))
|
|
})
|
|
let origin: string
|
|
let numericOrigin: string
|
|
const databases: ReturnType<typeof createMemoryDb>[] = []
|
|
beforeAll(async () => {
|
|
await new Promise<void>(resolve => server.listen(0, resolve))
|
|
const port = (server.address() as AddressInfo).port
|
|
origin = `http://localhost:${port}`
|
|
numericOrigin = `http://127.0.0.1:${port}`
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks(); vi.unstubAllEnvs()
|
|
for (const memory of databases.splice(0)) memory.sqlite.close()
|
|
})
|
|
afterAll(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())) })
|
|
function allow() { vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', `${origin},${numericOrigin}`) }
|
|
function memory() { const value = createMemoryDb(); runMigrations(value.sqlite); databases.push(value); return value }
|
|
function feed(db: ReturnType<typeof memory>['db'], id: string, config: Record<string, unknown> = {}) {
|
|
repos.insertIpList(db, { id, name: id, type: 'json_url', configJson: JSON.stringify({ url: 'https://feed.test/list', ...config }) })
|
|
repos.replaceIpListEntries(db, id, ['8.8.8.8/32'])
|
|
}
|
|
|
|
describe('source socket boundaries', () => {
|
|
it('rejects private, reserved, mapped and transition IPs; accepts public v4/v6', () => {
|
|
for (const ip of ['127.0.0.1', '10.1.1.1', '169.254.169.254', '172.16.0.1', '192.168.1.1', '100.64.0.1', '198.18.0.1', '224.1.1.1', '::1', 'fc00::1', 'fe80::1', '::ffff:8.8.8.8', '2002:7f00:1::', '2001:db8::1']) expect(safe.isPublicSourceAddress(ip), ip).toBe(false)
|
|
for (const ip of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) expect(safe.isPublicSourceAddress(ip), ip).toBe(true)
|
|
})
|
|
it('returns only the vetted DNS answers to the socket and rejects mixed/private resolution', async () => {
|
|
const resolver = vi.fn().mockResolvedValue([{ address: '8.8.8.8', family: 4 }])
|
|
const call = (all = false) => new Promise<unknown>((resolve, reject) => safe.sourceLookup(new URL('https://feed.test'), resolver)('feed.test', { all }, (err, address) => err ? reject(err) : resolve(address)))
|
|
expect(await call()).toBe('8.8.8.8')
|
|
expect(await call(true)).toEqual([{ address: '8.8.8.8', family: 4 }])
|
|
resolver.mockResolvedValue([{ address: '8.8.8.8', family: 4 }, { address: '127.0.0.1', family: 4 }])
|
|
await expect(call()).rejects.toThrow('not public')
|
|
vi.stubEnv('EVOFW_SOURCE_ALLOWED_ORIGINS', '%%%invalid')
|
|
await expect(call()).rejects.toThrow('policy is invalid')
|
|
})
|
|
it('blocks literal and DNS loopback without explicit origin allowlisting', async () => {
|
|
await expect(safe.safeFetchJson(`${numericOrigin}/echo`)).rejects.toThrow('not public')
|
|
await expect(safe.safeFetchJson(`${origin}/echo`)).rejects.toThrow('failed')
|
|
await expect(safe.safeFetchJson('file:///etc/passwd')).rejects.toThrow('HTTP(S)')
|
|
await expect(safe.safeFetchJson('https://user:[email protected]')).rejects.toThrow('credentials')
|
|
})
|
|
it('enforces chunked/declared byte limits, headers, JSON format, encoding and body deadline', async () => {
|
|
allow()
|
|
for (const path of ['/large', '/declared']) await expect(safe.safeFetchJson(`${origin}${path}`, { maxBytes: 32 })).rejects.toThrow('byte limit')
|
|
await expect(safe.safeFetchJson(`${origin}/headers`)).rejects.toThrow('failed')
|
|
await expect(safe.safeFetchJson(`${origin}/invalid`)).rejects.toThrow('valid JSON')
|
|
await expect(safe.safeFetchJson(`${origin}/compressed`)).rejects.toThrow('compressed')
|
|
await expect(safe.safeFetchJson(`${origin}/slow`, { timeoutMs: 30 })).rejects.toThrow(/interrupted|timed out/)
|
|
})
|
|
it('bounds redirects and strips secrets permanently on an origin change', async () => {
|
|
allow()
|
|
const headers = { Authorization: 'Bearer secret', Cookie: 'secret=value' }
|
|
expect((await safe.safeFetchJson(`${origin}/redirect`, { headers })).data).toEqual({ authorization: 'Bearer secret', cookie: 'secret=value' })
|
|
for (const path of ['/cross', '/cross-back']) expect((await safe.safeFetchJson(`${origin}${path}`, { headers })).data).toEqual({ authorization: null, cookie: null })
|
|
await expect(safe.safeFetchJson(`${origin}/loop`)).rejects.toThrow('redirect limit')
|
|
await expect(safe.safeFetchJson(`${origin}/private`, { headers })).rejects.toThrow('not public')
|
|
})
|
|
})
|
|
|
|
describe('bounded snapshot publication', () => {
|
|
it('bumps direct port-list agents and deduplicates L3 ancestor dependencies', async () => {
|
|
const { db, sqlite } = memory()
|
|
for (const id of ['port-only', 'many-parents']) repos.insertAgent(db, { id, name: id, tokenHash: id, tokenPrefix: id, status: 'approved' })
|
|
feed(db, 'child')
|
|
for (const id of ['parent-a', 'parent-b']) {
|
|
repos.insertIpList(db, { id, name: id, type: 'static', configJson: JSON.stringify({ items: [{ kind: 'list', value: 'child' }] }) })
|
|
repos.insertPolicySet(db, { id: `set-${id}`, name: id })
|
|
repos.insertPolicyRule(db, { id: `rule-${id}`, setId: `set-${id}`, priority: 10, action: 'deny', listId: id })
|
|
}
|
|
repos.setAgentPolicySets(db, 'many-parents', ['set-parent-a', 'set-parent-b'])
|
|
sqlite.prepare('INSERT INTO agent_port_rules(id,agent_id,action,protocol,port_start,port_end,src_kind,list_id) VALUES (?,?,?,?,?,?,?,?)').run('port-list', 'port-only', 'open', 'tcp', 443, 443, 'list', 'child')
|
|
const beforePort = repos.getAgent(db, 'port-only')!.policyGeneration
|
|
const beforeMany = repos.getAgent(db, 'many-parents')!.policyGeneration
|
|
await publishListCascade(db, 'child', { cidrs: ['1.1.1.1'] })
|
|
expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1)
|
|
expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1)
|
|
await publishListCascade(db, 'child', { cidrs: ['1.1.1.1/32'] })
|
|
expect(repos.getAgent(db, 'port-only')!.policyGeneration).toBe(beforePort + 1)
|
|
expect(repos.getAgent(db, 'many-parents')!.policyGeneration).toBe(beforeMany + 1)
|
|
})
|
|
it('quarantines unexpected empty data, preserves last success time and permits explicit empty', async () => {
|
|
const { db } = memory(); feed(db, 'f')
|
|
const before = repos.getIpList(db, 'f')!.refreshedAt
|
|
vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: [], bytes: 2 })
|
|
await refreshIpList(db, 'f')
|
|
expect(repos.listIpListEntries(db, 'f')).toHaveLength(1)
|
|
expect(repos.getIpList(db, 'f')!.lastError).toContain('quarantined')
|
|
expect(repos.getIpList(db, 'f')!.refreshedAt).toBe(before)
|
|
repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://feed.test', allow_empty: true }) })
|
|
await refreshIpList(db, 'f')
|
|
expect(repos.listIpListEntries(db, 'f')).toHaveLength(0)
|
|
expect(repos.getIpList(db, 'f')!.lastError).toBeNull()
|
|
})
|
|
it('discards stale remote results after a config edit', async () => {
|
|
const { db } = memory(); feed(db, 'f')
|
|
vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => {
|
|
repos.updateIpList(db, 'f', { configJson: JSON.stringify({ url: 'https://new.test' }) })
|
|
return { data: ['1.1.1.1'], bytes: 12 }
|
|
})
|
|
await refreshIpList(db, 'f')
|
|
expect(repos.getIpList(db, 'f')!.configJson).toContain('new.test')
|
|
expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32')
|
|
})
|
|
it('coalesces the same list and limits global active refreshes to two', async () => {
|
|
const { db } = memory(); for (const id of ['a', 'b', 'c']) feed(db, id)
|
|
let active = 0; let maximum = 0
|
|
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => {
|
|
active++; maximum = Math.max(active, maximum)
|
|
await new Promise(resolve => setTimeout(resolve, 10)); active--
|
|
return { data: ['1.1.1.1'], bytes: 12 }
|
|
})
|
|
const first = refreshIpList(db, 'a')
|
|
expect(refreshIpList(db, 'a')).toBe(first)
|
|
await Promise.all([first, refreshIpList(db, 'b'), refreshIpList(db, 'c')])
|
|
expect(maximum).toBe(2); expect(fetcher).toHaveBeenCalledTimes(3)
|
|
})
|
|
it('rejects excessive elements and incomplete/repeated cursor snapshots', async () => {
|
|
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: { items: Array(safe.SOURCE_LIMITS.elements + 1).fill('x') }, bytes: 1 })
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('element limit')
|
|
fetcher.mockResolvedValue({ data: { items: [], has_more: true, next_cursor: 'same' }, bytes: 1 })
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('repeated')
|
|
fetcher.mockResolvedValue({ data: { items: [], has_more: true }, bytes: 1 })
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('Incomplete')
|
|
fetcher.mockResolvedValue({ data: { items: [], has_more: 'yes' }, bytes: 1 })
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('has_more')
|
|
})
|
|
it('bounds cumulative snapshot bytes, page count and elapsed pagination time', async () => {
|
|
let page = 0
|
|
const fetcher = vi.spyOn(safe, 'safeFetchJson').mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: safe.SOURCE_LIMITS.bytes }))
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('byte limit')
|
|
expect(fetcher).toHaveBeenCalledTimes(4)
|
|
page = 0; fetcher.mockClear().mockImplementation(async () => ({ data: { items: [], has_more: true, next_cursor: String(++page) }, bytes: 1 }))
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('page limit')
|
|
expect(fetcher).toHaveBeenCalledTimes(safe.SOURCE_LIMITS.pages)
|
|
let now = 1000
|
|
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
|
fetcher.mockClear().mockImplementation(async () => { now += safe.SOURCE_LIMITS.timeoutMs + 1; return { data: { items: [], has_more: true, next_cursor: 'later' }, bytes: 1 } })
|
|
await expect(fetchEvobgpPages('https://bgp.test', 'secret', '/v1/communities', 200)).rejects.toThrow('deadline')
|
|
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
})
|
|
it('rejects excessive JSON URL entries without replacing last good', async () => {
|
|
const { db } = memory(); feed(db, 'f')
|
|
vi.spyOn(safe, 'safeFetchJson').mockResolvedValue({ data: Array(safe.SOURCE_LIMITS.elements + 1).fill('1.1.1.1'), bytes: 1 })
|
|
await refreshIpList(db, 'f')
|
|
expect(repos.getIpList(db, 'f')!.lastError).toContain('element limit')
|
|
expect(repos.listIpListEntries(db, 'f')[0]!.cidr).toBe('8.8.8.8/32')
|
|
})
|
|
})
|