/** * The registry-poll capability gate (celilo#1361): `module upgrade` reads the * TARGET version's manifest BEFORE updating anything, and defers — without * changing any state — when the deployed capability providers cannot serve * its requirements. * * celilo-website burned six release revisions (+1 through +6) on exactly * this: every poll attempt updated the stored version, failed in caddy's * publish hook ("sourceDir is required" against a provider built before * b67c9423), recorded the new version beside the old VERIFIED state, and * re-triggered on the next tick. The gate makes that sequence impossible. * * The RegistryClient is spied (the established pattern in * `module-import-registry.test.ts`); the download returns a REAL tar package * carrying only a manifest, which is all `fetchTargetManifest` reads. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { Mock } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { create as tarCreate } from 'tar'; import { getDb } from '../../db/client'; import { capabilities, modules } from '../../db/schema'; import type { IndexEntry } from '../../registry/client'; import { RegistryClient } from '../../registry/client'; import { resetTestDbPath } from '../../test-utils/db-path'; import { handleModuleUpgrade } from './module-upgrade'; function entry(vers: string): IndexEntry { return { name: 'celilo-website', vers, deps: [], cksum: 'abc', yanked: false }; } /** A minimal registry package: a tar whose root is manifest.yml. */ async function packageWithManifest(manifestYaml: string): Promise { const dir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-pkg-')); await writeFile(join(dir, 'manifest.yml'), manifestYaml); const tarPath = join(dir, 'pkg.netapp'); await tarCreate({ file: tarPath, cwd: dir, portable: true }, ['manifest.yml']); const bytes = await Bun.file(tarPath).arrayBuffer(); rmSync(dir, { recursive: true, force: true }); return bytes; } const MANIFEST = (publicWebVersion: string) => ` celilo_contract: "1.0" id: celilo-website name: Celilo Website version: 2.0.0 requires: capabilities: - name: public_web version: ${publicWebVersion} `; let tempDir: string; let getIndexSpy: Mock<(name: string) => Promise>; let downloadSpy: Mock<(name: string, vers: string) => Promise>; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-test-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_DATA_DIR = tempDir; getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([entry('2.0.0')]); downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(new ArrayBuffer(0)); }); afterEach(() => { getIndexSpy.mockRestore(); downloadSpy.mockRestore(); rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_DATA_DIR; }); function seedFleet(caddyPublicWebVersion: string): void { const db = getDb(); // sourcePath must be a real writable dir: when the gate does NOT fire, the // flow proceeds into updateOne, which stages the new copy beside it. const installDir = (id: string): string => { const p = join(tempDir, 'modules', id); mkdirSync(p, { recursive: true }); return p; }; db.insert(modules) .values({ id: 'celilo-website', name: 'Celilo Website', version: '1.0.5', manifestData: { id: 'celilo-website', name: 'Celilo Website', version: '1.0.5' }, sourcePath: installDir('celilo-website'), }) .run(); db.insert(modules) .values({ id: 'caddy', name: 'Caddy', version: '2.3.3+1', manifestData: { id: 'caddy', name: 'Caddy', version: '2.3.3' }, sourcePath: installDir('caddy'), }) .run(); db.insert(capabilities) .values({ moduleId: 'caddy', capabilityName: 'public_web', version: caddyPublicWebVersion, data: {}, }) .run(); } describe('handleModuleUpgrade — the capability gate (celilo#1361)', () => { test('defers an upgrade whose requirement no deployed provider serves', async () => { seedFleet('3.1.0'); downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('4.0.0'))); const result = await handleModuleUpgrade(['celilo-website'], {}); expect(result.success).toBe(false); if (!result.success) { expect(result.deferred).toBe(true); expect(result.error).toContain('deferred'); expect(result.error).toContain('public_web@4.0.0'); expect(result.error).toContain('caddy'); } // The point of deferring: NO state changed. The stored version still // reads the installed release and will re-read as up-to-date-candidate // next tick instead of looking upgraded-but-broken (celilo#1363's shape). const row = getDb() .select() .from(modules) .all() .find((m) => m.id === 'celilo-website'); expect(row?.version).toBe('1.0.5'); }); test('does not defer when the deployed provider serves the requirement', async () => { seedFleet('3.1.0'); downloadSpy.mockImplementation(async () => packageWithManifest(MANIFEST('3.0.0'))); const result = await handleModuleUpgrade(['celilo-website'], {}); // Whatever happens downstream (the minimal package has no scripts), the // gate itself must not have fired — `deferred` marks a capability wall. if (!result.success) { expect(result.deferred).toBeUndefined(); } else { expect(result.success).toBe(true); } }); });