/** * Tests for the broker-side hook state stores (hook-owned-state tasks 3.2-3.4). * * The stores are what a jailed hook's `context.secrets` / `context.config` * accessors answer against. Everything here runs against a real temp database * with real encryption, because the interesting failures are storage failures: * a value that will not decrypt back is worse than one that was never written. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type DbClient, closeDb, createDbClient } from '../db/client'; import { runMigrations } from '../db/migrate'; import { moduleConfigs, modules, secrets } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { createHookStores } from './hook-store'; describe('hook stores', () => { let testDbPath: string; let testDir: string; let db: DbClient; const MANIFEST = { secrets: { declares: [{ name: 'bot_token' }, { name: 'api_key' }] }, variables: { owns: [ { name: 'public_ip', type: 'string', source: 'hook' }, { name: 'registered_peers', type: 'string-map', source: 'hook' }, { name: 'acme_email', type: 'string', source: 'user' }, ], }, }; beforeEach(async () => { testDir = mkdtempSync(join(tmpdir(), 'celilo-hook-store-')); testDbPath = join(testDir, 'test.db'); process.env.CELILO_DB_PATH = testDbPath; // Redirect the master key so the test never touches the operator's real one. process.env.CELILO_MASTER_KEY_PATH = join(testDir, 'master.key'); await runMigrations(testDbPath); db = createDbClient({ path: testDbPath }); db.insert(modules) .values({ id: 'test-module', name: 'Test Module', version: '1.0.0', manifestData: MANIFEST, sourcePath: '/test/path', }) .run(); }); afterEach(() => { closeDb(); // The variable pointed at this suite's (now removed) temp database; // leaving it set sends the next var-less reader at a dangling path. // Reset to the scratch path so no later reader reaches the operator's // real celilo.db (celilo#1315). resetTestDbPath(); rmSync(testDir, { recursive: true, force: true }); delete process.env.CELILO_MASTER_KEY_PATH; }); it('round-trips a secret through encrypt and decrypt', async () => { const { secrets: store } = await createHookStores(db, 'test-module'); await store.set('bot_token', 's3cret-value'); expect(await store.get('bot_token')).toBe('s3cret-value'); }); it('round-trips a hook-owned config value as plaintext', async () => { const { config } = await createHookStores(db, 'test-module'); await config.set('public_ip', '203.0.113.7'); expect(await config.get('public_ip')).toBe('203.0.113.7'); // Plaintext: the row lands in module_configs, never in the secrets table. const rows = db.select().from(moduleConfigs).all(); expect(rows.some((r) => r.key === 'public_ip' && r.valueJson === '"203.0.113.7"')).toBe(true); expect(rows.some((r) => r.key === 'public_ip' && r.source === 'hook')).toBe(true); }); it('set on an undeclared secret throws naming the module and the declared set', async () => { const { secrets: store } = await createHookStores(db, 'test-module'); expect(store.set('not_declared', 'x')).rejects.toThrow( /test-module[\s\S]*not_declared[\s\S]*bot_token[\s\S]*api_key/, ); }); it('set on a name not owned as source: hook config throws naming the declared set', async () => { const { config } = await createHookStores(db, 'test-module'); // acme_email is declared, but as source: user — the operator owns it. await expect(config.set('acme_email', 'x')).rejects.toThrow(/source: hook/); await expect(config.set('not_declared', 'x')).rejects.toThrow(/public_ip/); }); it('delete of an absent name is a no-op', async () => { const { secrets: store, config } = await createHookStores(db, 'test-module'); await expect(store.delete('bot_token')).resolves.toBeUndefined(); await expect(config.delete('public_ip')).resolves.toBeUndefined(); }); it('delete removes an existing row', async () => { const { config } = await createHookStores(db, 'test-module'); await config.set('public_ip', '203.0.113.7'); await config.delete('public_ip'); expect(await config.get('public_ip')).toBeUndefined(); }); it('applyTransaction commits every buffered op on success', async () => { const { secrets: store } = await createHookStores(db, 'test-module'); await store.applyTransaction([ { op: 'set', name: 'bot_token', value: 'committed' }, { op: 'set', name: 'api_key', value: 'committed-too' }, ]); expect(await store.get('bot_token')).toBe('committed'); expect(await store.get('api_key')).toBe('committed-too'); }); it('an undeclared name inside a transaction discards the whole batch', async () => { const { secrets: store } = await createHookStores(db, 'test-module'); await expect( store.applyTransaction([ { op: 'set', name: 'api_key', value: 'good' }, { op: 'set', name: 'not_declared', value: 'bad' }, ]), ).rejects.toThrow(/not_declared/); expect(await store.get('api_key')).toBeUndefined(); }); it('a name owned as config is not writable through the secrets store and vice versa', async () => { const { secrets: store, config } = await createHookStores(db, 'test-module'); await expect(store.set('public_ip', 'x')).rejects.toThrow(/bot_token|api_key/); await expect(config.set('bot_token', 'x')).rejects.toThrow(/source: hook|public_ip/); }); it('reads secrets the ordinary interview path wrote, and writes stay visible to it', async () => { // The interview encrypts directly into the secrets table; the store must // read those rows, not only its own writes. const { encryptSecret } = await import('../secrets/encryption'); const { getOrCreateMasterKey } = await import('../secrets/master-key'); const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret('interview-value', masterKey); db.insert(secrets) .values({ moduleId: 'test-module', name: 'bot_token', ...encrypted }) .run(); const { secrets: store } = await createHookStores(db, 'test-module'); expect(await store.get('bot_token')).toBe('interview-value'); }); it('reports the declared sets in the store metadata', async () => { const stores = await createHookStores(db, 'test-module'); expect(stores.declaredSecretNames.sort()).toEqual(['api_key', 'bot_token']); expect(stores.declaredConfigNames.sort()).toEqual(['public_ip', 'registered_peers']); }); });