/** * Regression tests for the deploy-side missing-secret discovery path * AND the shared `findMissingSecrets` it now delegates to. * * Background: there used to be two implementations of "find missing * secrets" — `validateModuleSecrets` (used by `module generate`) and * `findMissingRequiredVariables` (used by `module deploy`). They * diverged on what fields they carried; deploy was silently dropping * `type` / `key_label` / `value_label`, which routed string-map * secrets through the wrong responder UX. * * Both call sites now delegate to `findMissingSecrets` in * config-interview.ts. These tests cover both layers: the shared * function directly, and the public deploy entry point that wraps it. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type DbClient, getDb } from '../db/client'; import { modules, secrets } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { findMissingSecrets } from './config-interview'; import { findMissingBuildArtifacts, findMissingRequiredVariables } from './deploy-validation'; function manifestWithStringMapSecret(): ModuleManifest { return { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', description: 'fixture', secrets: { declares: [ { name: 'ddns_passwords', type: 'string-map', required: true, description: 'DDNS password per managed domain', sensitive: true, key_label: 'Domain', value_label: 'Password', }, ], }, } as unknown as ModuleManifest; } function manifestWithPlainStringSecret(): ModuleManifest { return { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', description: 'fixture', secrets: { declares: [ { name: 'api_key', type: 'string', required: true, description: 'Upstream API key', sensitive: true, }, ], }, } as unknown as ModuleManifest; } describe('findMissingRequiredVariables (deploy path)', () => { let tempDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-deploy-validation-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: tempDir, version: '1.0.0', manifestData: {}, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); }); // The bug we're guarding against: the deploy used to drop type + // labels on the floor here, so the bus payload arrived with type: // 'string' and the responder defaulted to single-line input. test('propagates type / key_label / value_label for string-map secrets', async () => { const missing = await findMissingRequiredVariables( 'testmod', manifestWithStringMapSecret(), db, ); expect(missing).toHaveLength(1); expect(missing[0]).toMatchObject({ name: 'ddns_passwords', source: 'secret', type: 'string-map', key_label: 'Domain', value_label: 'Password', description: 'DDNS password per managed domain', }); }); test('plain string secrets still arrive with type=string and no labels', async () => { const missing = await findMissingRequiredVariables( 'testmod', manifestWithPlainStringSecret(), db, ); expect(missing).toHaveLength(1); expect(missing[0]).toMatchObject({ name: 'api_key', source: 'secret', type: 'string', description: 'Upstream API key', }); expect(missing[0].key_label).toBeUndefined(); expect(missing[0].value_label).toBeUndefined(); }); // Hook-owned-state task 2.3 (design D2): a `source: hook` variable is // discovered by the module's own hook at runtime. It is never operator // input, so an unwritten one must not read as missing configuration — not // for the interview, and not as a deploy-blocking error. test('a source: hook variable is never reported missing, even required and unwritten', async () => { const manifest = { celilo_contract: '1.0', variables: { owns: [ { name: 'public_ip', type: 'string', required: true, source: 'hook', }, ], }, } as unknown as ModuleManifest; const missing = await findMissingRequiredVariables('testmod', manifest, db); expect(missing).toEqual([]); }); test('configured secrets are not reported as missing', async () => { db.insert(secrets) .values({ moduleId: 'testmod', name: 'ddns_passwords', encryptedValue: 'dummy', iv: 'dummy', authTag: 'dummy', }) .run(); const missing = await findMissingRequiredVariables( 'testmod', manifestWithStringMapSecret(), db, ); expect(missing).toHaveLength(0); }); test('optional secrets are skipped (only required show up)', async () => { const manifest = { ...manifestWithStringMapSecret(), secrets: { declares: [ { name: 'ddns_passwords', type: 'string-map', required: false, description: 'optional', sensitive: true, }, ], }, } as unknown as ModuleManifest; const missing = await findMissingRequiredVariables('testmod', manifest, db); expect(missing).toHaveLength(0); }); }); describe('findMissingSecrets (shared)', () => { let tempDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-find-missing-secrets-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: tempDir, version: '1.0.0', manifestData: {}, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); }); test('accepts loose unknown[] declares (manifest read off disk)', async () => { // The validateModuleSecrets path reads manifest.yml off disk and // hands us a loose-typed shape. findMissingSecrets coerces each // entry per-field; malformed entries are skipped, well-formed ones // pass through with their fields preserved. const looseManifest = { secrets: { declares: [ { name: 'ok_secret', type: 'string-map', required: true, key_label: 'K', value_label: 'V', }, { name: 'no_required_field', type: 'string' }, // required missing → skipped { name: 'optional', type: 'string', required: false }, // required:false → skipped 'this is not an object', // skipped { /* no name */ required: true }, // skipped { name: 'with_generate', required: true, generate: { method: 'random', length: 32, encoding: 'base64' }, }, ], }, }; const missing = await findMissingSecrets('testmod', looseManifest, db); expect(missing.map((m) => m.name)).toEqual(['ok_secret', 'with_generate']); const okEntry = missing.find((m) => m.name === 'ok_secret'); expect(okEntry?.type).toBe('string-map'); expect(okEntry?.key_label).toBe('K'); expect(okEntry?.value_label).toBe('V'); const genEntry = missing.find((m) => m.name === 'with_generate'); expect(genEntry?.generate).toEqual({ method: 'random', length: 32, encoding: 'base64' }); }); test('returns empty array when manifest has no secrets section', async () => { expect(await findMissingSecrets('testmod', {}, db)).toEqual([]); expect(await findMissingSecrets('testmod', { secrets: {} }, db)).toEqual([]); expect(await findMissingSecrets('testmod', { secrets: { declares: [] } }, db)).toEqual([]); }); test('drops entries whose secrets are already in the DB', async () => { db.insert(secrets) .values({ moduleId: 'testmod', name: 'already_set', encryptedValue: 'x', iv: 'x', authTag: 'x', }) .run(); const missing = await findMissingSecrets( 'testmod', { secrets: { declares: [ { name: 'already_set', required: true }, { name: 'still_missing', required: true }, ], }, }, db, ); expect(missing.map((m) => m.name)).toEqual(['still_missing']); }); test('passes through key_pattern / value_pattern + their messages', async () => { // The terminal-responder reads these off the bus payload to apply // input-time regex validation. Without manifest → MissingVariable // propagation, the responder never sees them and operators end // up entering invalid keys (e.g. 'www.example.net' instead of // the apex 'example.net'). const missing = await findMissingSecrets( 'testmod', { secrets: { declares: [ { name: 'ddns_passwords', type: 'string-map', required: true, key_pattern: '^[a-z0-9-]+\\.[a-z]{2,}$', key_pattern_message: 'apex domain only — drop the www.', value_pattern: '^.{8,}$', value_pattern_message: 'min 8 chars', }, ], }, }, db, ); expect(missing).toHaveLength(1); expect(missing[0].key_pattern).toBe('^[a-z0-9-]+\\.[a-z]{2,}$'); expect(missing[0].key_pattern_message).toBe('apex domain only — drop the www.'); expect(missing[0].value_pattern).toBe('^.{8,}$'); expect(missing[0].value_pattern_message).toBe('min 8 chars'); }); }); /** * The deploy build-gate: the management server does NOT build modules from * source. A module's declared artifacts must already be present on disk * (shipped in the .netapp at `module package` / `module publish` time). Deploy * verifies them; a missing artifact is a hard error, never a from-source * rebuild on the management box (ISS-0131). */ describe('findMissingBuildArtifacts (deploy build-gate)', () => { let dir: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'build-gate-')); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); function manifestWithArtifacts(artifacts: string[]): ModuleManifest { return { celilo_contract: '1.0', id: 'buildmod', name: 'Build Module', version: '1.0.0', description: 'fixture', build: { command: 'true', artifacts }, } as unknown as ModuleManifest; } test('returns [] when the module declares no build section', () => { const manifest = { celilo_contract: '1.0', id: 'm', version: '1.0.0' } as ModuleManifest; expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]); }); test('returns [] when all declared artifacts exist on disk (prebuilt .netapp)', () => { mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync(join(dir, 'dist', 'server'), 'binary'); writeFileSync(join(dir, 'dist', 'index.html'), ''); const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html']); expect(findMissingBuildArtifacts(manifest, dir)).toEqual([]); }); test('returns the missing artifacts when some are absent (no mgmt-server rebuild)', () => { mkdirSync(join(dir, 'dist'), { recursive: true }); writeFileSync(join(dir, 'dist', 'server'), 'binary'); const manifest = manifestWithArtifacts(['dist/server', 'dist/index.html', 'dist/db.sqlite']); expect(findMissingBuildArtifacts(manifest, dir)).toEqual(['dist/index.html', 'dist/db.sqlite']); }); });