Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -0,0 +1,497 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import deploymentModule from '../src/main/deployment-service.cjs';
|
||||
|
||||
const { DeploymentService } = deploymentModule;
|
||||
|
||||
const SHA = 'a'.repeat(40);
|
||||
const PREVIOUS_SHA = 'b'.repeat(40);
|
||||
|
||||
async function serve(handler) {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return {
|
||||
url: `http://127.0.0.1:${server.address().port}/status`,
|
||||
close: () => new Promise((resolve) => server.close(resolve))
|
||||
};
|
||||
}
|
||||
|
||||
function jsonEndpoint(body, statusCode = 200) {
|
||||
return serve((request, response) => {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
response.end(typeof body === 'string' ? body : JSON.stringify(body));
|
||||
});
|
||||
}
|
||||
|
||||
// A port nothing listens on, so the request fails instead of hanging.
|
||||
async function unreachableUrl() {
|
||||
const closed = await serve(() => {});
|
||||
await closed.close();
|
||||
return closed.url;
|
||||
}
|
||||
|
||||
function makeStore({ profile = null, operations = [] } = {}) {
|
||||
const saved = new Map(operations.map((item) => [item.id, item]));
|
||||
const states = new Map();
|
||||
return {
|
||||
data: { operations, gitea: { baseUrl: 'https://gitea.example' } },
|
||||
getToken: () => 'gitea-secret-token',
|
||||
getDeploymentProfile: () => profile,
|
||||
getOperation: (id) => saved.get(id) || null,
|
||||
addOperation: async (operation) => {
|
||||
saved.set(operation.id, structuredClone(operation));
|
||||
return structuredClone(operation);
|
||||
},
|
||||
saveDeploymentState: async (profileId, state) => {
|
||||
states.set(profileId, state);
|
||||
return state;
|
||||
},
|
||||
saved,
|
||||
states
|
||||
};
|
||||
}
|
||||
|
||||
function makeOperation(overrides = {}) {
|
||||
return {
|
||||
id: 'operation-1',
|
||||
type: 'deployment',
|
||||
action: 'deploy',
|
||||
status: 'queued',
|
||||
repository: 'jens/app',
|
||||
profileId: 'production',
|
||||
environment: 'production',
|
||||
workflowFile: 'deploy.yml',
|
||||
branch: 'main',
|
||||
sha: SHA,
|
||||
shortSha: SHA.slice(0, 7),
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
stages: new DeploymentService({}, {}, {}).makeStages(),
|
||||
logs: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function successPayload(overrides = {}) {
|
||||
return {
|
||||
repository: 'jens/app',
|
||||
environment: 'production',
|
||||
commit_sha: SHA,
|
||||
previous_sha: PREVIOUS_SHA,
|
||||
requested_sha: SHA,
|
||||
request_id: 'operation-1',
|
||||
last_exit_code: 0,
|
||||
health: 'healthy',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('the status endpoint reader accepts both key spellings and refuses anything that is not a commit SHA', async (context) => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
assert.deepEqual(await service.readStatusEndpoint(''), { configured: false });
|
||||
|
||||
const snake = await jsonEndpoint(successPayload());
|
||||
context.after(() => snake.close());
|
||||
const snakeResult = await service.readStatusEndpoint(snake.url);
|
||||
assert.equal(snakeResult.ok, true);
|
||||
assert.equal(snakeResult.liveSha, SHA);
|
||||
assert.equal(snakeResult.previousSha, PREVIOUS_SHA);
|
||||
assert.equal(snakeResult.requestedSha, SHA);
|
||||
assert.equal(snakeResult.requestId, 'operation-1');
|
||||
assert.equal(snakeResult.lastExitCode, 0);
|
||||
|
||||
const camel = await jsonEndpoint({
|
||||
repository: 'jens/app',
|
||||
environment: 'PRODUCTION',
|
||||
commitSha: SHA.toUpperCase(),
|
||||
previousSha: PREVIOUS_SHA,
|
||||
requestedSha: SHA,
|
||||
requestId: 'operation-1',
|
||||
lastExitCode: 3
|
||||
});
|
||||
context.after(() => camel.close());
|
||||
const camelResult = await service.readStatusEndpoint(camel.url);
|
||||
assert.equal(camelResult.liveSha, SHA, 'a SHA is normalised to lower case');
|
||||
assert.equal(camelResult.environment, 'production', 'the environment is compared case-insensitively');
|
||||
assert.equal(camelResult.lastExitCode, 3);
|
||||
|
||||
const untrusted = await jsonEndpoint({ commit_sha: 'HEAD', previous_sha: 'v1.2.3', request_id: 42, requested_sha: 'not-a-sha' });
|
||||
context.after(() => untrusted.close());
|
||||
const untrustedResult = await service.readStatusEndpoint(untrusted.url);
|
||||
assert.equal(untrustedResult.liveSha, null);
|
||||
assert.equal(untrustedResult.previousSha, null);
|
||||
assert.equal(untrustedResult.requestedSha, null);
|
||||
assert.equal(untrustedResult.requestId, null, 'a non-string request id is not accepted');
|
||||
});
|
||||
|
||||
test('an unreachable or failing status endpoint is reported instead of assumed healthy', async (context) => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
|
||||
const failing = await jsonEndpoint({ error: 'boom' }, 503);
|
||||
context.after(() => failing.close());
|
||||
const failed = await service.readStatusEndpoint(failing.url);
|
||||
assert.deepEqual(
|
||||
{ configured: failed.configured, reachable: failed.reachable, ok: failed.ok, status: failed.status },
|
||||
{ configured: true, reachable: true, ok: false, status: 503 }
|
||||
);
|
||||
|
||||
const offline = await service.readStatusEndpoint(await unreachableUrl());
|
||||
assert.equal(offline.reachable, false);
|
||||
assert.equal(offline.ok, false);
|
||||
assert.ok(offline.error);
|
||||
});
|
||||
|
||||
test('healthchecks distinguish unconfigured, healthy, rejected and unreachable', async (context) => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
assert.deepEqual(await service.checkHealth(''), { configured: false, healthy: null });
|
||||
|
||||
const healthy = await jsonEndpoint({ ok: true });
|
||||
context.after(() => healthy.close());
|
||||
const healthyResult = await service.checkHealth(healthy.url);
|
||||
assert.equal(healthyResult.healthy, true);
|
||||
assert.equal(healthyResult.status, 200);
|
||||
|
||||
const rejected = await jsonEndpoint({ ok: false }, 500);
|
||||
context.after(() => rejected.close());
|
||||
assert.equal((await service.checkHealth(rejected.url)).healthy, false);
|
||||
|
||||
const offline = await service.checkHealth(await unreachableUrl());
|
||||
assert.equal(offline.healthy, false);
|
||||
assert.ok(offline.error);
|
||||
});
|
||||
|
||||
test('profile state derives health from the status document when no healthcheck is configured', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload({ health: 'degraded', deployed_at: '2026-08-01T10:00:00.000Z' }));
|
||||
context.after(() => endpoint.close());
|
||||
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||
const store = makeStore({ profile });
|
||||
const service = new DeploymentService(store, {}, {});
|
||||
|
||||
const state = await service.refreshProfileState('jens/app', 'production', { expectedSha: SHA });
|
||||
assert.equal(state.healthConfigured, false);
|
||||
assert.equal(state.healthy, false, 'a degraded status document is not treated as healthy');
|
||||
assert.equal(state.liveSha, SHA);
|
||||
assert.equal(state.versionMatches, true);
|
||||
assert.equal(state.deployedAt, '2026-08-01T10:00:00.000Z');
|
||||
assert.equal(store.states.get('production').liveSha, SHA, 'the state is persisted');
|
||||
});
|
||||
|
||||
test('an unknown health word leaves the health state undecided rather than guessing', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload({ health: 'starting' }));
|
||||
context.after(() => endpoint.close());
|
||||
const store = makeStore({ profile: { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' } });
|
||||
const state = await new DeploymentService(store, {}, {}).refreshProfileState('jens/app', 'production');
|
||||
assert.equal(state.healthy, null);
|
||||
assert.equal(state.versionMatches, null, 'without an expected SHA there is nothing to compare');
|
||||
});
|
||||
|
||||
test('refreshing the state of a removed profile fails loudly', async () => {
|
||||
const service = new DeploymentService(makeStore({ profile: null }), {}, {});
|
||||
await assert.rejects(() => service.refreshProfileState('jens/app', 'gone'), /Deployment profile not found/);
|
||||
});
|
||||
|
||||
test('a terminal operation is never polled again', async () => {
|
||||
const operation = makeOperation({ status: 'success' });
|
||||
const store = makeStore({ operations: [operation] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => assert.fail('a finished deployment must not be polled'),
|
||||
listWorkflowJobs: async () => assert.fail('a finished deployment must not be polled')
|
||||
}, {});
|
||||
|
||||
assert.equal((await service.refreshOperation('operation-1')).status, 'success');
|
||||
});
|
||||
|
||||
test('an unknown operation is reported instead of silently ignored', async () => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
await assert.rejects(() => service.refreshOperation('missing'), /Deployment operation not found/);
|
||||
});
|
||||
|
||||
test('a workflow run that is not visible yet keeps the deployment queued', async () => {
|
||||
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => ({ run: null, source: 'actions' })
|
||||
}, {});
|
||||
|
||||
const refreshed = await service.refreshOperation('operation-1');
|
||||
assert.equal(refreshed.status, 'queued');
|
||||
assert.equal(refreshed.stages.find((stage) => stage.id === 'queued').status, 'active');
|
||||
assert.match(refreshed.logs.at(-1), /queued or not visible/);
|
||||
});
|
||||
|
||||
test('a failed runner marks the deployment failed and skips verification', async () => {
|
||||
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => ({ run: { id: 7, runNumber: 7, status: 'completed', conclusion: 'failure', htmlUrl: 'https://gitea.example/run/7' }, source: 'actions' }),
|
||||
listWorkflowJobs: async () => [{ name: 'build', status: 'completed', conclusion: 'failure' }]
|
||||
}, {});
|
||||
|
||||
const refreshed = await service.refreshOperation('operation-1');
|
||||
assert.equal(refreshed.status, 'failed');
|
||||
assert.equal(refreshed.failure.stage, 'runner');
|
||||
assert.equal(refreshed.stages.find((stage) => stage.id === 'healthcheck').status, 'skipped');
|
||||
assert.equal(refreshed.runUrl, 'https://gitea.example/run/7');
|
||||
});
|
||||
|
||||
test('a successful runner still fails when the server does not prove it runs the exact commit', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload({ commit_sha: 'c'.repeat(40) }));
|
||||
context.after(() => endpoint.close());
|
||||
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||
const store = makeStore({ profile, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => ({ run: { id: 8, runNumber: 8, status: 'completed', conclusion: 'success' }, source: 'actions' }),
|
||||
listWorkflowJobs: async () => []
|
||||
}, {});
|
||||
|
||||
const refreshed = await service.refreshOperation('operation-1');
|
||||
assert.equal(refreshed.status, 'failed');
|
||||
assert.equal(refreshed.failure.stage, 'version-verification');
|
||||
assert.match(refreshed.failure.message, /instead of/);
|
||||
assert.equal(refreshed.stages.find((stage) => stage.id === 'complete').status, 'failed');
|
||||
});
|
||||
|
||||
test('a verified deployment completes, and the same evidence marks a rollback as rolled back', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload());
|
||||
context.after(() => endpoint.close());
|
||||
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
|
||||
const gitea = {
|
||||
findWorkflowRun: async () => ({ run: { id: 9, runNumber: 9, status: 'completed', conclusion: 'success' }, source: 'actions' }),
|
||||
listWorkflowJobs: async () => [{ name: 'deploy', status: 'completed', conclusion: 'success' }]
|
||||
};
|
||||
|
||||
const deployStore = makeStore({ profile, operations: [makeOperation()] });
|
||||
const deployed = await new DeploymentService(deployStore, gitea, {}).refreshOperation('operation-1');
|
||||
assert.equal(deployed.status, 'success');
|
||||
assert.equal(deployed.stages.find((stage) => stage.id === 'complete').status, 'complete');
|
||||
assert.equal(deployed.applicationState.liveSha, SHA);
|
||||
assert.ok(deployed.logs.some((line) => line.includes('[job] deploy: success')));
|
||||
|
||||
const rollbackStore = makeStore({ profile, operations: [makeOperation({ action: 'rollback' })] });
|
||||
const rolledBack = await new DeploymentService(rollbackStore, gitea, {}).refreshOperation('operation-1');
|
||||
assert.equal(rolledBack.status, 'rolled-back');
|
||||
});
|
||||
|
||||
test('unavailable job details degrade to a warning instead of failing the refresh', async () => {
|
||||
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => ({ run: { id: 10, runNumber: 10, status: 'in_progress', conclusion: null }, source: 'actions' }),
|
||||
listWorkflowJobs: async () => { throw new Error('jobs API disabled'); }
|
||||
}, {});
|
||||
|
||||
const refreshed = await service.refreshOperation('operation-1');
|
||||
assert.equal(refreshed.status, 'running');
|
||||
assert.equal(refreshed.stages.find((stage) => stage.id === 'runner').status, 'active');
|
||||
assert.ok(refreshed.logs.some((line) => line.includes('Job details unavailable: jobs API disabled')));
|
||||
});
|
||||
|
||||
test('a failing poll is recorded on the operation without losing it', async () => {
|
||||
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => { throw new Error('Gitea unreachable'); }
|
||||
}, {});
|
||||
|
||||
const refreshed = await service.refreshOperation('operation-1');
|
||||
assert.equal(refreshed.pollError, 'Gitea unreachable');
|
||||
assert.equal(refreshed.status, 'queued', 'the operation keeps its last known state');
|
||||
assert.ok(refreshed.logs.some((line) => line.includes('Status refresh failed')));
|
||||
});
|
||||
|
||||
test('a deployment whose profile was deleted reports that instead of crashing the poll', async () => {
|
||||
const store = makeStore({ profile: null, operations: [makeOperation()] });
|
||||
const refreshed = await new DeploymentService(store, {}, {}).refreshOperation('operation-1');
|
||||
assert.match(refreshed.pollError, /profile used by this operation no longer exists/);
|
||||
});
|
||||
|
||||
test('a refresh already in flight is not started a second time', async () => {
|
||||
let calls = 0;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
|
||||
const service = new DeploymentService(store, {
|
||||
findWorkflowRun: async () => { calls += 1; await gate; return { run: null, source: 'actions' }; }
|
||||
}, {});
|
||||
|
||||
const first = service.refreshOperation('operation-1');
|
||||
const second = await service.refreshOperation('operation-1');
|
||||
assert.equal(second.status, 'queued');
|
||||
release();
|
||||
await first;
|
||||
assert.equal(calls, 1, 'the second caller reuses the in-flight refresh');
|
||||
});
|
||||
|
||||
test('job states drive the runner stage', () => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
const stageOf = (jobs) => {
|
||||
const operation = makeOperation();
|
||||
service.mapJobsToStages(operation, jobs);
|
||||
return operation.stages.find((stage) => stage.id === 'runner').status;
|
||||
};
|
||||
|
||||
assert.equal(stageOf([{ status: 'in_progress' }]), 'active');
|
||||
assert.equal(stageOf([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'failed');
|
||||
assert.equal(stageOf([{ conclusion: 'success' }]), 'complete');
|
||||
assert.equal(stageOf([{ status: 'waiting' }]), 'pending');
|
||||
|
||||
const untouched = makeOperation();
|
||||
service.mapJobsToStages(untouched, []);
|
||||
assert.equal(untouched.stages.find((stage) => stage.id === 'queued').status, 'active', 'no jobs leaves the stages alone');
|
||||
});
|
||||
|
||||
test('a rejected dispatch records the failure on the operation and still surfaces the error', async () => {
|
||||
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 store = makeStore({ profile });
|
||||
const service = new DeploymentService(store, {
|
||||
listWorkflowRuns: async () => ({ runs: [{ id: 1 }, { id: 2 }] }),
|
||||
dispatchWorkflow: async () => { throw new Error('workflow file not found'); }
|
||||
}, {
|
||||
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 () => {} });
|
||||
|
||||
await assert.rejects(
|
||||
() => service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }),
|
||||
/workflow file not found/
|
||||
);
|
||||
|
||||
const stored = [...store.saved.values()].at(-1);
|
||||
assert.equal(stored.status, 'failed');
|
||||
assert.equal(stored.failure.stage, 'dispatch');
|
||||
assert.deepEqual(stored.baselineRunIds, ['1', '2'], 'runs that existed before dispatch are never mistaken for this one');
|
||||
assert.equal(stored.stages.find((stage) => stage.id === 'queued').status, 'failed');
|
||||
});
|
||||
|
||||
test('a rejected rollback dispatch is recorded the same way as a rejected deployment', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload());
|
||||
context.after(() => endpoint.close());
|
||||
const profile = {
|
||||
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
|
||||
};
|
||||
const store = makeStore({ profile });
|
||||
const service = new DeploymentService(store, {
|
||||
listWorkflowRuns: async () => ({ runs: [] }),
|
||||
dispatchWorkflow: async () => { throw new Error('rollback workflow is disabled'); }
|
||||
}, {
|
||||
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||
}, { info: async () => {}, error: async () => {} });
|
||||
|
||||
await assert.rejects(
|
||||
() => service.rollback({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', targetSha: PREVIOUS_SHA }),
|
||||
/rollback workflow is disabled/
|
||||
);
|
||||
|
||||
const stored = [...store.saved.values()].at(-1);
|
||||
assert.equal(stored.action, 'rollback');
|
||||
assert.equal(stored.status, 'failed');
|
||||
assert.equal(stored.failure.stage, 'dispatch');
|
||||
assert.equal(stored.workflowFile, 'rollback.yml');
|
||||
});
|
||||
|
||||
test('rollback refuses every state where the target is not the server-reported previous version', async (context) => {
|
||||
const endpoint = await jsonEndpoint(successPayload());
|
||||
context.after(() => endpoint.close());
|
||||
const base = {
|
||||
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
|
||||
};
|
||||
const git = { verifyCommitOnRemoteBranch: async () => ({ valid: true }) };
|
||||
const repository = { fullName: 'jens/app', localPath: '/repo' };
|
||||
const rollback = (profile, targetSha) => new DeploymentService(makeStore({ profile }), {}, git)
|
||||
.rollback({ repository, profileId: 'production', targetSha });
|
||||
|
||||
await assert.rejects(() => rollback({ ...base, rollbackWorkflowFile: '' }, PREVIOUS_SHA), /No rollback workflow is configured/);
|
||||
await assert.rejects(() => rollback(base, 'c'.repeat(40)), /no longer the previous server version/);
|
||||
await assert.rejects(() => rollback(base, SHA), /no longer the previous server version/, 'the live commit is not the previous one either');
|
||||
|
||||
// The "already live" guard only remains reachable when the server reports the
|
||||
// same commit as both its live and its previous version.
|
||||
const stuck = await jsonEndpoint(successPayload({ previous_sha: SHA }));
|
||||
context.after(() => stuck.close());
|
||||
await assert.rejects(() => rollback({ ...base, statusUrl: stuck.url }, SHA), /already live/);
|
||||
|
||||
const noPrevious = await jsonEndpoint(successPayload({ previous_sha: null }));
|
||||
context.after(() => noPrevious.close());
|
||||
await assert.rejects(() => rollback({ ...base, statusUrl: noPrevious.url }, PREVIOUS_SHA), /does not report a previous version/);
|
||||
|
||||
const otherEnvironment = await jsonEndpoint(successPayload({ environment: 'staging' }));
|
||||
context.after(() => otherEnvironment.close());
|
||||
await assert.rejects(() => rollback({ ...base, statusUrl: otherEnvironment.url }, PREVIOUS_SHA), /does not match this repository and environment/);
|
||||
|
||||
// An unreachable endpoint surfaces the underlying network error rather than a
|
||||
// generic message, so the reason a rollback was refused stays diagnosable.
|
||||
const unreachable = { ...base, statusUrl: await unreachableUrl() };
|
||||
await assert.rejects(() => rollback(unreachable, PREVIOUS_SHA), /fetch failed|ECONNREFUSED|must be reachable/i);
|
||||
});
|
||||
|
||||
test('an unavailable run baseline degrades to a warning rather than blocking the dispatch', async () => {
|
||||
const profile = {
|
||||
id: 'production', name: 'Production', environment: 'production', branch: 'main',
|
||||
workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/.well-known/forgeflow'
|
||||
};
|
||||
const store = makeStore({ profile });
|
||||
const service = new DeploymentService(store, {
|
||||
listWorkflowRuns: async () => { throw new Error('Actions API disabled'); },
|
||||
dispatchWorkflow: async () => ({ accepted: true })
|
||||
}, {
|
||||
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: 'production', sha: SHA });
|
||||
assert.equal(operation.status, 'queued');
|
||||
assert.deepEqual(operation.baselineRunIds, []);
|
||||
assert.ok(operation.logs.some((line) => line.includes('Could not capture the pre-dispatch run baseline')));
|
||||
});
|
||||
|
||||
test('deployment logs never repeat a line and never carry the Gitea token', () => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
const operation = makeOperation({ logs: undefined });
|
||||
|
||||
service.appendLog(operation, 'plain line');
|
||||
service.appendLog(operation, 'plain line');
|
||||
service.appendLog(operation, 'authorization: token gitea-secret-token');
|
||||
assert.equal(operation.logs.length, 2, 'a repeated line is not appended twice');
|
||||
assert.ok(!operation.logs.at(-1).includes('gitea-secret-token'));
|
||||
|
||||
for (let index = 0; index < 1200; index += 1) service.appendLog(operation, `line ${index}`);
|
||||
assert.equal(operation.logs.length, 1000, 'the log is bounded');
|
||||
assert.equal(operation.logs.at(-1), 'line 1199');
|
||||
});
|
||||
|
||||
test('a repository identity that is not exactly owner/repo is refused', () => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
assert.deepEqual(service.splitRepository('jens/app'), { owner: 'jens', repo: 'app' });
|
||||
for (const value of ['', 'app', 'jens/app/extra', '/app', 'jens/']) {
|
||||
assert.throws(() => service.splitRepository(value), /Invalid Gitea repository identity/);
|
||||
}
|
||||
});
|
||||
|
||||
test('deployment is refused without a linked local repository', async () => {
|
||||
const service = new DeploymentService(makeStore(), {}, {});
|
||||
await assert.rejects(() => service.deploy({ repository: { fullName: 'jens/app' }, profileId: 'production', sha: SHA }), /linked local repository/);
|
||||
await assert.rejects(() => service.rollback({ repository: { localPath: '/repo' }, profileId: 'production', targetSha: SHA }), /linked local repository/);
|
||||
});
|
||||
|
||||
test('validation refuses every local state that would deploy something other than the reviewed commit', async () => {
|
||||
const profile = { id: 'production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/status' };
|
||||
const base = { head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } };
|
||||
const cases = [
|
||||
[{ ...base, head: 'c'.repeat(40) }, /no longer matches the local repository/],
|
||||
[{ ...base, branch: { ...base.branch, head: 'feature' } }, /only allows deployments from main/],
|
||||
[{ ...base, counts: { changed: 2 } }, /Commit local changes/],
|
||||
[{ ...base, branch: { ...base.branch, ahead: 1 } }, /Push all local commits/],
|
||||
[{ ...base, branch: { ...base.branch, behind: 1 } }, /Synchronize with Gitea/],
|
||||
[{ ...base, branch: { ...base.branch, upstream: '' } }, /Publish this branch/]
|
||||
];
|
||||
|
||||
for (const [status, expected] of cases) {
|
||||
const service = new DeploymentService(makeStore({ profile }), {}, {
|
||||
status: async () => status,
|
||||
verifyCommitOnRemoteBranch: async () => ({ valid: true })
|
||||
});
|
||||
await assert.rejects(() => service.validateDeploy({ localPath: '/repo' }, profile, SHA), expected);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user