Files
ForgeFlow/tests/deployment-status.test.mjs
T

175 lines
8.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import deploymentModule from '../src/main/deployment-service.cjs';
const { DeploymentService, terminalRunConclusion, applicationVerificationFailure } = deploymentModule;
test('maps runner conclusions to ForgeFlow terminal states', () => {
assert.equal(terminalRunConclusion({ conclusion: 'success' }), 'success');
assert.equal(terminalRunConclusion({ conclusion: 'failure' }), 'failed');
assert.equal(terminalRunConclusion({ status: 'timed_out' }), 'failed');
assert.equal(terminalRunConclusion({ conclusion: 'cancelled' }), 'cancelled');
assert.equal(terminalRunConclusion({ status: 'running' }), null);
});
test('dispatches only controlled deployment inputs', async () => {
const sha = 'a'.repeat(40);
let dispatched = null;
const operations = new Map();
const profile = {
id: 'staging', name: 'Staging', environment: 'staging', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { commit_sha: 'b'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
status: async () => ({ head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, error: async () => {} });
const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: profile.id, sha });
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['commit_sha', 'environment', 'request_id']);
assert.equal(dispatched.inputs.commit_sha, sha);
assert.equal(dispatched.inputs.environment, 'staging');
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('requires exact server SHA and matching request ID after a successful workflow', () => {
const operation = { id: 'request-1', repository: 'jens/app', environment: 'staging', sha: 'a'.repeat(40), shortSha: 'aaaaaaa' };
assert.equal(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}), null);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: 'another-request', lastExitCode: 0, healthy: true
}).message, /different deployment request/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: 'b'.repeat(40), requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /server reports/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: false, liveSha: null,
requestId: null, healthy: null
}).message, /not reachable/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'other/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /belongs to other\/app/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 70, healthy: false
}).message, /exit code 70/i);
});
test('server verification reports every incomplete or mismatched evidence field', () => {
const sha = 'a'.repeat(40);
const operation = { id: 'request-1', repository: 'jens/app', environment: 'production', sha, shortSha: sha.slice(0, 7) };
const valid = { statusConfigured: true, statusReachable: true, statusRepository: operation.repository, statusEnvironment: operation.environment, liveSha: sha, requestedSha: sha, requestId: operation.id, lastExitCode: 0, healthy: true };
const cases = [
[{ ...valid, statusConfigured: false }, /is configured/i],
[{ ...valid, statusRepository: '' }, /did not identify its repository/i],
[{ ...valid, statusEnvironment: '' }, /did not identify its environment/i],
[{ ...valid, statusEnvironment: 'staging' }, /belongs to staging/i],
[{ ...valid, liveSha: '' }, /valid full commit SHA/i],
[{ ...valid, requestedSha: '' }, /requested commit SHA/i],
[{ ...valid, requestedSha: 'b'.repeat(40) }, /different requested commit/i],
[{ ...valid, requestId: '' }, /deployment request ID/i],
[{ ...valid, lastExitCode: null }, /exit code unknown/i],
[{ ...valid, healthy: false, healthStatus: 'degraded' }, /degraded/i],
[{ ...valid, healthy: null, error: 'probe failed' }, /probe failed/i]
];
for (const [state, pattern] of cases) assert.match(applicationVerificationFailure(operation, state).message, pattern);
});
test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => {
const liveSha = 'a'.repeat(40);
const previousSha = 'b'.repeat(40);
let dispatched = null;
let verified = null;
const operations = new Map();
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { target_sha: 'c'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
listWorkflowRuns: async () => ({ runs: [] }),
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
verifyCommitOnRemoteBranch: async (...args) => { verified = args; return { valid: true }; }
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha,
previousSha
});
const operation = await service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: previousSha
});
assert.deepEqual(verified, ['/repo', previousSha, 'main']);
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['environment', 'request_id', 'target_sha']);
assert.equal(dispatched.inputs.environment, 'production');
assert.equal(dispatched.inputs.target_sha, previousSha);
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('rollback refuses a stale target that is no longer the server-reported previous SHA', async () => {
const previousSha = 'b'.repeat(40);
let dispatched = false;
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow'
};
const service = new DeploymentService({
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => operation
}, {
dispatchWorkflow: async () => { dispatched = true; }
}, {
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha: 'a'.repeat(40),
previousSha
});
await assert.rejects(
service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: 'c'.repeat(40)
}),
/no longer the previous server version/i
);
assert.equal(dispatched, false);
});