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 { checkAspectApproval, computeAspectScopeHash, findAspectApproval, recordAspectApproval, recordAspectConsent, } from './aspect-approvals'; const baseAspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['dmz', 'app', 'secure'], triggers: ['on_install'], }; function insertModule(id: string, version: string) { getDb() .insert(modules) .values({ id, name: id, version, manifestData: { id, name: id, version, celilo_contract: '1.0' }, sourcePath: `/tmp/${id}`, }) .run(); } describe('aspect-approvals', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-aspect-approvals-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); describe('computeAspectScopeHash', () => { it('is stable for the same scope', () => { const h1 = computeAspectScopeHash(baseAspect); const h2 = computeAspectScopeHash(baseAspect); expect(h1).toBe(h2); }); it('is the same regardless of zone or trigger order', () => { const a: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app', 'dmz', 'secure'], triggers: ['on_install', 'on_new_system_in_zone'], }; const b: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['secure', 'app', 'dmz'], triggers: ['on_new_system_in_zone', 'on_install'], }; expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b)); }); it('changes when applicable_zones changes', () => { const a: BaseModuleAspect = { ...baseAspect, applicable_zones: ['dmz', 'app', 'secure'] }; const b: BaseModuleAspect = { ...baseAspect, applicable_zones: ['dmz', 'app', 'secure', 'internal'], }; expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b)); }); it('changes when triggers change', () => { const a: BaseModuleAspect = { ...baseAspect, triggers: ['on_install'] }; const b: BaseModuleAspect = { ...baseAspect, triggers: ['on_install', 'on_new_system_in_zone'], }; expect(computeAspectScopeHash(a)).not.toBe(computeAspectScopeHash(b)); }); it('is unaffected by ansible_role changes', () => { // ansible_role is intentionally NOT part of the scope hash — // module authors evolve their role contents within an approved // scope all the time. const a: BaseModuleAspect = { ...baseAspect, ansible_role: 'old-role' }; const b: BaseModuleAspect = { ...baseAspect, ansible_role: 'new-role' }; expect(computeAspectScopeHash(a)).toBe(computeAspectScopeHash(b)); }); }); describe('recordAspectApproval + findAspectApproval', () => { it('persists and reads back an approval', () => { insertModule('knot-unbound-internal', '1.0.0'); const db = getDb(); const written = recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash(baseAspect), approver: 'testuser', db, }); expect(written.moduleId).toBe('knot-unbound-internal'); expect(written.version).toBe('1.0.0'); expect(written.approver).toBe('testuser'); const found = findAspectApproval('knot-unbound-internal', '1.0.0', db); expect(found?.id).toBe(written.id); expect(found?.scopeHash).toBe(written.scopeHash); }); it('upserts on duplicate (moduleId, version) — the latest decision wins', () => { insertModule('knot-unbound-internal', '1.0.0'); const db = getDb(); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: 'abc', approver: null, db, }); // Re-deciding the same (module, version) overwrites rather than throwing // — this is how a refusal flips to approval, or a scope change re-records. recordAspectConsent({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: 'def', approver: null, consented: false, db, }); const row = findAspectApproval('knot-unbound-internal', '1.0.0', db); expect(row?.scopeHash).toBe('def'); // latest scope expect(row?.consented).toBe(false); // latest decision (a refusal) }); it('allows two approvals for the same module at different versions', () => { insertModule('knot-unbound-internal', '1.0.0'); insertModule('knot-other', '1.0.0'); // unrelated; just keeping the test surface narrow const db = getDb(); // Same module, but the test isolates versions by inserting a // second row in `modules` first. We just want to verify the // approvals table doesn't reject two rows with different // versions. db.insert(modules) .values({ id: 'knot-unbound-internal-v2', name: 'knot', version: '2.0.0', manifestData: { id: 'knot', name: 'knot', version: '2.0.0', celilo_contract: '1.0' }, sourcePath: '/tmp/knot', }) .run(); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: 'abc', approver: null, db, }); recordAspectApproval({ moduleId: 'knot-unbound-internal-v2', version: '2.0.0', scopeHash: 'def', approver: null, db, }); expect(findAspectApproval('knot-unbound-internal', '1.0.0', db)?.scopeHash).toBe('abc'); expect(findAspectApproval('knot-unbound-internal-v2', '2.0.0', db)?.scopeHash).toBe('def'); }); it('returns undefined when there is no approval', () => { const found = findAspectApproval('never-imported', '1.0.0', getDb()); expect(found).toBeUndefined(); }); }); describe('checkAspectApproval', () => { it('returns "no_approval" when nothing is recorded', () => { const status = checkAspectApproval('never-imported', '1.0.0', baseAspect, getDb()); expect(status).toBe('no_approval'); }); it('returns "approved" when an approval matches the current scope hash', () => { insertModule('knot-unbound-internal', '1.0.0'); const db = getDb(); recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash(baseAspect), approver: null, db, }); const status = checkAspectApproval('knot-unbound-internal', '1.0.0', baseAspect, db); expect(status).toBe('approved'); }); it('returns "denied" when a matching-scope row recorded a refusal', () => { insertModule('knot-unbound-internal', '1.0.0'); const db = getDb(); recordAspectConsent({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash(baseAspect), approver: null, consented: false, db, }); const status = checkAspectApproval('knot-unbound-internal', '1.0.0', baseAspect, db); expect(status).toBe('denied'); }); it('returns "scope_changed" when scope diverges from a prior approval', () => { insertModule('knot-unbound-internal', '1.0.0'); const db = getDb(); const oldAspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app'], triggers: ['on_install'], }; recordAspectApproval({ moduleId: 'knot-unbound-internal', version: '1.0.0', scopeHash: computeAspectScopeHash(oldAspect), approver: null, db, }); // Same version row, but the manifest's scope broadened — this is // the D7 upgrade-changes-scope case. const newAspect: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['app', 'dmz', 'secure', 'internal'], triggers: ['on_install'], }; const status = checkAspectApproval('knot-unbound-internal', '1.0.0', newAspect, db); expect(status).toBe('scope_changed'); }); // #262 — scope-keyed carry-forward across versions. The ISS-0156 cutover // hung because consent was recorded at the imported version, then a // `module update` bumped the version and the deploy re-prompted (no row for // the new version) — and nothing answered the prompt. Per D7, only a SCOPE // change requires re-consent; a pure version bump must not. it('carries approval forward to a bumped version with the SAME scope (#262 — no re-prompt)', () => { insertModule('technitium', '1.0.2+4'); const db = getDb(); recordAspectApproval({ moduleId: 'technitium', version: '1.0.2+4', scopeHash: computeAspectScopeHash(baseAspect), approver: null, db, }); // `module update` bumped +4 → +6; aspect scope unchanged. expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('approved'); }); it('carries a DENIAL forward to a bumped version with the same scope', () => { insertModule('technitium', '1.0.2+4'); const db = getDb(); recordAspectConsent({ moduleId: 'technitium', version: '1.0.2+4', scopeHash: computeAspectScopeHash(baseAspect), approver: null, consented: false, db, }); expect(checkAspectApproval('technitium', '1.0.2+6', baseAspect, db)).toBe('denied'); }); it('does NOT carry forward to a bumped version when the scope changed', () => { insertModule('technitium', '1.0.2+4'); const db = getDb(); recordAspectApproval({ moduleId: 'technitium', version: '1.0.2+4', scopeHash: computeAspectScopeHash(baseAspect), approver: null, db, }); const narrowed: BaseModuleAspect = { ansible_role: 'dns-client-config', applicable_zones: ['dmz'], triggers: ['on_install'], }; // Bumped version AND narrowed scope → no exact row, no scope match → re-prompt. expect(checkAspectApproval('technitium', '1.0.2+6', narrowed, db)).toBe('no_approval'); }); }); });