186 lines
11 KiB
JavaScript
186 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const test = require('node:test');
|
|
const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { createHash } = require('node:crypto');
|
|
const {
|
|
startStable, verifyStableEvidence, verifyBootstrapSession, applyExactEnvironment,
|
|
calculateCompiledArtifactDigest, candidateLockChecksum, fetchReleaseEvidence, sourceCommit
|
|
} = require('../start-stable');
|
|
|
|
function fixture() {
|
|
const lock = { moduleCode: 'demo', candidateVersion: '1.0.1-rc.2', buildRevision: 2, buildChecksum: 'b'.repeat(64), schemaInputChecksum: 'c'.repeat(64), branchName: 'release/1.0.1-rc.2' };
|
|
const generated = { moduleCode: 'demo', revision: 2, schemaVersion: 3, schemaInputChecksum: lock.schemaInputChecksum, implementationManifestChecksum: 'd'.repeat(64), runtimeContractChecksum: 'e'.repeat(64) };
|
|
const commit = 'a'.repeat(40);
|
|
const digest = `sha256:${'f'.repeat(64)}`;
|
|
const release = {
|
|
id: 42, moduleId: 2, releaseType: 'stable', status: 'published', lifecycleVersion: 1,
|
|
version: '1.0.1', serviceVersion: '1.0.1', promotedFromReleaseId: 41, buildSnapshotId: 52,
|
|
buildRevision: 2, frozenArtifactId: 62, schemaVersion: 3, schemaChecksum: '3'.repeat(64),
|
|
build: { id: 52, moduleId: 2, revision: 2, schemaVersion: 3, buildChecksum: lock.buildChecksum, schemaInputChecksum: lock.schemaInputChecksum },
|
|
frozenArtifact: { id: 62, rcReleaseId: 41, moduleId: 2, buildRevision: 2, sourceBranch: lock.branchName, tagName: `v${lock.candidateVersion}`, sourceCommit: commit, generatedLockChecksum: candidateLockChecksum(lock), artifactDigest: digest, artifactIdentityChecksum: '1'.repeat(64), implementationManifestChecksum: generated.implementationManifestChecksum },
|
|
schemaSnapshot: { actionManifest: { artifactDigest: digest }, actionManifestChecksum: '2'.repeat(64), runtimeContracts: { implementationManifestChecksum: generated.implementationManifestChecksum, runtimeContractChecksum: generated.runtimeContractChecksum } }
|
|
};
|
|
return { release, lock, generated, digest, commit, releaseId: 42 };
|
|
}
|
|
|
|
function sessionFor(exact) {
|
|
return { moduleCode: exact.RPC_MODULE_CODE, revision: Number(exact.SAAS_BUILD_REVISION), release: {
|
|
releaseId: Number(exact.RPC_RELEASE_ID), releaseVersion: exact.RPC_RELEASE_VERSION, serviceVersion: exact.RPC_SERVICE_VERSION,
|
|
runtimeMode: exact.SAAS_RUNTIME_MODE, buildRevision: Number(exact.SAAS_BUILD_REVISION), buildChecksum: exact.SAAS_BUILD_CHECKSUM,
|
|
schemaInputChecksum: exact.SAAS_SCHEMA_INPUT_CHECKSUM, implementationManifestChecksum: exact.RPC_IMPLEMENTATION_MANIFEST_CHECKSUM,
|
|
artifactDigest: exact.RPC_ARTIFACT_DIGEST, artifactIdentityChecksum: exact.RPC_ARTIFACT_IDENTITY_CHECKSUM
|
|
} };
|
|
}
|
|
|
|
test('Stable derives its own deployment envelope from the same frozen Candidate and Build', () => {
|
|
const input = fixture();
|
|
const before = JSON.stringify(input);
|
|
const exact = verifyStableEvidence(input);
|
|
assert.equal(exact.RPC_RELEASE_ID, '42');
|
|
assert.equal(exact.RPC_RELEASE_VERSION, '1.0.1');
|
|
assert.equal(exact.SAAS_RUNTIME_MODE, 'production');
|
|
assert.equal(exact.SAAS_BUILD_REVISION, '2');
|
|
assert.equal(exact.BUILD_SHA, input.commit);
|
|
assert.equal(exact.RPC_ARTIFACT_DIGEST, input.digest);
|
|
assert.equal(JSON.stringify(input), before, 'must never rewrite the Candidate or frozen evidence');
|
|
verifyBootstrapSession(sessionFor(exact), exact);
|
|
});
|
|
|
|
test('fail closed on RC targets, history-only artifacts, or any frozen identity mismatch', () => {
|
|
const edits = [
|
|
data => { data.release.releaseType = 'rc'; },
|
|
data => { data.release.status = 'retired'; },
|
|
data => { data.release.version = '1.0.1-rc.2'; },
|
|
data => { data.release.frozenArtifact.artifactDigest = null; },
|
|
data => { data.release.frozenArtifact.rcReleaseId++; },
|
|
data => { data.release.buildSnapshotId++; },
|
|
data => { data.release.build.buildChecksum = '9'.repeat(64); },
|
|
data => { data.release.buildRevision++; },
|
|
data => { data.release.frozenArtifact.generatedLockChecksum = '9'.repeat(64); },
|
|
data => { data.release.frozenArtifact.tagName = 'v9.0.0'; },
|
|
data => { data.commit = '9'.repeat(40); },
|
|
data => { data.digest = `sha256:${'9'.repeat(64)}`; },
|
|
data => { data.generated.revision++; },
|
|
data => { data.generated.releaseVersion = '1.0.1-rc.2'; },
|
|
data => { data.generated.implementationManifestChecksum = '9'.repeat(64); },
|
|
data => { data.generated.runtimeContractChecksum = '9'.repeat(64); },
|
|
data => { data.generated.schemaVersion++; },
|
|
data => { data.release.schemaSnapshot.actionManifest.artifactDigest = `sha256:${'9'.repeat(64)}`; }
|
|
];
|
|
for (const edit of edits) {
|
|
const input = fixture();
|
|
edit(input);
|
|
assert.throws(() => verifyStableEvidence(input), /Stable startup refused/);
|
|
}
|
|
});
|
|
|
|
test('environment and authenticated bootstrap cannot silently replace Stable with RC or a different Build', () => {
|
|
const exact = verifyStableEvidence(fixture());
|
|
assert.throws(() => applyExactEnvironment({ RPC_RELEASE_ID: '41' }, exact), /conflicts/);
|
|
const environment = {};
|
|
applyExactEnvironment(environment, exact);
|
|
assert.equal(environment.RPC_ARTIFACT_DIGEST, exact.RPC_ARTIFACT_DIGEST);
|
|
for (const key of ['releaseId', 'releaseVersion', 'buildChecksum', 'runtimeMode', 'artifactDigest', 'artifactIdentityChecksum']) {
|
|
const session = sessionFor(exact);
|
|
session.release[key] = 'incorrect';
|
|
assert.throws(() => verifyBootstrapSession(session, exact), /differs/);
|
|
}
|
|
});
|
|
|
|
test('compiled digest uses the exact finalize byte format and detects changes', t => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-digest-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(path.join(root, 'dist/nested'), { recursive: true });
|
|
writeFileSync(path.join(root, 'dist/a.js'), 'first');
|
|
writeFileSync(path.join(root, 'dist/nested/b.js'), 'second');
|
|
const exactHash = createHash('sha256').update('COOL_SAAS_ARTIFACT_DIRECTORY_V1\0', 'utf8');
|
|
for (const [name, content] of [['a.js', 'first'], ['nested/b.js', 'second']]) {
|
|
exactHash.update(`${Buffer.byteLength(name)}:`).update(name).update(`:${Buffer.byteLength(content)}:`).update(content);
|
|
}
|
|
assert.equal(calculateCompiledArtifactDigest(root), `sha256:${exactHash.digest('hex')}`);
|
|
const oldDigest = calculateCompiledArtifactDigest(root);
|
|
writeFileSync(path.join(root, 'dist/a.js'), 'changed');
|
|
assert.notEqual(calculateCompiledArtifactDigest(root), oldDigest);
|
|
});
|
|
|
|
test('missing, empty, and linked compiled directories are refused', t => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-linked-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
assert.throws(() => calculateCompiledArtifactDigest(root), /does not exist/);
|
|
mkdirSync(path.join(root, 'dist'));
|
|
assert.throws(() => calculateCompiledArtifactDigest(root), /empty/);
|
|
mkdirSync(path.join(root, 'outside'));
|
|
writeFileSync(path.join(root, 'outside/escaped.js'), 'outside the frozen directory');
|
|
symlinkSync(path.join(root, 'outside'), path.join(root, 'dist/linked'), 'junction');
|
|
assert.throws(() => calculateCompiledArtifactDigest(root), /symbolic links/);
|
|
});
|
|
|
|
test('control-plane evidence requires a Bearer credential and local HTTP cannot target a remote host', async () => {
|
|
const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', SAAS_CONTROL_PLANE_URL: 'http://127.0.0.1:8080', COOL_SAAS_TOKEN: 'test-short-lived-credential' };
|
|
const release = fixture().release;
|
|
assert.equal(await fetchReleaseEvidence(environment, async (url, options) => {
|
|
assert.equal(url.searchParams.get('id'), '42');
|
|
assert.equal(options.headers.Authorization, `Bearer ${environment.COOL_SAAS_TOKEN}`);
|
|
assert.equal(options.redirect, 'error');
|
|
return { ok: true, json: async () => ({ code: 1000, data: release }) };
|
|
}), release);
|
|
await assert.rejects(fetchReleaseEvidence({ ...environment, COOL_SAAS_TOKEN: '' }), /credential/);
|
|
await assert.rejects(fetchReleaseEvidence({ ...environment, SAAS_CONTROL_PLANE_URL: 'http://example.com' }), /HTTPS/);
|
|
await assert.rejects(fetchReleaseEvidence(environment, async () => ({ ok: false, status: 401 })), /rejected/);
|
|
});
|
|
|
|
test('actual startup verifies existing bytes, removes CLI secrets, and never invokes a build', async t => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-start-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(path.join(root, 'dist'), { recursive: true });
|
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'frozen compiled bytes');
|
|
const data = fixture();
|
|
data.digest = calculateCompiledArtifactDigest(root);
|
|
data.release.frozenArtifact.artifactDigest = data.digest;
|
|
data.release.schemaSnapshot.actionManifest.artifactDigest = data.digest;
|
|
writeFileSync(path.join(root, '.cool-saas-candidate.json'), JSON.stringify(data.lock));
|
|
const environment = { NODE_ENV: 'local', RPC_RELEASE_ID: '42', BUILD_SHA: data.commit, COOL_SAAS_TOKEN: 'short-lived-test', NODE_AUTH_TOKEN: 'must-not-inherit' };
|
|
let starts = 0;
|
|
const options = {
|
|
projectDir: root, environment,
|
|
fetch: async () => ({ ok: true, json: async () => ({ code: 1000, data: data.release }) }),
|
|
loadGenerated: () => data.generated,
|
|
start: ({ verifySession }) => {
|
|
starts++;
|
|
assert.equal(environment.COOL_SAAS_TOKEN, undefined);
|
|
assert.equal(environment.NODE_AUTH_TOKEN, undefined);
|
|
verifySession(sessionFor(environment));
|
|
return 'started';
|
|
}
|
|
};
|
|
assert.equal(await startStable(options), 'started');
|
|
assert.equal(starts, 1);
|
|
assert.equal(readFileSync(path.join(root, 'dist/configuration.js'), 'utf8'), 'frozen compiled bytes');
|
|
writeFileSync(path.join(root, 'dist/configuration.js'), 'tampered');
|
|
environment.COOL_SAAS_TOKEN = 'short-lived-test';
|
|
options.loadGenerated = () => { throw new Error('must not execute unverified compiled code'); };
|
|
await assert.rejects(startStable(options), /compiled artifact digest differs/);
|
|
assert.equal(starts, 1);
|
|
});
|
|
|
|
test('dirty Git checkouts cannot be presented as the frozen source commit', t => {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'stable-git-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const git = (...args) => {
|
|
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
return result.stdout.trim();
|
|
};
|
|
git('init'); git('config', 'user.email', 'test@example.invalid'); git('config', 'user.name', 'Stable Test');
|
|
writeFileSync(path.join(root, 'business.js'), 'original');
|
|
git('add', '.'); git('commit', '-m', 'frozen');
|
|
assert.equal(sourceCommit(root, {}), git('rev-parse', 'HEAD'));
|
|
writeFileSync(path.join(root, 'business.js'), 'modified');
|
|
assert.throws(() => sourceCommit(root, {}), /dirty/);
|
|
});
|