/** * Tests for capability function loader */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HookLogger, RouteReadView } from '@celilo/capabilities'; import { and, eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { moduleConfigs } from '../db/schema'; import { upsertModuleConfig } from '../services/module-config'; import { cleanupTestDatabase, setupTestDatabase } from '../test-utils/database'; import { loadCapabilityFunctions } from './capability-loader'; const noopLogger: HookLogger = { info() {}, warn() {}, error() {}, success() {}, }; // Minimal test module that exports a default factory const TEST_REGISTER_HOST_MODULE = ` export default function registerHost(context) { return { host: context.config.primary_domain, hasSecrets: !!context.secrets.ddns_password, }; } `; const TEST_DHCP_SERVER_MODULE = ` export default function createDhcpServer(context) { return { async setDnsServers() {}, async getDnsServers() { return [context.config.marker]; }, async setDomainName() {}, async getDomainName() { return context.config.marker; }, }; } `; /** * A factory that WRITES through the hook-owned-state accessor, so the loader's * store view is proven end to end: the method's write lands as a hook-owned * row, and an undeclared name throws instead of landing. Branded * (defineCapabilityFunction, the Phase 8 pattern) — the branded path is the * one the loader hands the store views to. The temp dir the fixture is * dropped into has no node_modules link, so the import uses the workspace * package by absolute path, the same trick capability-loader-firewall uses. */ const TEST_STORE_WRITING_DHCP_MODULE = ` import { defineCapabilityFunction } from '${join(__dirname, '../../../..', 'packages/capabilities/src/index.ts')}'; export default defineCapabilityFunction({ capability: 'dhcp_server', handler: (context) => ({ async setDnsServers() {}, async getDnsServers() { return [context.config.marker]; }, async setDomainName() {}, async getDomainName() { return context.config.marker; }, async claimIp() { await context.config.set('public_ip', '203.0.113.7'); return 'claimed'; }, async claimUndeclared() { await context.config.set('no_such_key', 'x'); }, }), }); `; describe('Capability Loader', () => { let db: DbClient; let tempDir: string; beforeEach(async () => { db = await setupTestDatabase(); tempDir = join( tmpdir(), `celilo-cap-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); mkdirSync(tempDir, { recursive: true }); }); afterEach(async () => { await cleanupTestDatabase(db); }); test('returns empty object when no capabilities are registered', async () => { const result = await loadCapabilityFunctions('test-module', db, noopLogger); expect(result).toEqual({}); }); test('returns empty object when capability is registered but module does not exist', async () => { const modulePath = join(tempDir, 'namecheap'); mkdirSync(modulePath, { recursive: true }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '2.0.0', '${modulePath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '2.0.0', '{}', unixepoch())`, ); const result = await loadCapabilityFunctions('caddy', db, noopLogger); expect(result).toEqual({}); }); test('loads dns_registrar capability when module, config, and secrets exist', async () => { // Create module directory with register-host module const modulePath = join(tempDir, 'namecheap'); const scriptsDir = join(modulePath, 'scripts'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'register-host.ts'), TEST_REGISTER_HOST_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '2.0.0', '${modulePath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '2.0.0', '{}', unixepoch())`, ); upsertModuleConfig(db, 'namecheap', 'primary_domain', 'example.com'); // Set encrypted secrets const { encryptSecret } = await import('../secrets/encryption'); const { getOrCreateMasterKey } = await import('../secrets/master-key'); const masterKey = await getOrCreateMasterKey(); const ddnsPasswordEnc = encryptSecret('test-ddns-password', masterKey); db.$client.run( `INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('namecheap', 'ddns_password', '${ddnsPasswordEnc.encryptedValue}', '${ddnsPasswordEnc.iv}', '${ddnsPasswordEnc.authTag}')`, ); const result = await loadCapabilityFunctions('caddy', db, noopLogger); expect(result).toHaveProperty('dns_registrar'); expect(result.dns_registrar).toBeTruthy(); }); test('prefers a provider explicitly scoped to the well-known capability zone', async () => { for (const moduleId of ['upstream-dhcp', 'internal-dhcp']) { const modulePath = join(tempDir, moduleId); const scriptsDir = join(modulePath, 'scripts'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${moduleId}', '${moduleId}', '1.0.0', '${modulePath}', '{}')`, ); upsertModuleConfig(db, moduleId, 'marker', moduleId); } // Insert the zone-agnostic upstream provider first to prove selection is // policy-driven rather than database-order-driven. db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('internal-dhcp', 'dhcp_server', '1.0.0', '{}', '["internal"]', unixepoch())`, ); const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger); const dhcp = result.dhcp_server as { getDomainName(): Promise }; expect(await dhcp.getDomainName()).toBe('internal-dhcp'); }); test('a provider factory writes hook-owned config through the injected store view', async () => { // The store view must do more than carry the plain record a factory reads. // A method writing `context.config.set` lands a hook-owned row (validated // against the PROVIDER's manifest, source column 'hook'), and an // undeclared name throws instead of writing a phantom row. const modulePath = join(tempDir, 'storewriting-vpn'); const scriptsDir = join(modulePath, 'scripts'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_STORE_WRITING_DHCP_MODULE); const manifest = JSON.stringify({ variables: { owns: [{ name: 'public_ip', type: 'string', source: 'hook' }] }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('storewriting-vpn', 'StoreWritingVpn', '1.0.0', '${modulePath}', '${manifest.replace(/'/g, "''")}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('storewriting-vpn', 'dhcp_server', '1.0.0', '{}', unixepoch())`, ); upsertModuleConfig(db, 'storewriting-vpn', 'marker', 'storewriting-vpn'); const result = await loadCapabilityFunctions('store-consumer', db, noopLogger); const dhcp = result.dhcp_server as { claimIp(): Promise; claimUndeclared(): Promise; }; expect(await dhcp.claimIp()).toBe('claimed'); const row = db .select() .from(moduleConfigs) .where( and(eq(moduleConfigs.moduleId, 'storewriting-vpn'), eq(moduleConfigs.key, 'public_ip')), ) .get(); expect(row?.valueJson).toBe('"203.0.113.7"'); expect(row?.source).toBe('hook'); // The store round-trips text, so the map read the NEXT load takes sees the // same string the write sent. const reloaded = await loadCapabilityFunctions('store-consumer', db, noopLogger); const dhcpAgain = reloaded.dhcp_server as { getDnsServers(): Promise }; expect(await dhcpAgain.getDnsServers()).toEqual(['storewriting-vpn']); await expect(dhcp.claimUndeclared()).rejects.toThrow(/no declared hook-owned config/); }); test('falls back to a zone-agnostic provider when no explicit zone provider exists', async () => { const modulePath = join(tempDir, 'upstream-dhcp'); const scriptsDir = join(modulePath, 'scripts'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, 'dhcp-server-functions.ts'), TEST_DHCP_SERVER_MODULE); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('upstream-dhcp', 'upstream-dhcp', '1.0.0', '${modulePath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('upstream-dhcp', 'dhcp_server', '1.0.0', '{}', NULL, unixepoch())`, ); upsertModuleConfig(db, 'upstream-dhcp', 'marker', 'upstream-dhcp'); const result = await loadCapabilityFunctions('dns-consumer', db, noopLogger); const dhcp = result.dhcp_server as { getDomainName(): Promise }; expect(await dhcp.getDomainName()).toBe('upstream-dhcp'); }); test('injects a read-only web_routes view into the public_web provider own hooks (ISS-0035)', async () => { const modulePath = join(tempDir, 'caddy'); mkdirSync(modulePath, { recursive: true }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${modulePath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`, ); // The two route CONSUMERS, as real rows. `web_routes.module_id` is a foreign // key onto `modules`, and seeding a route without its module was accepted // only because the test helper ran with foreign keys off (celilo#1074). for (const consumer of ['apt-repo', 'authentik']) { db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('${consumer}', '${consumer}', '1.0.0', '${tempDir}/${consumer}', '{}')`, ); } db.$client.run( `INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('apt--root', 'apt-repo', 'reverse_proxy', '/', 'apt.example.com', '10.0.20.50', 8080, 0)`, ); db.$client.run( `INSERT INTO web_routes (slug, module_id, type, path, hostname, target_host, target_port, websocket) VALUES ('auth--root', 'authentik', 'reverse_proxy', '/', 'auth.example.com', '10.0.20.51', 9000, 0)`, ); // caddy running its OWN hook gets a read-only view of the route registry. const provider = await loadCapabilityFunctions('caddy', db, noopLogger); expect(provider).toHaveProperty('web_routes'); const view = provider.web_routes as RouteReadView; const all = await view.getAllRoutes(); expect(all).toHaveLength(2); expect(all.map((r) => r.hostname).sort()).toEqual(['apt.example.com', 'auth.example.com']); expect((await view.getRoutes('apt-repo')).map((r) => r.hostname)).toEqual(['apt.example.com']); // A consumer (not the provider) never sees the route table. const consumer = await loadCapabilityFunctions('apt-repo', db, noopLogger); expect(consumer).not.toHaveProperty('web_routes'); }); // ce-iku regression: public_web's managed-domain check must reflect the // registrar's DECLARED `domain_list` computed field (namecheap: // keys(secret.ddns_passwords)) — the same live set DDNS validation sees — // NOT a stale `config.domains` row that survived an older manifest version. // Without the fix the config-shape heuristic short-circuits on the stale // config.domains and a just-added domain (present only in the secret) is // silently excluded, dead-ending register_route forever. test('public_web managed-domains come from the registrar domain_list computed field, not a stale config.domains', async () => { const { encryptSecret } = await import('../secrets/encryption'); const { getOrCreateMasterKey } = await import('../secrets/master-key'); const { isMissingProviderInputError } = await import('@celilo/capabilities'); const masterKey = await getOrCreateMasterKey(); // Provider: caddy (public_web). Needs ≥1 configured hostname + target_ip // for createPublicWeb to build. const caddyPath = join(tempDir, 'caddy'); mkdirSync(caddyPath, { recursive: true }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('caddy', 'Caddy', '1.0.0', '${caddyPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('caddy', 'public_web', '1.0.0', '{}', unixepoch())`, ); upsertModuleConfig(db, 'caddy', 'hostnames', ['seed.celilo.computer']); upsertModuleConfig(db, 'caddy', 'target_ip', '10.0.20.10'); // Provider: namecheap (dns_registrar). Its `data` declares the canonical // domain_list computed field. config.domains is STALE (missing the newly // onboarded domain); the SECRET holds the real, current set. const ncPath = join(tempDir, 'namecheap'); mkdirSync(ncPath, { recursive: true }); const registrarData = JSON.stringify({ provider: 'namecheap', domain_list: { __celilo_computed__: 'keys(secret.ddns_passwords)' }, }); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('namecheap', 'Namecheap', '3.2.0', '${ncPath}', '{}')`, ); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, registered_at) VALUES ('namecheap', 'dns_registrar', '4.0.0', '${registrarData}', unixepoch())`, ); // Stale config that must be IGNORED — omits buildyourowninternet.dev. upsertModuleConfig(db, 'namecheap', 'domains', ['celilo.computer']); // Live secret — the source of truth — includes the new domain. const ddnsEnc = encryptSecret( JSON.stringify({ 'celilo.computer': 'pw1', 'buildyourowninternet.dev': 'pw2' }), masterKey, ); db.$client.run( `INSERT INTO secrets (module_id, name, encrypted_value, iv, auth_tag) VALUES ('namecheap', 'ddns_passwords', '${ddnsEnc.encryptedValue}', '${ddnsEnc.iv}', '${ddnsEnc.authTag}')`, ); const consumer = await loadCapabilityFunctions('byoi', db, noopLogger); const publicWeb = consumer.public_web as { register_route: (r: { type: string; path: string; hostname: string }) => Promise; }; expect(publicWeb).toBeTruthy(); // Does register_route reject specifically because the hostname's apex // isn't in any managed domain? (Later reconcile errors are a different // failure — the domain check already passed by then.) async function rejectsAsMissingProvider(hostname: string): Promise { try { await publicWeb.register_route({ type: 'static', path: '/', hostname }); return false; } catch (err) { return isMissingProviderInputError(err); } } // In the secret (domain_list) but NOT in the stale config.domains — must // pass the managed-domain check. expect(await rejectsAsMissingProvider('www.buildyourowninternet.dev')).toBe(false); // In neither the secret nor config — control: must still be rejected. expect(await rejectsAsMissingProvider('app.notmanaged.example')).toBe(true); }); });