test: SHA normalization and registry-loader unit tests

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=<path> node --test test/registry-loader.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kavi 2026-05-23 04:34:30 -04:00
parent 6a583a8572
commit d5508249f2
2 changed files with 114 additions and 0 deletions

View File

@ -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');
});

View File

@ -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:<hex>. 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');
});