/** * Aspect-approval flow on import (ISS-0045): in a non-TTY context, importing a * module that declares a base_module_aspect must fail fast with guidance rather * than hang on the approval prompt; --accept-aspects approves non-interactively. */ 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 { closeDb, getDb } from '../../db/client'; import { runMigrations } from '../../db/migrate'; import { modules } from '../../db/schema'; import type { BaseModuleAspect } from '../../manifest/schema'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleAspectApprovalAfterImport } from './module-import'; const aspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['dmz', 'app', 'secure'], triggers: ['on_install'], }; function insertAspectModule(id: string, version: string): void { getDb() .insert(modules) .values({ id, name: id, version, manifestData: { id, name: id, version, celilo_contract: '1.0', base_module_aspect: aspect, }, sourcePath: `/tmp/${id}`, }) .run(); } // Use defineProperty (not direct assignment): another suite may have redefined // process.stdin.isTTY as a non-writable property, which makes `= false` throw. function setTTY(value: boolean | undefined): void { Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true }); } describe('handleAspectApprovalAfterImport (non-interactive)', () => { let dir: string; let originalIsTTY: boolean | undefined; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-import-aspect-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); originalIsTTY = process.stdin.isTTY; // Force non-interactive so the no-flag path can't block on a real prompt. setTTY(false); }); afterEach(() => { setTTY(originalIsTTY); closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('fails fast (no hang) when stdin is not a TTY and --accept-aspects is absent', async () => { insertAspectModule('technitium', '1.0.0'); const result = await handleAspectApprovalAfterImport({ moduleId: 'technitium', targetPath: '/tmp/technitium', flags: {}, db: getDb(), }); expect(result.approved).toBe(false); if (result.approved) throw new Error('expected approval to be declined'); expect(result.error).toContain('--accept-aspects'); expect(result.error).toContain("isn't a TTY"); }); it('approves non-interactively when --accept-aspects is passed', async () => { insertAspectModule('technitium', '1.0.0'); const result = await handleAspectApprovalAfterImport({ moduleId: 'technitium', targetPath: '/tmp/technitium', flags: { 'accept-aspects': true }, db: getDb(), }); expect(result.approved).toBe(true); }); it('is a no-op for a module without a base_module_aspect', async () => { getDb() .insert(modules) .values({ id: 'caddy', name: 'caddy', version: '1.0.0', manifestData: { id: 'caddy', name: 'caddy', version: '1.0.0', celilo_contract: '1.0' }, sourcePath: '/tmp/caddy', }) .run(); const result = await handleAspectApprovalAfterImport({ moduleId: 'caddy', targetPath: '/tmp/caddy', flags: {}, db: getDb(), }); expect(result.approved).toBe(true); }); });