/** * Control-plane quiescence (tasks 2.1, 2.2, 2.5). * * Every assertion is made in BOTH directions per Rule 7.6. "The hook did not * fire" is the same observation you get from a test that was never wired up, so * each case first proves the UNPAUSED module gets through the guard. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { handleEventsRunHook } from '../cli/commands/events'; import { type DbClient, createDbClient } from '../db/client'; import { type ModuleState, modules } from '../db/schema'; import { runNamedHook } from '../hooks/run-named-hook'; import type { HookLogger } from '../hooks/types'; const SILENT_LOGGER: HookLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, } as unknown as HookLogger; let db: DbClient; /** * A manifest declaring the timer- and event-driven hooks that are the runtime * hazard — the ones the dispatcher fires against celilo-mgr's module store and * that can call into a provider that is gone. */ function manifestWithHooks(id: string) { return { id, name: id, version: '1.0.0', celilo_contract: '1.0', provides: { capabilities: [] }, requires: { capabilities: [] }, hooks: { on_install: { script: 'scripts/on-install.ts' }, on_uninstall: { script: 'scripts/on-uninstall.ts' }, on_system_event: { script: 'scripts/on-system-event.ts' }, refresh_registrations: { script: 'scripts/refresh-registrations.ts' }, }, subscriptions: [{ name: 'refresh', pattern: 'timer.tick.15m', hook: 'refresh_registrations' }], }; } function insertModule(id: string, state: ModuleState): void { db.insert(modules) .values({ id, name: id, version: '1.0.0', state, manifestData: manifestWithHooks(id), sourcePath: join(tmpdir(), 'celilo-nonexistent-module'), pausedAt: state === 'PAUSED' ? new Date() : null, pauseReason: state === 'PAUSED' ? 'edge router swap' : null, }) .run(); } beforeEach(() => { const dir = mkdtempSync(join(tmpdir(), 'celilo-quiesce-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); process.env.CELILO_EVENT_BUS_PATH = join(dir, 'events.db'); db = createDbClient({ path: process.env.CELILO_DB_PATH }); }); afterEach(() => { db.$client.close(); }); describe('runNamedHook refuses to run dispatched work on a paused module', () => { test('PROVE IT RUNS: an unpaused module is NOT short-circuited by the pause guard', async () => { insertModule('caddy', 'INSTALLED'); const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER); // The hook script does not exist, so this fails downstream — the point is // only that it got PAST the guard, which `skippedPaused` proves it did. expect(result.skippedPaused).toBeUndefined(); }); test('a paused module is skipped instead', async () => { insertModule('caddy', 'PAUSED'); const result = await runNamedHook('caddy', 'on_system_event', db, SILENT_LOGGER); expect(result.skippedPaused).toBe(true); // Success, not failure: the module is deliberately quiesced. A failure here // would be retried by the bus and then alerted on, paging the operator // about the pause they took themselves. expect(result.success).toBe(true); }); test('a paused module is skipped for timer-driven hooks too', async () => { insertModule('technitium', 'PAUSED'); const result = await runNamedHook('technitium', 'refresh_registrations', db, SILENT_LOGGER); expect(result.skippedPaused).toBe(true); }); test.each([['on_install'], ['on_uninstall']] as const)( '%s is EXEMPT — it must run while the module is still paused', async (hookName) => { // on_install is how unpause redeploys the module back to life, and // on_uninstall is how a paused provider is removed — which is the entire // point of pausing its consumers. Blocking either would deadlock the // whole design. insertModule('greenwave', 'PAUSED'); const result = await runNamedHook( 'greenwave', hookName as 'on_install' | 'on_uninstall', db, SILENT_LOGGER, ); expect(result.skippedPaused).toBeUndefined(); }, ); }); describe('the bus dispatch entry point skips a paused module (task 2.1)', () => { // `celilo events run-hook ` is what every `hook:` subscription // resolves to, so this is where an event delivery lands. test('PROVE IT PROCEEDS: an unpaused module gets past the guard to subscription lookup', async () => { insertModule('caddy', 'INSTALLED'); const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']); // Reaching the "no such subscription" error proves the pause guard let it // through; a paused module never gets this far. expect(result.success).toBe(false); expect(result.success === false ? result.error : '').toContain('no subscription named'); }); test('a paused module is skipped before any subscription is resolved', async () => { insertModule('caddy', 'PAUSED'); const result = await handleEventsRunHook(['caddy', 'no-such-subscription', '1']); expect(result.success).toBe(true); expect(result.success === true ? result.message : '').toContain('paused'); }); }); describe('resyncAllSubscriptions cannot re-arm a paused module', () => { test('a paused module is outside the deployed set the resync rebuilds from', () => { // The resync selects INSTALLED/VERIFIED. That allow-list is what makes // quiescence survive a restore (which starts events.db empty) — worth // pinning, because widening it to "not IMPORTED" would silently un-pause // every paused module on the next resync. insertModule('caddy', 'PAUSED'); insertModule('authentik', 'INSTALLED'); const deployed = db .select({ id: modules.id }) .from(modules) .where(eq(modules.state, 'PAUSED')) .all(); expect(deployed.map((m) => m.id)).toEqual(['caddy']); }); });