/** * The recurrence gate for celilo#1072. * * celilo could name every provider a module MIGHT be bound to and none of the * ones it IS. The live case: `tango-nexus` declares four optional capabilities * (external_web, public_web, source_forge, registry_publish), is deployed * against one off-fleet host, and nothing recorded which of the four was real. * * This models that shape with two capabilities the loader injects and one the * consumer calls. The gate is the NEGATIVE half: the capability that was * injected and never used must report zero bindings. Anything that records at * resolution time — which is where the obvious fix goes — passes the positive * assertion and fails this one. * * Two different flavours of over-recording are caught here, and the second is * easy to miss when reading this file as being about one thing. The first is a * consumer credited with a capability it never called. The second is the * self-binding case: `dhcp-provider` running its OWN hook is also handed * `notification` from `notify-provider`, because the loader hands over * everything registered. Resolution-time recording books that as a binding * `dhcp-provider` never made, so the "not bound to itself" test fails for a * reason that has nothing to do with self-binding. */ 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 } from '@celilo/capabilities'; import type { DbClient } from '../db/client'; import { listCapabilityBindings } from '../services/capability-bindings'; 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() {}, }; const DHCP_MODULE = ` export default function createDhcpServer(context) { return { async setDnsServers() {}, async getDnsServers() { return [context.config.marker]; }, async setDomainName() {}, async getDomainName() { return context.config.marker; }, }; } `; const NOTIFICATION_MODULE = ` export default function createNotification() { return { async send() { return { delivered: true }; }, }; } `; function installProvider( db: DbClient, tempDir: string, moduleId: string, script: string, source: string, capabilityName: string, ): void { const modulePath = join(tempDir, moduleId); const scriptsDir = join(modulePath, 'scripts'); mkdirSync(scriptsDir, { recursive: true }); writeFileSync(join(scriptsDir, script), source); 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); db.$client.run( `INSERT INTO capabilities (module_id, capability_name, version, data, zones, registered_at) VALUES ('${moduleId}', '${capabilityName}', '1.0.0', '{}', NULL, unixepoch())`, ); } describe('capability bindings recorded from the loader', () => { let db: DbClient; let tempDir: string; beforeEach(async () => { db = await setupTestDatabase(); // `setupTestDatabase` does not run `PRAGMA foreign_keys = ON` and // `db/client.ts:70` does, so every cascade in the schema is enforced in // production and off in the suite (celilo#1074). Enabled here so the // cascade assertion below measures the real behaviour. Delete this line // when #1074 lands. db.$client.run('PRAGMA foreign_keys = ON'); tempDir = join(tmpdir(), `celilo-binding-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(tempDir, { recursive: true }); installProvider( db, tempDir, 'dhcp-provider', 'dhcp-server-functions.ts', DHCP_MODULE, 'dhcp_server', ); installProvider( db, tempDir, 'notify-provider', 'notification.ts', NOTIFICATION_MODULE, 'notification', ); db.$client.run( `INSERT INTO modules (id, name, version, source_path, manifest_data) VALUES ('consumer', 'consumer', '1.0.0', '${tempDir}', '{}')`, ); }); afterEach(async () => { await cleanupTestDatabase(db); }); test('an injected capability the consumer never calls reports zero bindings', async () => { const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger); // Both are injected — the loader hands over every registered capability // regardless of what the consumer declared. expect(capabilities.dhcp_server).toBeTruthy(); expect(capabilities.notification).toBeTruthy(); await (capabilities.dhcp_server as { getDomainName(): Promise }).getDomainName(); const bindings = listCapabilityBindings(db, 'consumer'); expect(bindings.map((b) => b.capabilityName)).toEqual(['dhcp_server']); expect(bindings[0].providerModuleId).toBe('dhcp-provider'); }); test('a redeploy re-asserts the binding rather than duplicating it', async () => { for (let i = 0; i < 3; i++) { const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger); await (capabilities.dhcp_server as { getDomainName(): Promise }).getDomainName(); } expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1); }); test('the binding dies with the consumer', async () => { const capabilities = await loadCapabilityFunctions('consumer', db, noopLogger); await (capabilities.dhcp_server as { getDomainName(): Promise }).getDomainName(); expect(listCapabilityBindings(db, 'consumer')).toHaveLength(1); db.$client.run(`DELETE FROM modules WHERE id = 'consumer'`); expect(listCapabilityBindings(db, 'consumer')).toHaveLength(0); }); test('a provider consuming its own capability is not bound to itself', async () => { const capabilities = await loadCapabilityFunctions('dhcp-provider', db, noopLogger); await (capabilities.dhcp_server as { getDomainName(): Promise }).getDomainName(); expect(listCapabilityBindings(db, 'dhcp-provider')).toHaveLength(0); }); });