import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules, systemConfig } from '../db/schema'; import type { ContractHookSignature } from '../manifest/contracts'; import { resetTestDbPath } from '../test-utils/db-path'; import { checkRequiredCapabilities, declaredPathInputs, executeHookScript, invokeHook, resolveHookScript, resolveHookTimeouts, validateHookInputs, validateHookOutputs, } from './executor'; import { readJailMode } from './jail'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext, HookDefinition } from './types'; const FIXTURES_DIR = join(__dirname, 'test-fixtures'); const EMPTY_SIG: ContractHookSignature = { inputs: {}, outputs: {} }; const VPS_IP_INPUT_SIG: ContractHookSignature = { inputs: { vps_ip: { required: true }, hostname: { required: true } }, outputs: {}, }; const API_KEY_OUTPUT_SIG: ContractHookSignature = { inputs: {}, outputs: { api_key: { required: true } }, }; describe('Hook Executor', () => { describe('validateHookInputs', () => { test('passes when no inputs required', () => { expect(validateHookInputs(EMPTY_SIG, {})).toBeNull(); }); test('passes when all inputs provided', () => { expect( validateHookInputs(VPS_IP_INPUT_SIG, { vps_ip: '1.2.3.4', hostname: 'test' }), ).toBeNull(); }); test('fails when inputs are missing', () => { const result = validateHookInputs(VPS_IP_INPUT_SIG, { vps_ip: '1.2.3.4' }); expect(result).toBe('Missing required hook inputs: hostname'); }); test('fails when multiple inputs are missing', () => { const sig: ContractHookSignature = { inputs: { a: { required: true }, b: { required: true }, c: { required: true } }, outputs: {}, }; const result = validateHookInputs(sig, {}); expect(result).toBe('Missing required hook inputs: a, b, c'); }); test('ignores optional inputs', () => { const sig: ContractHookSignature = { inputs: { a: { required: true }, b: { required: false } }, outputs: {}, }; expect(validateHookInputs(sig, { a: '1' })).toBeNull(); }); }); describe('validateHookOutputs', () => { test('passes when no outputs required', () => { expect(validateHookOutputs(EMPTY_SIG, {})).toBeNull(); }); test('passes when all outputs present', () => { expect(validateHookOutputs(API_KEY_OUTPUT_SIG, { api_key: 'abc123' })).toBeNull(); }); test('fails when outputs are missing', () => { const sig: ContractHookSignature = { inputs: {}, outputs: { api_key: { required: true }, token: { required: true } }, }; const result = validateHookOutputs(sig, { api_key: 'abc123' }); expect(result).toBe('Hook did not return required outputs: token'); }); test('ignores optional outputs', () => { const sig: ContractHookSignature = { inputs: {}, outputs: { api_key: { required: true }, optional: { required: false } }, }; expect(validateHookOutputs(sig, { api_key: 'abc123' })).toBeNull(); }); }); describe('resolveHookScript', () => { test('resolves relative path within module directory', () => { const result = resolveHookScript('/modules/namecheap', './scripts/setup.js'); expect(result).toBe('/modules/namecheap/scripts/setup.js'); }); test('rejects path that escapes module directory', () => { expect(() => { resolveHookScript('/modules/namecheap', '../../etc/passwd'); }).toThrow('Hook script path escapes module directory'); }); test('resolves nested paths correctly', () => { const result = resolveHookScript('/modules/namecheap', './scripts/hooks/setup.ts'); expect(result).toBe('/modules/namecheap/scripts/hooks/setup.ts'); }); }); describe('executeHookScript', () => { test('executes successful hook and returns outputs', async () => { const { logger, messages } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'success-hook.ts'); const result = await executeHookScript(scriptPath, { config: configStore({ username: 'testuser' }), secrets: secretStore({ password: 'secret' }), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, vps_ip: '10.0.0.1', }); // Void hook: no outputs. The envelope carries an empty record. expect(result).toEqual({}); expect(messages).toContainEqual({ level: 'info', message: 'Starting test hook' }); expect(messages).toContainEqual({ level: 'success', message: 'Test hook completed' }); }); test('throws when script does not exist', async () => { const { logger } = createCapturingLogger(); await expect( executeHookScript('/nonexistent/hook.ts', { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }), ).rejects.toThrow('Hook script not found'); }); test('throws when script has no default export', async () => { const { logger } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'no-default-hook.ts'); await expect( executeHookScript(scriptPath, { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }), ).rejects.toThrow('must export a default function'); }); test('throws when default export is not branded by defineHook', async () => { // HOOK_API_V2 Phase 8 / D8: the executor only accepts hook scripts // wrapped with defineHook, so they carry the CELILO_HOOK_BRAND // symbol the executor checks via isCompiledHook. A raw async // function default export is rejected with a clear migration hint. const { logger } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'unbranded-hook.ts'); await expect( executeHookScript(scriptPath, { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }), ).rejects.toThrow('does not use defineHook()'); }); test('handles hook that returns nothing', async () => { const { logger } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'void-hook.ts'); const result = await executeHookScript(scriptPath, { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }); expect(result).toEqual({}); }); test('propagates hook errors', async () => { const { logger } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'failing-hook.ts'); await expect( executeHookScript(scriptPath, { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }), ).rejects.toThrow('Hook execution failed: simulated error'); }); // celilo#622: the idle bound must be the caller's to set. The idle timer // polls every 5s, so this costs ~6s of wall clock — it is the only // assertion here that touches a real clock, which is why the *decision* // lives in the pure resolveHookTimeouts below. test('honors a caller-supplied idle timeout instead of the 30s default', async () => { const { logger } = createCapturingLogger(); const scriptPath = join(FIXTURES_DIR, 'silent-hook.ts'); const context: HookContext = { config: configStore({ silent_ms: 8000 }), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }; // 8s of silence against a 1ms idle bound: killed at the first poll. // Under the old hardcoded 30s idle this resolved instead. await expect( executeHookScript(scriptPath, context, { timeoutMs: 60_000, idleTimeoutMs: 1 }), ).rejects.toThrow('idle timeout exceeded'); }, 20_000); }); describe('declaredPathInputs', () => { // The jail's bind mounts come from this, so a wrong answer here is a hook // that cannot read a file it was handed — or one that can read a file // nobody declared. const SIG: ContractHookSignature = { inputs: { backup_dir: { required: true, path: { access: 'write' } }, artifact_path: { required: true, path: { access: 'read' } }, artifact_count: { required: false }, }, outputs: {}, }; test('walks the declaration and carries the declared access', () => { expect( declaredPathInputs(SIG, { backup_dir: '/tmp/stage/data', artifact_path: '/tmp/stage/db.sqlite', artifact_count: 3, }), ).toEqual([ { name: 'backup_dir', value: '/tmp/stage/data', access: 'write' }, { name: 'artifact_path', value: '/tmp/stage/db.sqlite', access: 'read' }, ]); }); test('a path-shaped input the contract does not declare contributes nothing', () => { // `db_path` was passed by backup-create.ts for months and declared // nowhere (celilo#1118). The derivation walks declarations, so the jail // withholds it — which is the correct outcome and the reason the // contract has to declare what it passes. expect(declaredPathInputs(SIG, { db_path: '/var/celilo/celilo.db' })).toEqual([]); }); test('a name that merely LOOKS like a path is not one', () => { // Never inferred from the name. A heuristic silently changes what a hook // can reach the day somebody adds an input called `workspace`. const noPaths: ContractHookSignature = { inputs: { restore_dir: { required: true } }, outputs: {}, }; expect(declaredPathInputs(noPaths, { restore_dir: '/tmp/x' })).toEqual([]); }); test('a declared input this run did not receive is skipped', () => { expect(declaredPathInputs(SIG, {})).toEqual([]); }); }); describe('the recorded jail mode describes the HOST', () => { test('an invocation with no module tree records nothing', async () => { // Such a run is unjailable whatever the host can do, so recording it // would overwrite a `jailed` record with an `unjailed` one — exactly the // transition a self-monitor raises an alert on (design D8, task 4.4). const store = join(mkdtempSync(join(tmpdir(), 'celilo-mode-')), 'mode.json'); const saved = process.env.CELILO_HOOK_JAIL_MODE_PATH; process.env.CELILO_HOOK_JAIL_MODE_PATH = store; try { const { logger } = createCapturingLogger(); await executeHookScript(join(FIXTURES_DIR, 'void-hook.ts'), { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }); expect(readJailMode()).toBeUndefined(); expect(existsSync(store)).toBe(false); } finally { if (saved === undefined) delete process.env.CELILO_HOOK_JAIL_MODE_PATH; else process.env.CELILO_HOOK_JAIL_MODE_PATH = saved; } }); }); describe('the executor resolves the jail policy through stored config (hook-jail-config-surface slice 4)', () => { // Each test gets its own migrated scratch database and its own jail-mode // store, so a planted row here never leaks into another test (or another // host). With no module tree in `options.jail`, `planJailedSpawn` treats a // `required` policy as a hard failure on EVERY host — which makes the // resolution outcome observable without depending on what jail backend a // given machine happens to have. let dir: string; const saved: Record = {}; const plantPolicyRow = (value: string): void => { getDb().insert(systemConfig).values({ key: 'hooks.jail_policy', value }).run(); }; // The per-module row is keyed by module id, but the executor is handed // the module's tree — the join the production read makes (task 1.4). The // path is fake: nothing in this describe touches the filesystem beyond // the scratch DB, and `existsSync(scriptPath)` fails before any policy // read when it is not fake. const moduleTree = (): string => join(dir, 'modules', 'fixture-module'); const plantModulePolicyRow = (moduleId: string, policy: string): void => { getDb() .insert(modules) .values({ id: moduleId, name: moduleId, version: '1.0.0', manifestData: {}, sourcePath: moduleTree(), }) .run(); // Raw SQL, not the drizzle table, on purpose: the drizzle type only // accepts 'auto' | 'off' | 'required', and the bad-value test models a // row that arrived past every typed writer — a restore or a hand edit, // the untrusted path the resolver throws on (Rule 3.7). getDb() .$client.prepare('INSERT INTO module_jail_policies (module_id, policy) VALUES (?, ?)') .run(moduleId, policy); }; const runVoidHook = (moduleTree?: string): Promise> => { const { logger } = createCapturingLogger(); return executeHookScript( join(FIXTURES_DIR, 'void-hook.ts'), { config: configStore(), secrets: secretStore(), systems: [], logger, debug: false, screenshotDir: '/tmp', stateDir: '/tmp', capabilities: {}, }, { // Absent means the system-config-only path: exactly what the tests // above exercise. Present means the executor reads the module's row // and resolves through it. jail: moduleTree === undefined ? undefined : { modulePath: moduleTree, pathInputs: [] }, }, ); }; beforeEach(async () => { for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) { saved[key] = process.env[key]; } dir = mkdtempSync(join(tmpdir(), 'celilo-executor-policy-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); process.env.CELILO_HOOK_JAIL_MODE_PATH = join(dir, 'mode.json'); delete process.env.CELILO_HOOK_JAIL; await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); // The db path resets to the scratch path rather than restoring the // saved value: that value may be another suite's temp database, or // unset — which sends the next var-less reader to the operator's real // celilo.db (celilo#1315). resetTestDbPath(); for (const key of ['CELILO_HOOK_JAIL', 'CELILO_HOOK_JAIL_MODE_PATH']) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; } rmSync(dir, { recursive: true, force: true }); }); test('a stored row decides when the env is silent', async () => { plantPolicyRow('required'); await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/); }); test('env wins over a conflicting stored row', async () => { process.env.CELILO_HOOK_JAIL = 'off'; plantPolicyRow('required'); await expect(runVoidHook()).resolves.toBeDefined(); }); test('env=required still reaches the planner when the row says off', async () => { process.env.CELILO_HOOK_JAIL = 'required'; plantPolicyRow('off'); await expect(runVoidHook()).rejects.toThrow(/no hook jail is available/); }); test('a bad stored row fails the hook instead of running it unjailed', async () => { plantPolicyRow('alwayssafe'); await expect(runVoidHook()).rejects.toThrow(/hooks\.jail_policy='alwayssafe'/); }); test('nothing set resolves to the default, off (peba’s ruling on ce-rez7)', async () => { // Migrated DB with no row: the stored-row read returns undefined and the // resolver falls through to the default, which jails nobody. await expect(runVoidHook()).resolves.toBeDefined(); }); test('the module row beats system config (per-module-jail-policy task 1.4)', async () => { // System says jail hard; the module is exempted. The exempted module's // hook runs unjailed while every other module of this host would fail — // the spec's own "A module runs unjailed while the fleet is jailed". plantPolicyRow('required'); plantModulePolicyRow('exempted-module', 'off'); await expect(runVoidHook(moduleTree())).resolves.toBeDefined(); // And the same host still jails a module with no row: `required` with // no jail backend is a hard failure, so the system row stays in force. // Which surface the failure takes is host-determined, and both prove // the hook did not run: on a host with a jail backend it dies at mount // planning (the fake module tree does not exist, "cannot be built"); // on a host with no backend it dies at backend detection ("no hook // jail is available"). The point under test is policy precedence, not // the surface. (celilo#1342: the CI runner has no bubblewrap, so the // mount-planning surface is unreachable there.) await expect(runVoidHook(join(dir, 'modules', 'other-module'))).rejects.toThrow( /cannot be built|no hook jail is available/, ); }); test('a bad per-module row fails the hook instead of running it unjailed', async () => { plantModulePolicyRow('broken-module', 'alwayssafe'); await expect(runVoidHook(moduleTree())).rejects.toThrow(/module's jail policy='alwayssafe'/); }); test('env beats a conflicting module row', async () => { plantModulePolicyRow('exempted-module', 'off'); process.env.CELILO_HOOK_JAIL = 'required'; // The point is that the env overrode the module's `off`, so the hook // is jailed and fails, not exempted and successful. Which failure it // hits is host-determined: mount planning (the fake module tree does // not exist) on a host with a jail backend, backend detection on a // host without one. Either proves the hook did not run. await expect(runVoidHook(moduleTree())).rejects.toThrow( /cannot be built|no hook jail is available/, ); }); }); describe('resolveHookTimeouts', () => { test('no declaration: 60s total, 30s idle heuristic', () => { expect(resolveHookTimeouts(undefined, false)).toEqual({ timeoutMs: 60_000, idleTimeoutMs: 30_000, }); }); // The bug in one assertion: namecheap declares 120000 and must get 120s, // not 60s — and must NOT still be killed at 30s of silence, which would // honor the declaration in name only and leave it exactly as broken. test('a declared timeout sets the total AND replaces the idle heuristic', () => { expect(resolveHookTimeouts(120_000, false)).toEqual({ timeoutMs: 120_000, idleTimeoutMs: 120_000, }); }); test('a declared timeout shorter than the idle default still wins', () => { expect(resolveHookTimeouts(5_000, false)).toEqual({ timeoutMs: 5_000, idleTimeoutMs: 5_000, }); }); test('debug mode overrides any declaration', () => { expect(resolveHookTimeouts(120_000, true)).toEqual({ timeoutMs: 600_000, idleTimeoutMs: 600_000, }); }); }); describe('invokeHook artifacts', () => { test('collects EVERY file the hook wrote, not just the newest .png', async () => { const { logger } = createCapturingLogger(); const result = await invokeHook( __dirname, 'container_created', '1.0', { script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 }, { vps_ip: '10.0.0.5' }, {}, {}, logger, ); expect(result.success).toBe(true); const names = (result.artifactPaths ?? []).map((p) => p.split('/').pop()); // The old behaviour returned exactly one of these — the .png — and // threw away the DOM and the request log, which is the least useful // third of a post-mortem on its own. expect(names).toEqual(['spa-failure.html', 'spa-failure.png', 'spa-failure.requests.txt']); // Per-run, so a consumer writing FIXED filenames cannot overwrite its // own previous run — which is what makes retention meaningful. const dir = (result.artifactPaths ?? [])[0]; expect(dir).toContain('/screenshots/container_created-'); rmSync(join(__dirname, 'screenshots'), { recursive: true, force: true }); }); test('a hook that writes nothing leaves no directory behind', async () => { // Every hook invocation creates a run directory. If empty ones were // kept, a module would accrue one per run forever — ~96 a day for a // 15-minute monitor — and nothing would ever reclaim them. const { logger } = createCapturingLogger(); const result = await invokeHook( __dirname, 'container_created', '1.0', { script: './test-fixtures/success-hook.ts', timeout: 10000 }, { vps_ip: '10.0.0.5' }, {}, {}, logger, ); expect(result.success).toBe(true); expect(result.artifactPaths).toBeUndefined(); expect(existsSync(join(__dirname, 'screenshots', 'container_created'))).toBe(false); const runDirs = existsSync(join(__dirname, 'screenshots')) ? readdirSync(join(__dirname, 'screenshots')) : []; expect(runDirs).toEqual([]); }); test('an early return before execution leaks no directory', async () => { // The capability pre-flight returns between "create the directory" and // the try/finally that reclaims it. Creating the directory too early // therefore leaked one on every such run, and nothing cleans them up. const { logger } = createCapturingLogger(); const result = await invokeHook( __dirname, 'container_created', '1.0', { script: './test-fixtures/success-hook.ts', timeout: 10000 }, { vps_ip: '10.0.0.5' }, {}, {}, logger, { requiredCapabilities: ['dns_internal'], capabilities: {} }, ); expect(result.success).toBe(false); const runDirs = existsSync(join(__dirname, 'screenshots')) ? readdirSync(join(__dirname, 'screenshots')) : []; expect(runDirs).toEqual([]); }); }); describe('invokeHook', () => { test('full successful invocation', async () => { const { logger, messages } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts', timeout: 10000, }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, { vps_ip: '10.0.0.5' }, { username: 'test' }, { password: 'secret' }, logger, ); expect(result.success).toBe(true); // Hook return values are no longer consumed (hook-owned-state D5): a // void hook's outputs are empty, and nothing persists them. expect(result.outputs).toEqual({}); expect(result.duration).toBeGreaterThanOrEqual(0); expect(messages.some((m) => m.message.includes('completed successfully'))).toBe(true); }); test('fails when required inputs missing', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts', }; // on_backup requires backup_dir per V1 contract const result = await invokeHook( __dirname, 'on_backup', '1.0', definition, {}, {}, {}, logger, ); expect(result.success).toBe(false); expect(result.error).toContain('Missing required hook inputs: backup_dir'); }); test('fails when script path escapes module dir', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: '../../etc/passwd', }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, {}, {}, {}, logger, ); expect(result.success).toBe(false); expect(result.error).toContain('escapes module directory'); }); test('fails when required outputs not returned', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/void-hook.ts', }; // on_backup requires artifact_count, size_bytes, schema_version per V1 contract const result = await invokeHook( __dirname, 'on_backup', '1.0', definition, { backup_dir: '/tmp/backup' }, {}, {}, logger, ); expect(result.success).toBe(false); expect(result.error).toContain('Hook did not return required outputs'); }); test('captures error from failing hook', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/failing-hook.ts', }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, {}, {}, {}, logger, ); expect(result.success).toBe(false); expect(result.error).toContain('simulated error'); }); test('rejects unknown contract version', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts' }; const result = await invokeHook( __dirname, 'container_created', '99.99', definition, {}, {}, {}, logger, ); expect(result.success).toBe(false); expect(result.error).toContain("Unsupported celilo_contract version '99.99'"); }); test('rejects unknown hook name within a known contract', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts' }; const result = await invokeHook(__dirname, 'on_poop', '1.0', definition, {}, {}, {}, logger); expect(result.success).toBe(false); expect(result.error).toContain("Hook 'on_poop' is not part of celilo_contract 1.0"); }); test('pre-flight: fails when a required capability is missing', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts', }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, { vps_ip: '10.0.0.5' }, {}, {}, logger, { capabilities: { public_web: {} }, requiredCapabilities: ['public_web', 'idp'], }, ); expect(result.success).toBe(false); expect(result.error).toContain("requires capability 'idp'"); expect(result.error).toContain('Install a module that provides idp'); }); test('pre-flight: passes when all required capabilities are present', async () => { const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts', }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, { vps_ip: '10.0.0.5' }, {}, {}, logger, { capabilities: { public_web: {}, idp: {} }, requiredCapabilities: ['public_web', 'idp'], }, ); expect(result.success).toBe(true); }); test('pre-flight: skipped when caller does not pass requiredCapabilities', async () => { // Backwards compatibility: pre-Phase-3 callers don't pass the field, // and the executor must not enforce anything in that case. const { logger } = createCapturingLogger(); const definition: HookDefinition = { script: './test-fixtures/success-hook.ts', }; const result = await invokeHook( __dirname, 'container_created', '1.0', definition, { vps_ip: '10.0.0.5' }, {}, {}, logger, { capabilities: {} }, // no requiredCapabilities → no check ); expect(result.success).toBe(true); }); }); describe('checkRequiredCapabilities', () => { test('returns null when nothing is required', () => { expect(checkRequiredCapabilities('on_install', [], {})).toBeNull(); }); test('returns null when all required are present', () => { expect( checkRequiredCapabilities('on_install', ['public_web', 'idp'], { public_web: {}, idp: {}, }), ).toBeNull(); }); test('returns error message listing the single missing name', () => { const result = checkRequiredCapabilities('on_install', ['idp'], {}); expect(result).not.toBeNull(); expect(result).toContain("Hook 'on_install' requires capability 'idp'"); expect(result).toContain('Install a module that provides idp'); }); test('returns error message listing multiple missing names', () => { const result = checkRequiredCapabilities('on_install', ['public_web', 'idp'], {}); expect(result).not.toBeNull(); expect(result).toContain("'public_web'"); expect(result).toContain("'idp'"); expect(result).toContain('each missing capability'); }); test('skips framework-granted privileges (not provider-loaded)', () => { // cross_module_read is a privilege granted by the framework to the // hooks that use it (backup/restore), not a provider-loaded // capability. It must not block other hooks like on_install // (regression: celilo-mgmt deploy, openspec/specs/progressive-zone-disclosure/spec.md). expect(checkRequiredCapabilities('on_install', ['cross_module_read'], {})).toBeNull(); // Real missing providers still flagged alongside a privilege. const mixed = checkRequiredCapabilities('on_install', ['cross_module_read', 'idp'], {}); expect(mixed).toContain("'idp'"); expect(mixed).not.toContain('cross_module_read'); }); }); });