155 lines
7.3 KiB
JavaScript
155 lines
7.3 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('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);
|
|
});
|
|
|