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 } from '../db/client'; import type { ModuleManifest } from '../manifest/schema'; import { setupTestDatabaseAt } from '../test-utils/database'; import { resetTestDbPath } from '../test-utils/db-path'; import { type CleanupTarget, type ProviderRow, type ProviderState, planConsumerCleanup, runConsumerCleanup, } from './consumer-cleanup'; /** * The pure half of the removal dispatch * (openspec/changes/consumer-removal-cleanup). Which providers get told, and * which are skipped and why, is all decided here — so it is all assertable * without a database, a hook runner, or a deployed anything. */ function manifest(input: { requires?: string[]; optional?: string[] }): ModuleManifest { return { requires: { capabilities: (input.requires ?? []).map((name) => ({ name, version: '1.0.0' })) }, optional: { capabilities: (input.optional ?? []).map((name) => ({ name, version: '1.0.0' })) }, } as unknown as ModuleManifest; } const deployed = (...ids: string[]): ProviderState[] => ids.map((moduleId) => ({ moduleId, state: 'VERIFIED' })); const provides = (...pairs: Array<[string, string]>): ProviderRow[] => pairs.map(([moduleId, capabilityName]) => ({ moduleId, capabilityName })); describe('planConsumerCleanup', () => { it('collects providers of BOTH requires and optional capabilities', () => { // The same set `remove-guard.ts` counts as a dependency edge. A capability // consumed optionally still had state minted for it — `technitium` consumes // `dhcp_server` under `optional:`, and the two definitions disagreeing has // already been silently harmful once. const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['public_web'], optional: ['firewall'] }), provides(['caddy', 'public_web'], ['iptables', 'firewall']), deployed('caddy', 'iptables'), ); expect(plan.map((t) => t.providerId)).toEqual(['caddy', 'iptables']); }); it('tells EVERY provider of one capability (the chained-firewall case)', () => { // `capabilities` has no uniqueness on the name: `firewall` deliberately has // several rows, an edge provider plus inner layers, and each holds its own // rules for the departing consumer (D3). const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['firewall'] }), provides(['iptables', 'firewall'], ['greenwave', 'firewall']), deployed('iptables', 'greenwave'), ); expect(plan.map((t) => t.providerId)).toEqual(['greenwave', 'iptables']); }); it('tells a provider of two consumed capabilities exactly ONCE', () => { // Dispatching per capability would run the same withdrawal twice (D1) — the // hook is a full converge, so the second run is at best wasted and at worst // re-applies a ruleset over a half-applied one. const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['source_forge', 'registry_publish'] }), provides(['forgejo', 'source_forge'], ['forgejo', 'registry_publish']), deployed('forgejo'), ); expect(plan).toHaveLength(1); expect(plan[0].providerId).toBe('forgejo'); expect(plan[0].capabilityNames).toEqual(['registry_publish', 'source_forge']); }); it('sorts by provider id, so dispatch order is deterministic', () => { const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['firewall'] }), provides(['zebra', 'firewall'], ['axon', 'firewall'], ['iptables', 'firewall']), deployed('zebra', 'axon', 'iptables'), ); expect(plan.map((t) => t.providerId)).toEqual(['axon', 'iptables', 'zebra']); }); it('marks a PAUSED provider skipped rather than dispatching to it', () => { // A paused module does not run non-lifecycle hooks (D7). Recording the skip // is what lets the caller say WHICH provider is still holding state. const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['public_web'] }), provides(['caddy', 'public_web']), [{ moduleId: 'caddy', state: 'PAUSED' }], ); expect(plan).toEqual([ { providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' }, ]); }); it('marks a never-deployed provider skipped', () => { // Capabilities are registered at IMPORT, so the table routinely names // providers that have never resolved anything and hold nothing (D8). for (const state of ['IMPORTED', 'VALIDATED', 'CONFIGURED']) { const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['public_web'] }), provides(['caddy', 'public_web']), [{ moduleId: 'caddy', state }], ); expect(plan[0].skip).toBe('not-deployed'); } }); it('skips a provider with no module row at all rather than dispatching blind', () => { const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['public_web'] }), provides(['caddy', 'public_web']), [], ); expect(plan[0].skip).toBe('not-deployed'); }); it('is empty for a module that consumes nothing', () => { expect( planConsumerCleanup( 'consumer', manifest({}), provides(['caddy', 'public_web']), deployed('caddy'), ), ).toEqual([]); }); it('never asks the departing module to withdraw its own state', () => { // A module can both provide and require a capability. Its own `on_uninstall` // owns its teardown; being handed itself as a provider would run a converge // on a module that is mid-removal. const plan = planConsumerCleanup( 'caddy', manifest({ requires: ['firewall'] }), provides(['caddy', 'firewall'], ['iptables', 'firewall']), deployed('caddy', 'iptables'), ); expect(plan.map((t) => t.providerId)).toEqual(['iptables']); }); it('ignores providers of capabilities the consumer never declared', () => { const plan = planConsumerCleanup( 'consumer', manifest({ requires: ['public_web'] }), provides(['caddy', 'public_web'], ['namecheap', 'dns_registrar']), deployed('caddy', 'namecheap'), ); expect(plan.map((t) => t.providerId)).toEqual(['caddy']); }); }); describe('planConsumerCleanup stays permissive after celilo#1072', () => { it('notifies a provider the consumer has no recorded binding to', () => { // celilo#1072 added `capability_bindings`, which records the provider a // consumer actually CALLED. That set is precise and it is deliberately not // this one. Cleanup must reach every provider that MIGHT hold minted state, // including one a hook resolved, minted against, and never called again — // and including one whose binding row was never written because the state // was minted by an older celilo. Narrowing this to the recorded set would // leave a site block in caddy or a DNAT rule in a ruleset with nothing left // in the registry to reconcile it away, which is the exact failure // `consumer-cleanup.ts` exists to prevent. // // The inputs here are manifest + provider rows and NOTHING ELSE. That is // the assertion: no binding table is consulted, so no binding table can // shrink the result. const plan = planConsumerCleanup( 'tango-nexus', manifest({ optional: ['external_web', 'public_web', 'source_forge', 'registry_publish'] }), provides( ['cpanel-host', 'external_web'], ['caddy', 'public_web'], ['forgejo', 'source_forge'], ['celilo-registry', 'registry_publish'], ), deployed('cpanel-host', 'caddy', 'forgejo', 'celilo-registry'), ); expect(plan.map((t) => t.providerId)).toEqual([ 'caddy', 'celilo-registry', 'cpanel-host', 'forgejo', ]); expect(plan.every((t) => t.skip === undefined)).toBe(true); }); }); describe('runConsumerCleanup — the paused skip is reported, not silent', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'cc-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); }); afterEach(() => { db.$client.close(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); /** * Asserted here rather than through the CLI: `module remove`'s output is * clack-formatted and its stderr is discarded by the integration harness, so * a substring match there would prove nothing about the message. This is the * layer where the text actually exists. */ it('warns, naming the provider AND the consumer whose state it keeps (D7)', async () => { const messages: string[] = []; const logger = { info: () => {}, warn: (m: string) => messages.push(m), error: () => {}, success: () => {}, }; const result = await runConsumerCleanup( 'departing', [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'paused' }], db, logger, ); expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'paused' }]); expect(result.failures).toEqual([]); expect(messages).toHaveLength(1); expect(messages[0]).toContain('caddy'); expect(messages[0]).toContain('departing'); expect(messages[0]).toContain('public_web'); }); it('says nothing about a provider that was never deployed', async () => { // Unlike a pause, this is not a state an operator chose and can undo — the // provider holds nothing, so there is nothing to report. const messages: string[] = []; const logger = { info: () => {}, warn: (m: string) => messages.push(m), error: () => {}, success: () => {}, }; const result = await runConsumerCleanup( 'departing', [{ providerId: 'caddy', capabilityNames: ['public_web'], skip: 'not-deployed' }], db, logger, ); expect(result.skipped).toEqual([{ providerId: 'caddy', reason: 'not-deployed' }]); expect(messages).toEqual([]); }); }); describe('runConsumerCleanup — dispatch', () => { let dir: string; let db: DbClient; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'ccd-')); const dbPath = join(dir, 'celilo.db'); process.env.CELILO_DB_PATH = dbPath; db = await setupTestDatabaseAt(dbPath); }); afterEach(() => { db.$client.close(); resetTestDbPath(); rmSync(dir, { recursive: true, force: true }); }); const capture = () => { const warnings: string[] = []; return { warnings, logger: { info: () => {}, warn: (m: string) => warnings.push(m), error: () => {}, success: () => {}, }, }; }; const target = (providerId: string): CleanupTarget => ({ providerId, capabilityNames: ['firewall'], }); /** * `runNamedHook` reports a paused module as SUCCESS. Taking that at face value * would log a withdrawal that never happened — the silence this change exists * to end — so the flag is read rather than assumed. Reachable when a module is * paused between the plan and this dispatch. */ it('a provider paused between plan and dispatch is skipped, not counted as withdrawn', async () => { const { warnings, logger } = capture(); const result = await runConsumerCleanup( 'departing', [target('iptables')], db, logger, async () => ({ success: true, outputs: {}, duration: 0, skippedPaused: true, }), ); expect(result.notified).toEqual([]); expect(result.skipped).toEqual([{ providerId: 'iptables', reason: 'paused' }]); expect(result.failures).toEqual([]); expect(warnings[0]).toContain('iptables'); expect(warnings[0]).toContain('departing'); }); it('a provider with no such hook is not reported as having withdrawn anything', async () => { const { logger } = capture(); const result = await runConsumerCleanup( 'departing', [target('namecheap')], db, logger, async () => ({ success: true, outputs: {}, duration: 0, notDefined: true, }), ); // It mints nothing per consumer, so succeeding IS the right answer — but it // did not withdraw anything, and must not claim to. expect(result.notified).toEqual([]); expect(result.failures).toEqual([]); }); it('continues past a failure, and records it on the failing provider only (D13)', async () => { const { logger } = capture(); const told: string[] = []; const result = await runConsumerCleanup( 'departing', [target('axon'), target('iptables')], db, logger, async (providerId) => { told.push(providerId); return providerId === 'axon' ? { success: false, outputs: {}, duration: 0, error: 'router said no' } : { success: true, outputs: {}, duration: 0 }; }, ); // Both were told, in plan order — stopping early would leave MORE providers // holding state for a module about to disappear. expect(told).toEqual(['axon', 'iptables']); expect(result.failures).toEqual([{ providerId: 'axon', error: 'router said no' }]); expect(result.notified).toEqual(['iptables']); }); });