import { afterEach, describe, expect, it, vi } from 'vitest' import { createMemoryDb, runMigrations, repos } from '@evofw/db' import { buildApp } from '../../app.js' import { loadConfig } from '../../config.js' import { hashToken } from '../../plugins/auth.js' import { evaluateAgentPolicy } from './evaluate.js' import { policyCacheFor } from './cache.js' import { refreshAllHostnameRules } from './resolve-hostname.js' const dns = vi.hoisted(() => ({ resolve4: vi.fn(), resolve6: vi.fn(), cancel: vi.fn() })) vi.mock('node:dns/promises', async (original) => ({ ...await original(), Resolver: class { resolve4 = dns.resolve4; resolve6 = dns.resolve6; cancel = dns.cancel } })) const opened: ReturnType[] = [] const apps: Awaited>[] = [] afterEach(async () => { for (const app of apps.splice(0)) await app.close(); for (const mem of opened.splice(0)) mem.sqlite.close(); vi.resetAllMocks() }) function memory() { const mem = createMemoryDb(); opened.push(mem); runMigrations(mem.sqlite) repos.insertAgent(mem.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' }) return mem } function hostnameFixture() { const mem = memory(); repos.ensureSharedSetAssigned(mem.db, 'a') for (const [id, hostname, cidr, priority] of [['h1', 'first.test', '192.0.2.1/32', 10], ['h2', 'second.test', '192.0.2.2/32', 20]] as const) { repos.insertPolicyRule(mem.db, { id, setId: 'set-shared-default', hostname, action: 'deny', priority }) repos.replaceResolvedForRule(mem.db, id, [cidr]) } dns.resolve6.mockResolvedValue([]) return mem } describe('cache assignment and DNS publication invalidation', () => { it('refreshes cached preview set names after a rename without bumping again for an identical name', async () => { const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } }); apps.push(app); await app.ready() repos.insertAgent(app.db, { id: 'a', name: 'a', tokenPrefix: 'a', tokenHash: 'a', status: 'approved' }) repos.ensureSharedSetAssigned(app.db, 'a') repos.insertPolicyRule(app.db, { id: 'deny', setId: 'set-shared-default', action: 'deny', cidr: '10.0.0.0/8', priority: 10 }) const before = (await app.inject({ url: '/api/v1/agents/a/preview' })).json() const renamed = await app.inject({ method: 'PATCH', url: '/api/v1/policy-sets/set-shared-default', payload: { name: 'Renamed source set' } }) expect(renamed.statusCode).toBe(200) const after = (await app.inject({ url: '/api/v1/agents/a/preview' })).json() expect(after.chain[0].set_name).toBe('Renamed source set') expect(after.generation).toBe(before.generation + 1) expect(after.hash).toBe(before.hash) await app.inject({ method: 'PATCH', url: '/api/v1/policy-sets/set-shared-default', payload: { name: 'Renamed source set' } }) expect((await app.inject({ url: '/api/v1/agents/a/preview' })).json().generation).toBe(after.generation) }) it('invalidates a preview cached before single and bulk approval, with exactly one insertion bump and idempotent retries', async () => { const app = await buildApp({ memory: true, config: { ...loadConfig(), authRequired: false, staticDir: null, logLevel: 'error' } }); apps.push(app); await app.ready() repos.insertPolicyRule(app.db, { id: 'deny', setId: 'set-shared-default', action: 'deny', cidr: '10.0.0.0/8', priority: 10 }) for (const id of ['single', 'bulk']) { repos.insertAgent(app.db, { id, name: id, tokenPrefix: id, tokenHash: hashToken(`token-${id}`), status: 'pending' }) const preview = await app.inject({ url: `/api/v1/agents/${id}/preview` }) expect(preview.statusCode).toBe(200) expect(preview.json().summary.cidrs_deny).toBe(0) expect(preview.json().generation).toBe(1) } const single = await app.inject({ method: 'POST', url: '/api/v1/agents/single/approve' }) expect(single.statusCode).toBe(200) expect(single.json().policy_generation).toBe(2) const bulk = await app.inject({ method: 'POST', url: '/api/v1/agents/approve-bulk', payload: { agent_ids: ['bulk'] } }) expect(bulk.statusCode).toBe(200) expect(bulk.json().items[0].policy_generation).toBe(2) for (const id of ['single', 'bulk']) { const policy = await app.inject({ url: '/v1/agent/policy', headers: { authorization: `Bearer token-${id}` } }) expect(policy.statusCode).toBe(200) expect(policy.json()).toMatchObject({ generation: 2, deny_cidrs: ['10.0.0.0/8'] }) expect(repos.ensureSharedSetAssigned(app.db, id)).toBe(false) expect(repos.getAgent(app.db, id)!.policyGeneration).toBe(2) } expect((await app.inject({ method: 'POST', url: '/api/v1/agents/single/approve' })).json().policy_generation).toBe(2) expect((await app.inject({ method: 'POST', url: '/api/v1/agents/approve-bulk', payload: { agent_ids: ['bulk'] } })).json().items).toEqual([]) expect(repos.getAgent(app.db, 'bulk')!.policyGeneration).toBe(2) }) it('rolls back assignment insertion when the generation bump fails, and existing assignments never bump again', () => { const mem = memory() mem.sqlite.exec("CREATE TRIGGER reject_generation BEFORE UPDATE OF policy_generation ON agents BEGIN SELECT RAISE(ABORT,'test generation failure'); END") expect(() => repos.ensureSharedSetAssigned(mem.db, 'a')).toThrow(/generation failure/) expect(repos.listSetsForAgent(mem.db, 'a')).toEqual([]) expect(repos.getAgent(mem.db, 'a')!.policyGeneration).toBe(1) mem.sqlite.exec('DROP TRIGGER reject_generation') expect(repos.ensureSharedSetAssigned(mem.db, 'a')).toBe(true) expect(repos.ensureSharedSetAssigned(mem.db, 'a')).toBe(false) expect(repos.getAgent(mem.db, 'a')!.policyGeneration).toBe(2) }) it('keeps warm and cold previews on the old snapshot while another DNS lookup is pending, then publishes once atomically', async () => { const mem = hostnameFixture() const before = evaluateAgentPolicy(mem.db, 'a') let release!: (value: string[]) => void const second = new Promise((resolve) => { release = resolve }) dns.resolve4.mockImplementation((host) => host === 'second.test' ? second : Promise.resolve(['203.0.113.1'])) const refresh = refreshAllHostnameRules(mem.db) await vi.waitFor(() => expect(dns.resolve4).toHaveBeenCalledWith('second.test')) expect(repos.listResolvedForRule(mem.db, 'h1').map((row) => row.cidr)).toEqual(['192.0.2.1/32']) expect(evaluateAgentPolicy(mem.db, 'a')).toBe(before) policyCacheFor(mem.db).get('a', 'force-cold-preview', Date.now()) expect(evaluateAgentPolicy(mem.db, 'a').hash).toBe(before.hash) release(['203.0.113.2']); await refresh const after = evaluateAgentPolicy(mem.db, 'a') expect(after.denyCidrs).toEqual(['203.0.113.1/32', '203.0.113.2/32']) expect(after.generation).toBe(before.generation + 1) await refreshAllHostnameRules(mem.db) expect(evaluateAgentPolicy(mem.db, 'a')).toBe(after) }) it('preserves failed lookup snapshots and never overwrites a newer publication made while DNS was pending', async () => { const mem = hostnameFixture() let reject!: (reason: Error) => void const second = new Promise((_, fail) => { reject = fail }) dns.resolve4.mockImplementation((host) => host === 'second.test' ? second : Promise.resolve(['203.0.113.1'])) const refresh = refreshAllHostnameRules(mem.db) await vi.waitFor(() => expect(dns.resolve4).toHaveBeenCalledWith('second.test')) repos.replaceResolvedForRule(mem.db, 'h1', ['198.51.100.1/32']) repos.bumpAgentsForSet(mem.db, 'set-shared-default') const newer = evaluateAgentPolicy(mem.db, 'a') reject(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' })); await refresh expect(evaluateAgentPolicy(mem.db, 'a')).toBe(newer) expect(newer.denyCidrs).toEqual(['192.0.2.2/32', '198.51.100.1/32']) }) })