/** * Unit tests for the version-change classifier used by `module update`'s * registry-sweep mode, plus integration tests for `updateOne`'s * `displayVersion` / `quiet` opts that drive the cleaner sweep output. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import { eq } from 'drizzle-orm'; import { create as tarCreate } from 'tar'; import { type DbClient, getDb } from '../../db/client'; import { moduleIntegrity, modules } from '../../db/schema'; import { resetTestDbPath } from '../../test-utils/db-path'; import { classifyVersionChange, handleModuleUpdate, updateOne } from './module-update'; describe('classifyVersionChange', () => { test('identical versions are up-to-date', () => { expect(classifyVersionChange('1.0.0', '1.0.0')).toBe('up-to-date'); expect(classifyVersionChange('1.0.0+3', '1.0.0+3')).toBe('up-to-date'); expect(classifyVersionChange('2.4.7+5', '2.4.7+5')).toBe('up-to-date'); }); test('major bump → breaking', () => { expect(classifyVersionChange('1.0.0+3', '2.0.0+1')).toBe('major'); expect(classifyVersionChange('1.5.9', '2.0.0')).toBe('major'); // Even a tiny step into the next major counts as breaking; the // operator decides whether to take it. expect(classifyVersionChange('1.0.0+9', '2.0.0+0')).toBe('major'); }); test('minor bump → non-breaking', () => { expect(classifyVersionChange('1.0.0+3', '1.1.0+1')).toBe('minor'); expect(classifyVersionChange('2.5.0', '2.6.0')).toBe('minor'); }); test('patch bump → non-breaking', () => { expect(classifyVersionChange('1.0.0+3', '1.0.1+1')).toBe('patch'); expect(classifyVersionChange('2.5.7', '2.5.8')).toBe('patch'); }); test('revision-only bump (+N) → patch (non-breaking)', () => { // Exact case the user is hitting today: same code, fresh publish. expect(classifyVersionChange('1.0.0+3', '1.0.0+4')).toBe('patch'); expect(classifyVersionChange('namecheap-1.0.0', 'namecheap-1.0.0+5')).not.toBe('major'); expect(classifyVersionChange('3.1.0+0', '3.1.0+9')).toBe('patch'); }); test('installed ahead of registry → ahead (skip silently)', () => { // Operator pushed locally without publishing — registry is stale. expect(classifyVersionChange('2.0.0+1', '1.5.0+9')).toBe('ahead'); expect(classifyVersionChange('1.0.1', '1.0.0')).toBe('ahead'); expect(classifyVersionChange('1.0.0+5', '1.0.0+3')).toBe('ahead'); }); test('tolerates `v` / `=` prefixes', () => { expect(classifyVersionChange('v1.0.0+3', 'v1.0.0+4')).toBe('patch'); expect(classifyVersionChange('=1.0.0', '=1.1.0')).toBe('minor'); }); test('missing patch / revision segments default to 0', () => { expect(classifyVersionChange('1.0', '1.0.1')).toBe('patch'); expect(classifyVersionChange('1.0', '2.0')).toBe('major'); expect(classifyVersionChange('1.0.0', '1.0.0+1')).toBe('patch'); }); }); describe('updateOne — displayVersion and quiet', () => { let tempDir: string; let srcDir: string; let installedDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-upgrade-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; // Pre-installed module landing zone (where files get copied to). installedDir = join(tempDir, 'installed', 'testmod'); mkdirSync(installedDir, { recursive: true }); // "New" source dir we're upgrading from (mimics a registry extract). srcDir = join(tempDir, 'src'); mkdirSync(srcDir, { recursive: true }); writeFileSync( join(srcDir, 'manifest.yml'), `celilo_contract: "1.0" id: testmod name: Test Module version: 1.0.0 description: fixture `, ); db = getDb(); // Seed the modules table with the "currently installed" record. // We deliberately put a registry-style version string in here so // displayVersion-less upgrades preserve the previousVersion field. // celilo#1363: updatedAt is seeded frozen so the success assertions can // prove the stamp actually happens whenever the version moves. db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: installedDir, version: '1.0.0+5', updatedAt: new Date('2026-04-25T16:26:31Z'), manifestData: { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; }); test('returns previousVersion + newVersion in the success outcome', async () => { const result = await updateOne(srcDir, db, {}, { quiet: true }); expect(result.status).toBe('success'); if (result.status !== 'success') return; // narrow for ts expect(result.moduleId).toBe('testmod'); expect(result.previousVersion).toBe('1.0.0+5'); // No displayVersion supplied → falls back to the new manifest's // semver core (no +N visible to the operator from a path upgrade). expect(result.newVersion).toBe('1.0.0'); }); test('displayVersion is persisted to modules.version (so +N survives)', async () => { // The bug case: registry says 1.0.0+6, manifest says 1.0.0. Without // displayVersion, the +6 was dropped on the floor and `module list` // rolled back to "1.0.0", masking the actual installed revision. const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' }); expect(result.status).toBe('success'); if (result.status !== 'success') return; expect(result.newVersion).toBe('1.0.0+6'); const row = db.select().from(modules).all()[0]; expect(row.version).toBe('1.0.0+6'); }); // celilo#1363: the version moved (1.0.5+5 → +6) while updatedAt stayed at // 2026-04-25, so the audit read the module as untouched and fine. The stamp // must happen in updateOne itself, where every version write lives. test('writing a new version stamps updatedAt', async () => { const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '1.0.0+6' }); expect(result.status).toBe('success'); const row = db.select().from(modules).all()[0]; expect(row.version).toBe('1.0.0+6'); expect(row.updatedAt?.getTime()).toBeGreaterThan(new Date('2026-04-25T16:26:31Z').getTime()); }); test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => { // We can't easily intercept the log helpers' output without adding test // hooks, so instead we exercise that the call simply succeeds and // returns the structured outcome — the sweep relies on this to // render its own output without duplicates. A non-quiet call // exercises the same code path with the log lines enabled; both // return the same shape. const quietResult = await updateOne(srcDir, db, {}, { quiet: true }); expect(quietResult.status).toBe('success'); if (quietResult.status !== 'success') return; expect(quietResult.newVersion).toBe('1.0.0'); }); // Regression for the namecheap stale-findings problem: the upgrade // path MUST overwrite manifestData with the new manifest — otherwise // any subsequent audit (or any code path that reads from the DB) // sees the OLD manifest's required vars even after the operator // upgraded to a version that removed them. The user hit this on // celilo-mgmt: namecheap@3.1.1+6 dropped the `domains` variable, but // post-upgrade the audit still complained "required config 'domains' // is not set" because the DB's manifestData hadn't been refreshed. test('persists new manifest to modules.manifestData (not just .version)', async () => { // Write a brand-new manifest.yml in srcDir that REMOVES a // variable the old DB record had. After upgrade, the modules // row's manifestData should reflect the removal. writeFileSync( join(srcDir, 'manifest.yml'), `celilo_contract: "1.0" id: testmod name: Test Module Renamed version: 2.0.0 description: fixture v2 variables: owns: [] imports: [] `, ); // Simulate the DB starting in a state where manifestData has a // `variables.owns` array — the namecheap-3.1.0 → 3.1.1 case. db.update(modules) .set({ manifestData: { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', variables: { owns: [{ name: 'domains', type: 'array', required: true }], imports: [] }, }, }) .where(eq(modules.id, 'testmod')) .run(); const result = await updateOne(srcDir, db, {}, { quiet: true, displayVersion: '2.0.0+1' }); expect(result.status).toBe('success'); const row = db.select().from(modules).all()[0]; // version field updates (this part already worked): expect(row.version).toBe('2.0.0+1'); expect(row.name).toBe('Test Module Renamed'); // manifestData reflects the NEW manifest — the dropped variable // is gone. This is the regression assertion. const manifest = row.manifestData as { version: string; name: string; variables?: { owns: unknown[] }; }; expect(manifest.version).toBe('2.0.0'); expect(manifest.name).toBe('Test Module Renamed'); expect(manifest.variables?.owns ?? []).toEqual([]); }); // Regression for ISS-0091: `module update` used to skip event-bus // subscription registration (only `module import` did it), so a // refreshed module silently lost its reconcile subscriptions — found // live when caddy's reconcile_routes subscription vanished after a // path-based update. test('registers the new manifest subscriptions on the bus (ISS-0091)', async () => { process.env.EVENT_BUS_DB = join(tempDir, 'events.db'); try { writeFileSync( join(srcDir, 'manifest.yml'), `celilo_contract: "1.0" id: testmod name: Test Module version: 1.1.0 description: fixture with subscriptions subscriptions: - name: testmod-tick pattern: timer.tick.15m handler: "true" `, ); const result = await updateOne(srcDir, db, {}, { quiet: true }); expect(result.status).toBe('success'); const { openBus, defineEvents } = await import('@celilo/event-bus'); const bus = openBus({ dbPath: join(tempDir, 'events.db'), events: defineEvents({}) }); try { // Subscriber names are scoped `.` on the bus. expect(bus.getSubscriberByName('testmod.testmod-tick')).not.toBeNull(); } finally { bus.close(); } } finally { delete process.env.EVENT_BUS_DB; } }); }); /** * Regression for the fabricated decline: driven headlessly with no responder, * the registry sweep reported a breaking update as "operator declined" — a * decision nobody was asked to make. An unanswerable question is not a "no". */ describe('registry sweep — an unanswered breaking update is not a decline', () => { let tempDir: string; let server: ReturnType; let registryUrl: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-sweep-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; // Isolated bus with no responder attached — the headless case. process.env.EVENT_BUS_DB = join(tempDir, 'events.db'); getDb() .insert(modules) .values({ id: 'iptables', name: 'iptables', sourcePath: join(tempDir, 'installed'), version: '1.0.2+9', manifestData: { celilo_contract: '1.0', id: 'iptables', name: 'iptables', version: '1.0.2', }, }) .run(); // Minimal sparse-index server offering a major bump for `iptables`. server = Bun.serve({ port: 0, fetch(req) { const path = new URL(req.url).pathname; if (path === '/index/ip/ta/iptables') { return new Response( `${JSON.stringify({ name: 'iptables', vers: '2.0.0+1', deps: [], cksum: 'x' })}\n`, ); } return new Response('not found', { status: 404 }); }, }); registryUrl = `http://localhost:${server.port}`; }); afterEach(() => { server.stop(true); rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; delete process.env.EVENT_BUS_DB; }); test('reports it as unanswered, never as declined, and fails the sweep', async () => { const result = await handleModuleUpdate([], { registry: registryUrl }); const report = result.success ? (result.message ?? '') : (result.error ?? ''); expect(report).not.toContain('operator declined'); expect(report).toContain('NOT declined'); expect(report).toContain('iptables'); // A breaking update that silently didn't land must not read as success. expect(result.success).toBe(false); // And the module is still on the old version — no accidental upgrade. expect(getDb().select().from(modules).all()[0].version).toBe('1.0.2+9'); }); }); /** * Every file under `root`, keyed by relative path, valued by its exact bytes. * Compared whole so a TRUNCATION shows up — an existence check would not. */ function snapshot(root: string, dir = root): Record { const files: Record = {}; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) Object.assign(files, snapshot(root, full)); else if (entry.isFile()) files[relative(root, full)] = readFileSync(full, 'utf-8'); } return files; } describe("updateOne — pointed at the module's own installed path", () => { let tempDir: string; let installedDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-selfpath-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; installedDir = join(tempDir, 'installed', 'selfmod'); mkdirSync(installedDir, { recursive: true }); writeFileSync( join(installedDir, 'manifest.yml'), `celilo_contract: "1.0" id: selfmod name: Self Module version: 1.0.0 description: fixture `, ); db = getDb(); db.insert(modules) .values({ id: 'selfmod', name: 'Self Module', sourcePath: installedDir, version: '1.0.0', manifestData: { celilo_contract: '1.0', id: 'selfmod', name: 'Self Module', version: '1.0.0', }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; }); test('refuses by name, and writes NOTHING', async () => { // A data-loss guard, not a UX nicety. On celilo-mgr on 2026-08-19, // `celilo module update /var/celilo/modules/wireguard-manager` threw // `EINVAL: copy_file_range` and TRUNCATED // `ansible/roles/wireguard-manager/handlers/main.yml` in the module source // on the way down. The crash was mid-copy, so the tree was left damaged // rather than untouched. It stayed invisible for nine hours because // `generated/` still held a good copy; the next generate propagated the // truncation, and the deploy after that died with "The requested handler // 'Restart wireguard-manager' was not found". App health, ingress and // tunnel peers were all green throughout — none of them can see a // truncated Ansible handler. // // So this compares the whole tree byte for byte rather than checking that // a file still exists. "It threw" was already true of the behaviour that // caused the damage, and an existence check cannot see a truncation. const before = snapshot(installedDir); const result = await updateOne(installedDir, db, {}, { quiet: true }); expect(result.status).toBe('failed'); if (result.status !== 'failed') return; expect(result.error).toContain('IS the installed copy of selfmod'); expect(result.error).not.toContain('EINVAL'); expect(snapshot(installedDir)).toEqual(before); }); }); /** * celilo#1008. `updateOne` copied the new tree onto the installed one in * place, so a copy that died partway left a module half old and half new, * with no record anywhere of which files were which. Jeremy Banka's * `f9a57f1b` staged into a sibling and swapped with two renames; the rest of * that commit is superseded by celilo#925, but the atomicity is not, and this * is where it lands. * * The failure is provoked rather than injected, so nothing test-only reaches * production code. The source carries a FIFO, which `cpSync` refuses with * ENOTSUP — a stand-in for any mid-copy failure a real update can hit (a full * disk, a permission, an I/O error). It is chosen because it fails on the * SOURCE, so it fires whether the copy targets the live install or a staging * directory, which a destination-side collision would not. `z-` sorts last, * so the entries ahead of it copy successfully first — precisely the * half-applied state being ruled out. */ describe('updateOne — an update that fails partway leaves the install untouched', () => { let tempDir: string; let srcDir: string; let installedDir: string; let db: DbClient; /** Every file under `root`, relative path → bytes, for an exact comparison. */ function snapshotTree(root: string, dir = root): Record { const out: Record = {}; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) Object.assign(out, snapshotTree(root, full)); else out[relative(root, full)] = readFileSync(full, 'utf-8'); } return out; } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-atomic-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; installedDir = join(tempDir, 'installed', 'testmod'); mkdirSync(join(installedDir, 'scripts'), { recursive: true }); mkdirSync(join(installedDir, 'generated'), { recursive: true }); writeFileSync( join(installedDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n', ); writeFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'export const OLD = 1;\n'); // `derived` — celilo's own output, must survive an update either way. writeFileSync(join(installedDir, 'generated', 'terraform.tfstate'), '{"old":true}\n'); srcDir = join(tempDir, 'src'); mkdirSync(join(srcDir, 'scripts'), { recursive: true }); writeFileSync( join(srcDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture\n', ); writeFileSync(join(srcDir, 'scripts', 'on_install.ts'), 'export const NEW = 2;\n'); // cpSync refuses a FIFO with ENOTSUP. Sorts last, so the real files copy first. execFileSync('mkfifo', [join(srcDir, 'z-boom')]); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: installedDir, version: '1.0.0', manifestData: { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; }); test('a failed update leaves the installed tree byte-identical', async () => { const before = snapshotTree(installedDir); expect(before['scripts/on_install.ts']).toBe('export const OLD = 1;\n'); await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow(); expect(snapshotTree(installedDir)).toEqual(before); }); test('a successful update keeps celilo output and drops what the version removed', async () => { // Same fixture minus the FIFO, so the update runs to completion. rmSync(join(srcDir, 'z-boom')); // A file the previous version shipped and the new one does not. writeFileSync(join(installedDir, 'scripts', 'gone_in_2.ts'), 'export const OLD = 1;\n'); const result = await updateOne(srcDir, db, {}, { quiet: true }); expect(result.status).toBe('success'); // `derived`: celilo's own output survives (task 11.6). expect(readFileSync(join(installedDir, 'generated', 'terraform.tfstate'), 'utf-8')).toBe( '{"old":true}\n', ); // `package`: the new version's content landed... expect(readFileSync(join(installedDir, 'scripts', 'on_install.ts'), 'utf-8')).toBe( 'export const NEW = 2;\n', ); // ...and what it dropped is gone, pruned in staging rather than in place. expect(existsSync(join(installedDir, 'scripts', 'gone_in_2.ts'))).toBe(false); // No staging debris on the success path either. expect(readdirSync(join(tempDir, 'installed'))).toEqual(['testmod']); }); test('a failed update leaves no staging directory behind', async () => { await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow(); const siblings = readdirSync(join(tempDir, 'installed')); expect(siblings).toEqual(['testmod']); }); test('a failed update does not advance the integrity baseline', async () => { await expect(updateOne(srcDir, db, {}, { quiet: true })).rejects.toThrow(); const row = db .select() .from(moduleIntegrity) .where(eq(moduleIntegrity.moduleId, 'testmod')) .get(); // Nothing recorded at all: the update never reached a state worth claiming. expect(row).toBeUndefined(); // And the module row still names the version actually on disk. const mod = db.select().from(modules).where(eq(modules.id, 'testmod')).get(); expect(mod?.version).toBe('1.0.0'); }); }); describe('updateOne — a .netapp package source', () => { let tempDir: string; let installedDir: string; let db: DbClient; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'celilo-netapp-')); process.env.CELILO_DB_PATH = join(tempDir, 'test.db'); process.env.CELILO_ORIGINAL_CWD = tempDir; installedDir = join(tempDir, 'installed', 'testmod'); mkdirSync(installedDir, { recursive: true }); writeFileSync( join(installedDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 1.0.0\ndescription: fixture\n', ); db = getDb(); db.insert(modules) .values({ id: 'testmod', name: 'Test Module', sourcePath: installedDir, version: '1.0.0+5', manifestData: { celilo_contract: '1.0', id: 'testmod', name: 'Test Module', version: '1.0.0', }, }) .run(); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); resetTestDbPath(); delete process.env.CELILO_ORIGINAL_CWD; }); // ce-h4of: the temp-dir cleanup ran BEFORE the staged swap, and // `replaceInstalledModule` still reads the extracted package through // `actualPath`, which IS the temp dir for a .netapp source. Every // registry-driven upgrade died with `ENOENT ... scandir // .tmp-module-extract/`. Directory sources never set tempDir, which // is why the path-source tests above stayed green. This test drives the // real extraction path to keep that ordering honest. test('the staged swap reads the package before the temp dir is cleaned', async () => { const srcDir = join(tempDir, 'pkg-src'); mkdirSync(srcDir, { recursive: true }); writeFileSync( join(srcDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: testmod\nname: Test Module\nversion: 2.0.0\ndescription: fixture v2\n', ); const pkgPath = join(tempDir, 'testmod-2.0.0.netapp'); await tarCreate({ file: pkgPath, cwd: srcDir }, ['manifest.yml']); const extractRoot = join(process.cwd(), '.tmp-module-extract'); const extractCountBefore = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0; // Registry packages are pre-verified at publish time; updateOne gets // skip-verify from the sweep, so no signature.sig is needed here. const result = await updateOne( pkgPath, db, { 'skip-verify': true }, { quiet: true, displayVersion: '2.0.0+7' }, ); expect(result.status).toBe('success'); if (result.status !== 'success') return; expect(result.previousVersion).toBe('1.0.0+5'); expect(result.newVersion).toBe('2.0.0+7'); expect(readFileSync(join(installedDir, 'manifest.yml'), 'utf-8')).toContain('version: 2.0.0'); // The extraction scratch space is gone again once the swap has read it. const extractCountAfter = existsSync(extractRoot) ? readdirSync(extractRoot).length : 0; expect(extractCountAfter).toBe(extractCountBefore); }); });