/** * Integration test: computed capability fields end-to-end through the * DB-backed resolver. Exercises registration folding a `computed:` entry * into stored capability data, then resolving `$capability:X.field` by * evaluating the DSL in the provider's context (typed secret lookup). * * Isolation: CELILO_DATA_DIR is pointed at a temp dir and a throwaway master * key is written there, so this never touches the operator's real key/db. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { registerModuleCapabilities } from '../../capabilities/registration'; import { type DbClient, closeDb, createDbClient } from '../../db/client'; import type { ModuleManifest } from '../../manifest/schema'; import { encryptSecret } from '../../secrets/encryption'; import { generateMasterKey, writeMasterKey } from '../../secrets/master-key'; import { buildResolutionContext } from '../context'; import { parseVariables } from '../parser'; import { resolveVariable } from '../resolver'; import type { ResolutionContext } from '../types'; import { COMPUTED_MARKER_KEY } from './marker'; let testDir: string; let originalDataDir: string | undefined; let db: DbClient; /** Fresh manifest per call — never share a mutable manifest across tests. */ function namecheapManifest(computedValue = 'keys(secret.ddns_passwords)'): ModuleManifest { return { celilo_contract: '1.0', id: 'namecheap', name: 'Namecheap', version: '4.1.0', description: 'test', provides: { capabilities: [ { name: 'dns_registrar', version: '4.1.0', data: { provider: 'namecheap' }, computed: [{ name: 'domain_list', type: 'computed', value: computedValue }], }, ], }, } as unknown as ModuleManifest; } /** Build a resolution context whose capabilities map comes from the DB. */ function contextFromDb(moduleId: string): ResolutionContext { const rows = db.$client.prepare('SELECT capability_name, data FROM capabilities').all() as Array<{ capability_name: string; data: string; }>; const capMap: Record> = {}; for (const row of rows) { capMap[row.capability_name] = JSON.parse(row.data); } return { moduleId, selfConfig: {}, systemConfig: {}, systemSecrets: {}, secrets: {}, capabilities: capMap, }; } beforeEach(async () => { testDir = mkdtempSync(join(tmpdir(), 'celilo-computed-')); originalDataDir = process.env.CELILO_DATA_DIR; process.env.CELILO_DATA_DIR = testDir; await writeMasterKey(generateMasterKey()); db = createDbClient({ path: join(testDir, 'test.db') }); db.$client .prepare( 'INSERT INTO modules (id, name, version, state, manifest_data, source_path) VALUES (?, ?, ?, ?, ?, ?)', ) .run('namecheap', 'Namecheap', '4.1.0', 'INSTALLED', '{}', '/tmp/namecheap'); db.$client .prepare( 'INSERT INTO modules (id, name, version, state, manifest_data, source_path) VALUES (?, ?, ?, ?, ?, ?)', ) .run('technitium', 'Technitium', '1.0.0', 'INSTALLED', '{}', '/tmp/technitium'); // namecheap's ddns_passwords secret: a JSON map of domain -> password. // Encrypt with the SAME master key the resolver will decrypt with (the one // beforeEach wrote into CELILO_DATA_DIR). const enc = encryptSecret( JSON.stringify({ 'example.net': 'pw1', 'celilo.computer': 'pw2' }), await loadTestMasterKey(), ); db.$client .prepare( 'INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES (?, ?, ?, ?, ?)', ) .run('namecheap', 'ddns_passwords', enc.encryptedValue, enc.iv, enc.authTag); }); afterEach(() => { closeDb(); process.env.CELILO_DATA_DIR = originalDataDir; rmSync(testDir, { recursive: true, force: true }); }); /** Read the master key that beforeEach wrote into CELILO_DATA_DIR. */ async function loadTestMasterKey(): Promise { const { readMasterKey } = await import('../../secrets/master-key'); return readMasterKey(); } describe('computed capability fields — DB integration', () => { test('registration folds a computed field into stored data as a marker', async () => { await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client); const row = db.$client .prepare('SELECT data FROM capabilities WHERE capability_name = ?') .get('dns_registrar') as { data: string }; const data = JSON.parse(row.data); expect(data.provider).toBe('namecheap'); expect(data.domain_list).toEqual({ [COMPUTED_MARKER_KEY]: 'keys(secret.ddns_passwords)' }); }); test('resolver evaluates the computed field in the provider context', async () => { await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client); const ref = parseVariables('$capability:dns_registrar.domain_list')[0]; const result = await resolveVariable(ref, contextFromDb('technitium'), db); expect(result.success).toBe(true); if (result.success) { // Non-scalar computed results serialize to JSON in the string path. expect(JSON.parse(result.value)).toEqual(['example.net', 'celilo.computer']); } }); test('a static field alongside the computed one still resolves', async () => { await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client); const ref = parseVariables('$capability:dns_registrar.provider')[0]; const result = await resolveVariable(ref, contextFromDb('technitium'), db); expect(result.success).toBe(true); if (result.success) expect(result.value).toBe('namecheap'); }); test('a computed field referencing a missing secret yields a clear error', async () => { await registerModuleCapabilities( 'namecheap', namecheapManifest('keys(secret.nope)'), db.$client, ); const ref = parseVariables('$capability:dns_registrar.domain_list')[0]; const result = await resolveVariable(ref, contextFromDb('technitium'), db); expect(result.success).toBe(false); if (!result.success) expect(result.error).toMatch(/could not be resolved|Failed to evaluate/); }); // Gap B (openspec/specs/internal-dns-split-horizon/spec.md step 3): buildResolutionContext // must EAGERLY evaluate computed markers into the capabilities map, so the // `variables.imports` path (which reads context.capabilities directly, not via // resolveVariable) sees the real array. test('buildResolutionContext pre-evaluates the computed field into the capabilities map', async () => { await registerModuleCapabilities('namecheap', namecheapManifest(), db.$client); const ctx = await buildResolutionContext('technitium', db); const reg = ctx.capabilities.dns_registrar as Record; // The real array, not the raw marker object. expect(reg.domain_list).toEqual(['example.net', 'celilo.computer']); expect(reg.provider).toBe('namecheap'); }); test('a provider whose computed field fails to evaluate does not break context build', async () => { await registerModuleCapabilities( 'namecheap', namecheapManifest('keys(secret.nope)'), db.$client, ); // Build context for an unrelated consumer — must not throw despite the // un-evaluable marker (best-effort: leaves the marker in place). const ctx = await buildResolutionContext('technitium', db); const reg = ctx.capabilities.dns_registrar as Record; expect(reg.provider).toBe('namecheap'); expect(reg.domain_list).toEqual({ [COMPUTED_MARKER_KEY]: 'keys(secret.nope)' }); }); });