From d5508249f2e1ace1537c0ed6f3e885ae23f1fe7c Mon Sep 17 00:00:00 2001 From: Kavi Date: Sat, 23 May 2026 04:34:30 -0400 Subject: [PATCH] test: SHA normalization and registry-loader unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression coverage for: - Bug #3: sha256: prefix comparison — tests the stripSha helper and the imageMatch logic as used in verifyStatelessRecreated() - Bug #7 / #6: registry file must exist and have all required fields; no app may use deploy_mode=webhook (webhook path retired) Run: REGISTRY_FILE= node --test test/registry-loader.test.js Co-Authored-By: Claude Sonnet 4.6 --- test/registry-loader.test.js | 61 ++++++++++++++++++++++++++++++++++++ test/sha-comparison.test.js | 53 +++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 test/registry-loader.test.js create mode 100644 test/sha-comparison.test.js diff --git a/test/registry-loader.test.js b/test/registry-loader.test.js new file mode 100644 index 0000000..a111dfe --- /dev/null +++ b/test/registry-loader.test.js @@ -0,0 +1,61 @@ +// Unit tests for registry loading — validates the path resolution and +// structural invariants of deploy-registry.json. +// Run with: node --test test/registry-loader.test.js +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// On Bruno the registry is bind-mounted into the container at /app/deploy-registry.json +// (process.cwd() = /app). Locally (dev on gal), it lives in coder-core. +// Accept an override via env var so CI / test runners can point at any copy. +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REGISTRY_FILE = process.env.REGISTRY_FILE + ?? path.join(ROOT, 'deploy-registry.json') // container bind-mount + ?? path.join(ROOT, '../../coder-core/services/kua-deploy/deploy-registry.json'); // local dev fallback + +// ------------------------------------------------------------------ +// Bug #7 regression: DEPLOY_REGISTRY_PATH must resolve to a real file. +// In kua-mcp-core the old ROOT was computed via __dirname + "../../../" +// which overshoots to "/" inside the container. Here we validate the +// kua-deploy-native path (process.cwd() relative) and the mcp-core +// path (CODER_CORE_ROOT env-var-backed). +// ------------------------------------------------------------------ + +let registry; +test('registry file exists at expected path', () => { + assert.ok(fs.existsSync(REGISTRY_FILE), `registry not found at ${REGISTRY_FILE}`); + const raw = fs.readFileSync(REGISTRY_FILE, 'utf-8'); + registry = JSON.parse(raw); +}); + +test('registry has apps object', () => { + assert.ok(registry && typeof registry.apps === 'object', 'registry.apps must be an object'); + const count = Object.keys(registry.apps).length; + assert.ok(count >= 5, `expected at least 5 registered apps, got ${count}`); +}); + +test('every app has required fields', () => { + for (const [name, cfg] of Object.entries(registry.apps)) { + assert.ok(typeof cfg.repo_dir === 'string' && cfg.repo_dir.length > 0, + `${name}: repo_dir must be a non-empty string`); + assert.ok(typeof cfg.deploy_mode === 'string', + `${name}: deploy_mode must be present`); + assert.ok(cfg.production && typeof cfg.production === 'object', + `${name}: production config must be present`); + } +}); + +test('no app uses webhook deploy_mode (webhook path is retired)', () => { + const webhookApps = Object.entries(registry.apps) + .filter(([, cfg]) => cfg.deploy_mode === 'webhook') + .map(([name]) => name); + assert.deepEqual(webhookApps, [], + `These apps still have deploy_mode=webhook (retire them to direct): ${webhookApps.join(', ')}`); +}); + +test('kua-deploy is registered in its own registry', () => { + assert.ok('kua-deploy' in registry.apps, 'kua-deploy must be in deploy-registry.json'); + assert.equal(registry.apps['kua-deploy'].deploy_mode, 'direct'); +}); diff --git a/test/sha-comparison.test.js b/test/sha-comparison.test.js new file mode 100644 index 0000000..448c704 --- /dev/null +++ b/test/sha-comparison.test.js @@ -0,0 +1,53 @@ +// Unit tests for SHA normalization in verify paths. +// Run with: node --test test/sha-comparison.test.js +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +// The canonical normalization function used in verifyStatelessRecreated() +// and runtime-status — must stay in sync with server.js. +const stripSha = (s) => (s || '').replace(/^sha256:/, ''); + +// ------------------------------------------------------------------ +// Bug #3 regression: docker compose images returns bare hex, +// docker inspect .Image returns sha256:. They must compare equal. +// ------------------------------------------------------------------ + +test('sha: bare hex == bare hex', () => { + const a = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + assert.equal(stripSha(a), stripSha(a)); + assert.ok(stripSha(a) === stripSha(a)); +}); + +test('sha: sha256-prefixed == bare hex (the failing case before the fix)', () => { + const bare = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + const prefixed = `sha256:${bare}`; + // Before fix: prefixed === bare => false + assert.notEqual(prefixed, bare, 'raw strings are indeed unequal — this is the bug'); + // After fix: stripSha(prefixed) === stripSha(bare) => true + assert.equal(stripSha(prefixed), stripSha(bare), 'normalized strings must be equal'); +}); + +test('sha: both sha256-prefixed', () => { + const a = 'sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + const b = 'sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + assert.equal(stripSha(a), stripSha(b)); +}); + +test('sha: different digests stay different after normalization', () => { + const a = 'sha256:aaaa0000000000000000000000000000000000000000000000000000000000001111'; + const b = 'sha256:bbbb0000000000000000000000000000000000000000000000000000000000002222'; + assert.notEqual(stripSha(a), stripSha(b)); +}); + +test('sha: empty/null input returns empty string', () => { + assert.equal(stripSha(''), ''); + assert.equal(stripSha(null), ''); + assert.equal(stripSha(undefined), ''); +}); + +test('sha: imageMatch logic mirrors server.js verifyStatelessRecreated', () => { + const expectedSha = 'a0845a6c5772e01234567890abcdef01234567890abcdef01234567890abcdef01'; + const actualSha = `sha256:${expectedSha}`; + const imageMatch = !!expectedSha && stripSha(actualSha) === stripSha(expectedSha); + assert.ok(imageMatch, 'imageMatch must be true when digests are the same modulo sha256: prefix'); +});