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