import { describe, expect, test } from 'bun:test'; import type { ModuleManifest } from '../manifest/schema'; import { type DependentCandidate, describeRemovalRefusal, findRemovalBlockers, } from './remove-guard'; function manifest( id: string, opts: { requires?: string[]; optional?: string[] } = {}, ): ModuleManifest { return { id, name: id, version: '1.0.0', celilo_contract: '1.0', provides: { capabilities: [] }, requires: { capabilities: (opts.requires ?? []).map((name) => ({ name, version: '1.0.0' })) }, optional: opts.optional ? { capabilities: opts.optional.map((name) => ({ name, version: '1.0.0' })) } : undefined, } as unknown as ModuleManifest; } function candidate( id: string, opts: { requires?: string[]; optional?: string[]; paused?: boolean; deployed?: boolean } = {}, ): DependentCandidate { return { id, manifest: manifest(id, opts), paused: opts.paused ?? false, deployed: opts.deployed ?? true, }; } /** What `greenwave` provides, and the consumers that wedged its removal. */ const GREENWAVE_CAPABILITIES = ['firewall', 'dhcp_server']; describe('findRemovalBlockers — the guard that wedged the greenwave swap', () => { test('a live consumer blocks removal', () => { const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('caddy', { requires: ['firewall'] }), ]); expect(blockers).toEqual([{ moduleId: 'caddy', capability: 'firewall', kind: 'requires' }]); }); test('a PAUSED consumer does not block (task 5.1)', () => { // Sound because unpause cannot return it to service without a redeploy, and // a redeploy re-resolves its capabilities against whatever is present then. const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('caddy', { requires: ['firewall'], paused: true }), ]); expect(blockers).toEqual([]); }); test('a PARTIALLY paused dependent set still blocks, naming only the live ones', () => { const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('caddy', { requires: ['firewall'], paused: true }), candidate('authentik', { requires: ['firewall'] }), ]); expect(blockers.map((b) => b.moduleId)).toEqual(['authentik']); }); }); describe('findRemovalBlockers — the optional-dependency regression (task 5.4)', () => { // technitium consumes dhcp_server under `optional:`, so the old guard — // which read `requires` alone — let a greenwave removal orphan its // DHCP-handed-out DNS silently while correctly blocking on caddy. test('an optional consumer NOW blocks, where it used to be invisible', () => { const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('technitium', { optional: ['dhcp_server'] }), ]); expect(blockers).toEqual([ { moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' }, ]); }); test('and stops blocking once paused', () => { const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('technitium', { optional: ['dhcp_server'], paused: true }), ]); expect(blockers).toEqual([]); }); test('a module depending BOTH ways is reported once, as the stronger claim', () => { const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('both', { requires: ['firewall'], optional: ['dhcp_server'] }), ]); expect(blockers).toHaveLength(1); expect(blockers[0].kind).toBe('requires'); }); }); describe('findRemovalBlockers — deliberate non-goals', () => { test('another provider existing does NOT by itself permit removal (celilo#683, task 5.5)', () => { // `axon` provides the same capabilities as `greenwave`. The guard still // refuses, because celilo does not reason about provider substitutability — // pausing is the mechanism this change relies on instead. const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('axon'), candidate('caddy', { requires: ['firewall'] }), ]); expect(blockers.map((b) => b.moduleId)).toEqual(['caddy']); }); test('a module consuming an unrelated capability is not a dependent', () => { expect( findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('blog', { requires: ['public_web'] }), ]), ).toEqual([]); }); test('a provider of nothing blocks nobody', () => { expect(findRemovalBlockers([], [candidate('caddy', { requires: ['firewall'] })])).toEqual([]); }); }); describe('describeRemovalRefusal', () => { test('names each blocker AND which declaration makes it one (task 5.3)', () => { const message = describeRemovalRefusal('greenwave', [ { moduleId: 'caddy', capability: 'firewall', kind: 'requires' }, { moduleId: 'technitium', capability: 'dhcp_server', kind: 'optional' }, ]); // The operator needs to tell a hard prerequisite from an optional consumer // to judge what pausing it implies. expect(message).toContain("caddy — requires 'firewall'"); expect(message).toContain("technitium — optional 'dhcp_server'"); // And it must point at the way out, since that is the whole change. expect(message).toContain('celilo module pause --cascade greenwave'); }); }); describe('an undeployed dependent does not block (guard/cascade agreement)', () => { // The spec says removal is refused for another INSTALLED module. An imported // module has never resolved the capability and cannot be orphaned by the // provider going away. // // This is load-bearing for D3's guard/cascade agreement, not a nicety. // `pause --cascade` SKIPS undeployed members — pausing needs a settled // deployed state and there is nothing to quiesce. If the guard still counted // them, the operator would pause the whole cascade and the removal would // still refuse, naming a module that pause is structurally unable to act on. // That is a dead end: no sequence of commands gets the operator out of it. test('an IMPORTED consumer is not a blocker', () => { expect( findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('technitium', { optional: ['dhcp_server'], deployed: false }), ]), ).toEqual([]); }); test('but a DEPLOYED one still is', () => { expect( findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('technitium', { optional: ['dhcp_server'], deployed: true }), ]).map((b) => b.moduleId), ).toEqual(['technitium']); }); test('the guard and the cascade agree on every combination', () => { // Whatever pause can act on, the guard must count; whatever pause skips, // the guard must ignore. Enumerated so a future change to either side // cannot silently break the pairing. const blockers = findRemovalBlockers(GREENWAVE_CAPABILITIES, [ candidate('live', { requires: ['firewall'] }), candidate('paused', { requires: ['firewall'], paused: true }), candidate('imported', { requires: ['firewall'], deployed: false }), ]); expect(blockers.map((b) => b.moduleId)).toEqual(['live']); }); });