/** * Tests for capability data $self: variable resolution (lazy resolution) * Verifies that capability data containing unresolved $self: variables * gets resolved from the provider module's config when requested */ import { afterEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createDbClient } from '@/db/client'; import { buildResolutionContext } from './context'; import { resolveVariable } from './resolver'; import type { ResolutionContext } from './types'; let testDirs: string[] = []; afterEach(() => { for (const dir of testDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { // Ignore } } testDirs = []; }); describe('Capability data $self: variable resolution', () => { test('should lazily resolve $self: variables in capability data', async () => { const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-')); testDirs.push(testDir); const testDbPath = join(testDir, 'test.db'); const db = createDbClient({ path: testDbPath }); // Create provider module (dns-external) db.$client .prepare( `INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)`, ) .run( 'dns-external', 'DNS External', '1.0.0', '/tmp/modules/dns-external', 'CONFIGURED', JSON.stringify({ id: 'dns-external', name: 'DNS External', version: '1.0.0' }), ); // Register capability with unresolved $self: variables db.$client .prepare( `INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)`, ) .run( 'dns-external', 'dns_external', '1.0.0', JSON.stringify({ server: { ip: { primary: '$self:ip.primary', // Unresolved! }, }, tsig: { key_name: '$self:tsig_key_name', // Unresolved! }, }), ); // Store provider module's config db.$client .prepare( `INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?), (?, ?, ?)`, ) .run( 'dns-external', 'ip.primary', '203.0.113.42', 'dns-external', 'tsig_key_name', 'celilo-ddns', ); // Create consumer module (caddy) db.$client .prepare( `INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)`, ) .run( 'caddy', 'Caddy', '1.0.0', '/tmp/modules/caddy', 'CONFIGURED', JSON.stringify({ id: 'caddy', name: 'Caddy', version: '1.0.0' }), ); // Test: Resolve capability variable with lazy $self: resolution const context: ResolutionContext = { moduleId: 'caddy', selfConfig: {}, systemConfig: {}, secrets: {}, systemSecrets: {}, capabilities: { dns_external: { server: { ip: { primary: '$self:ip.primary', // Will be lazily resolved }, }, tsig: { key_name: '$self:tsig_key_name', // Will be lazily resolved }, }, }, }; const result1 = await resolveVariable( { type: 'capability', path: 'dns_external.server.ip.primary', raw: '$capability:dns_external.server.ip.primary', }, context, db, ); expect(result1.success).toBe(true); if (result1.success) { expect(result1.value).toBe('203.0.113.42'); } const result2 = await resolveVariable( { type: 'capability', path: 'dns_external.tsig.key_name', raw: '$capability:dns_external.tsig.key_name', }, context, db, ); expect(result2.success).toBe(true); if (result2.success) { expect(result2.value).toBe('celilo-ddns'); } }); test('should error when provider module has not configured the required value', async () => { const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-')); testDirs.push(testDir); const testDbPath = join(testDir, 'test.db'); const db = createDbClient({ path: testDbPath }); // Create provider module db.$client .prepare( `INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)`, ) .run( 'dns-external', 'DNS External', '1.0.0', '/tmp/modules/dns-external', 'CONFIGURED', JSON.stringify({ id: 'dns-external', name: 'DNS External', version: '1.0.0' }), ); // Register capability with unresolved variable db.$client .prepare( `INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)`, ) .run( 'dns-external', 'dns_external', '1.0.0', JSON.stringify({ server: { ip: { primary: '$self:ip.primary' } }, }), ); // Provider module config is MISSING the required value const context: ResolutionContext = { moduleId: 'caddy', selfConfig: {}, systemConfig: {}, secrets: {}, systemSecrets: {}, capabilities: { dns_external: { server: { ip: { primary: '$self:ip.primary' } }, }, }, }; const result = await resolveVariable( { type: 'capability', path: 'dns_external.server.ip.primary', raw: '$capability:dns_external.server.ip.primary', }, context, db, ); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain("has not configured 'ip.primary'"); } }); test('buildResolutionContext resolves capability-derived variable even when DB has raw unresolved $self: value', async () => { // Regression: hook config was assembled from raw DB reads without applying // capability derivation. When lunacycle stores primary_domain as "$self:primary_domain" // (because the DB guard skipped persisting the resolved value), the hook // received the literal template string instead of "iamtheinternet.org". // After the fix, buildResolutionContext always returns the resolved value in selfConfig. const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-raw-')); testDirs.push(testDir); const db = createDbClient({ path: join(testDir, 'test.db') }); db.$client .prepare( 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)', ) .run( 'namecheap', 'Namecheap', '1.0.0', '/tmp/modules/namecheap', 'VERIFIED', JSON.stringify({ id: 'namecheap', name: 'Namecheap', version: '1.0.0' }), ); db.$client .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)') .run('namecheap', 'primary_domain', 'iamtheinternet.org'); db.$client .prepare( 'INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)', ) .run( 'namecheap', 'dns_registrar', '2.0.0', JSON.stringify({ primary_domain: '$self:primary_domain' }), ); const consumerManifest = { id: 'lunacycle', name: 'LunaCycle', version: '1.0.0', celilo_contract: '1.0', variables: { owns: [ { name: 'primary_domain', type: 'string', required: false, source: 'capability', derive_from: '$capability:dns_registrar.primary_domain', }, ], }, }; db.$client .prepare( 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)', ) .run( 'lunacycle', 'LunaCycle', '1.0.0', '/tmp/modules/lunacycle', 'INSTALLED', JSON.stringify(consumerManifest), ); // Simulate raw DB state: primary_domain stored as unresolved template string db.$client .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)') .run('lunacycle', 'primary_domain', '$self:primary_domain'); const context = await buildResolutionContext('lunacycle', db); // selfConfig should have the resolved value, not the raw DB "$self:primary_domain" expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org'); // Simulate the hook installConfigMap merge logic from module-deploy.ts: // Raw DB read produces the unresolved string; the fix overwrites it from context.selfConfig const installConfigMap: Record = { primary_domain: '$self:primary_domain' }; for (const [key, value] of Object.entries(context.selfConfig)) { if ( !(key in installConfigMap) || (typeof installConfigMap[key] === 'string' && (installConfigMap[key] as string).startsWith('$')) ) { installConfigMap[key] = value; } } expect(installConfigMap.primary_domain).toBe('iamtheinternet.org'); }); test('hook config merge does not overwrite user-set values with capability-derived ones', async () => { // If a user explicitly sets a value that happens to match a capability-derived variable, // their value (which does NOT start with $) should be preserved. const installConfigMap: Record = { primary_domain: 'my-custom-domain.com', // user-set, no $ prefix other_key: '$self:unresolved', // raw template — should be overwritten }; const resolvedSelfConfig: Record = { primary_domain: 'iamtheinternet.org', // capability-derived value other_key: 'resolved-value', new_key: 'auto-derived', }; for (const [key, value] of Object.entries(resolvedSelfConfig)) { if ( !(key in installConfigMap) || (typeof installConfigMap[key] === 'string' && (installConfigMap[key] as string).startsWith('$')) ) { installConfigMap[key] = value; } } // User-set value preserved (no $ prefix → not overwritten) expect(installConfigMap.primary_domain).toBe('my-custom-domain.com'); // Unresolved template overwritten expect(installConfigMap.other_key).toBe('resolved-value'); // New keys from context added expect(installConfigMap.new_key).toBe('auto-derived'); }); test('buildResolutionContext resolves $self: refs in capability data for consumer module', async () => { // Regression test: lunacycle's primary_domain (source: capability, derive_from: // "$capability:dns_registrar.primary_domain") should resolve to the provider's // actual configured value, not the raw "$self:primary_domain" template string. const testDir = mkdtempSync(join(tmpdir(), 'test-cap-self-ctx-')); testDirs.push(testDir); const db = createDbClient({ path: join(testDir, 'test.db') }); // Provider module (namecheap) with primary_domain configured db.$client .prepare( 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)', ) .run( 'namecheap', 'Namecheap', '1.0.0', '/tmp/modules/namecheap', 'VERIFIED', JSON.stringify({ id: 'namecheap', name: 'Namecheap', version: '1.0.0' }), ); db.$client .prepare('INSERT INTO module_configs (module_id, key, value) VALUES (?, ?, ?)') .run('namecheap', 'primary_domain', 'iamtheinternet.org'); // Capability data stored with unresolved $self: reference (as registered at import time) db.$client .prepare( 'INSERT INTO capabilities (module_id, capability_name, version, data) VALUES (?, ?, ?, ?)', ) .run( 'namecheap', 'dns_registrar', '2.0.0', JSON.stringify({ provider: 'namecheap', primary_domain: '$self:primary_domain' }), ); // Consumer module (lunacycle) with capability-derived primary_domain const consumerManifest = { id: 'lunacycle', name: 'LunaCycle', version: '1.0.0', celilo_contract: '1.0', variables: { owns: [ { name: 'primary_domain', type: 'string', required: false, source: 'capability', derive_from: '$capability:dns_registrar.primary_domain', }, ], }, }; db.$client .prepare( 'INSERT INTO modules (id, name, version, source_path, state, manifest_data) VALUES (?, ?, ?, ?, ?, ?)', ) .run( 'lunacycle', 'LunaCycle', '1.0.0', '/tmp/modules/lunacycle', 'INSTALLED', JSON.stringify(consumerManifest), ); const context = await buildResolutionContext('lunacycle', db); // primary_domain should be resolved to the provider's actual value expect(context.selfConfig.primary_domain).toBe('iamtheinternet.org'); }); });