test: cover the deployment and deploy-key paths, and gate coverage per module
deployment-service.cjs sat at 52% and unraid-deploy-key-host.cjs at 39% of its functions, both hidden behind a healthy aggregate. They are now at 100% lines and functions, tested through real HTTP endpoints and by intercepting the shell script the key host sends, rather than by mocking the boundary away. What is pinned down: a successful workflow run still fails when the server cannot prove it runs that exact commit; a rollback ends as rolled-back rather than success; an unreachable status endpoint is never treated as healthy; a failed poll is recorded on the operation instead of losing it; deploy keys stay repository-scoped under the server base path with a pinned host key; promotion verifies the candidate before swapping atomically; and revocation moves key material to recovery instead of deleting it. Two assumptions turned out to be wrong and the tests follow the real behaviour: the previous-SHA check runs before the already-live check, and a rollback against an unreachable endpoint surfaces the underlying network error. Covering clone-target exposed a real defect: a remote ending in "....git" yielded the folder name "...". Windows strips trailing dots, so that resolves back to the project root itself, past an escape guard that only looks for "..". A dots-only name now falls back to "repository", consistent with how an empty name was already handled. As a side effect "." and ".." resolve to a usable folder instead of raising an error. Coverage gates: the aggregate moves to 85/85/68, and a new per-module gate (60 statements, 50 functions, 36 branches) stops a single module from silently collapsing behind the total. It reuses the data from the first run, so the suite is not executed twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9260d35957
commit
a5666e95f2
@@ -26,6 +26,35 @@ test('resolves the automatic clone target inside the configured project root', (
|
||||
assert.equal(plan.directoryName, 'portfolio');
|
||||
});
|
||||
|
||||
test('a clone target that would leave the project root is refused', () => {
|
||||
const root = path.join(os.tmpdir(), 'forgeflow-projects');
|
||||
const resolved = path.resolve(root);
|
||||
|
||||
// The escape guard inside resolveCloneTarget stays as a backstop, but no
|
||||
// sanitised folder name can reach it any more: the name is a single path
|
||||
// segment and a dots-only segment falls back to "repository".
|
||||
for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) {
|
||||
const plan = resolveCloneTarget(root, remote);
|
||||
assert.ok(
|
||||
plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved,
|
||||
`${remote} resolved outside the project root: ${plan.target}`,
|
||||
);
|
||||
}
|
||||
for (const badRoot of ['', ' ', null, undefined]) {
|
||||
assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a folder name that sanitises away still produces a usable directory', () => {
|
||||
// Windows strips trailing dots, so a dots-only name would land on the project
|
||||
// root itself instead of a subdirectory.
|
||||
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository');
|
||||
assert.equal(cloneDirectoryName('..'), 'repository');
|
||||
assert.equal(cloneDirectoryName(''), 'repository');
|
||||
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app');
|
||||
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name');
|
||||
});
|
||||
|
||||
test('clone target inspection accepts missing and empty destinations', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
|
||||
|
||||
const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" };
|
||||
const REPOSITORY = { fullName: "Jens/Portfolio" };
|
||||
|
||||
// The host reaches the server through a single exec call, so capturing the script
|
||||
// it sends is the only way to assert what actually happens to the key material.
|
||||
function keyHost(stdout = "") {
|
||||
const scripts = [];
|
||||
const ssh = {
|
||||
exec: async (serverId, command, options) => {
|
||||
const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || "";
|
||||
scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") });
|
||||
return { stdout };
|
||||
},
|
||||
};
|
||||
return { host: new UnraidDeployKeyHost({ ssh }), scripts };
|
||||
}
|
||||
|
||||
test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => {
|
||||
const { host } = keyHost();
|
||||
const first = host.paths(REPOSITORY, SERVER);
|
||||
const again = host.paths({ fullName: "jens/portfolio" }, SERVER);
|
||||
const other = host.paths({ fullName: "Jens/Other" }, SERVER);
|
||||
|
||||
assert.deepEqual(first, again, "the same repository always resolves to the same directory");
|
||||
assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory");
|
||||
for (const value of Object.values(first)) {
|
||||
assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value);
|
||||
assert.ok(!value.includes(".."));
|
||||
}
|
||||
assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded");
|
||||
});
|
||||
|
||||
test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => {
|
||||
const { host } = keyHost();
|
||||
assert.equal(
|
||||
host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}),
|
||||
"git@gitea.example:Jens/Portfolio.git",
|
||||
"an HTTPS remote is skipped in favour of the SSH URL",
|
||||
);
|
||||
assert.equal(
|
||||
host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }),
|
||||
"ssh://git@gitea.example:2222/Jens/Portfolio.git",
|
||||
);
|
||||
assert.throws(
|
||||
() => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}),
|
||||
(error) => {
|
||||
assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("the Git SSH environment pins the scoped key and refuses an unknown host", () => {
|
||||
const { host } = keyHost();
|
||||
const paths = host.paths(REPOSITORY, SERVER);
|
||||
const environment = host.environment(paths);
|
||||
|
||||
assert.match(environment, /IdentitiesOnly=yes/);
|
||||
assert.match(environment, /BatchMode=yes/);
|
||||
assert.match(environment, /StrictHostKeyChecking=yes/);
|
||||
assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped");
|
||||
assert.ok(environment.includes(paths.privateKey));
|
||||
});
|
||||
|
||||
test("a backup copies the current key material into a fresh recovery slot", async () => {
|
||||
const publicKey = "ssh-ed25519 QkFL forgeflow";
|
||||
const { host, scripts } = keyHost(
|
||||
`__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`,
|
||||
);
|
||||
|
||||
const backup = await host.backup({ repository: REPOSITORY, server: SERVER });
|
||||
assert.equal(backup.publicKey, publicKey);
|
||||
assert.match(backup.recovery, /recovery\/backup-1$/);
|
||||
assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable");
|
||||
assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/);
|
||||
});
|
||||
|
||||
test("candidate verification only reports ready on a real remote commit", async () => {
|
||||
const remoteSha = "d".repeat(40);
|
||||
const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } };
|
||||
const context = {
|
||||
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
||||
profile: { branch: "main" },
|
||||
server: SERVER,
|
||||
candidate,
|
||||
};
|
||||
|
||||
const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`);
|
||||
const proof = await proven.host.verifyCandidate(context);
|
||||
assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" });
|
||||
assert.match(proven.scripts[0].script, /git ls-remote --exit-code/);
|
||||
assert.match(proven.scripts[0].script, /refs\/heads\/main/);
|
||||
assert.equal(proven.scripts[0].options.timeout, 45_000);
|
||||
assert.deepEqual(await proven.host.preflightCandidate(context), proof);
|
||||
|
||||
const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
|
||||
assert.equal((await unproven.host.verifyCandidate(context)).ready, false);
|
||||
await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/);
|
||||
});
|
||||
|
||||
test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => {
|
||||
const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`);
|
||||
const paths = host.paths(REPOSITORY, SERVER);
|
||||
|
||||
const proof = await host.verifyActive({
|
||||
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
|
||||
profile: { branch: "main" },
|
||||
server: SERVER,
|
||||
});
|
||||
assert.equal(proof.ready, true);
|
||||
assert.ok(scripts[0].script.includes(paths.privateKey));
|
||||
assert.ok(scripts[0].script.includes(paths.knownHosts));
|
||||
});
|
||||
|
||||
test("promotion only replaces key material after proving the candidate is complete", async () => {
|
||||
const { host, scripts } = keyHost();
|
||||
const paths = host.paths(REPOSITORY, SERVER);
|
||||
const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } };
|
||||
|
||||
await host.promote({ repository: REPOSITORY, server: SERVER, candidate });
|
||||
const script = scripts[0].script;
|
||||
assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced");
|
||||
assert.ok(script.includes("test -s '/c/known_hosts'"));
|
||||
assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap");
|
||||
// The staging suffix is appended outside the quoted path, so the command reads
|
||||
// mv '<path>'.new '<path>' rather than mv '<path>.new' '<path>'.
|
||||
assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic");
|
||||
assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first");
|
||||
});
|
||||
|
||||
test("rollback restores the recovery slot and removes the candidate", async () => {
|
||||
const { host, scripts } = keyHost();
|
||||
const paths = host.paths(REPOSITORY, SERVER);
|
||||
|
||||
await host.rollback({
|
||||
repository: REPOSITORY,
|
||||
server: SERVER,
|
||||
candidate: { paths: { directory: "/candidate" } },
|
||||
previous: { key: { recovery: "/recovery/backup-1" } },
|
||||
});
|
||||
assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'"));
|
||||
assert.ok(scripts[0].script.includes(paths.directory));
|
||||
assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'"));
|
||||
});
|
||||
|
||||
test("committing a rotation discards only the candidate directory", async () => {
|
||||
const { host, scripts } = keyHost();
|
||||
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
||||
// Every script carries the strict-mode preamble that bash() prepends.
|
||||
assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'");
|
||||
assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit");
|
||||
});
|
||||
|
||||
test("every server script runs under strict mode with Git prompts disabled", async () => {
|
||||
const { host, scripts } = keyHost();
|
||||
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
|
||||
assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/);
|
||||
assert.equal(scripts[0].serverId, SERVER.id);
|
||||
});
|
||||
|
||||
test("revocation moves key material aside so it can still be restored", async () => {
|
||||
const { host, scripts } = keyHost();
|
||||
const paths = host.paths(REPOSITORY, SERVER);
|
||||
|
||||
await host.revoke({ repository: REPOSITORY, server: SERVER });
|
||||
const script = scripts[0].script;
|
||||
assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area");
|
||||
assert.match(script, /mv /, "the key is moved, never deleted");
|
||||
assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path");
|
||||
});
|
||||
|
||||
test("restore reinstates the newest recovery slot and reports the public evidence", async () => {
|
||||
const publicKey = "ssh-ed25519 UkVT forgeflow";
|
||||
const { host, scripts } = keyHost(
|
||||
`__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`,
|
||||
);
|
||||
|
||||
const restored = await host.restore({ repository: REPOSITORY, server: SERVER });
|
||||
assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" });
|
||||
assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically");
|
||||
assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly");
|
||||
});
|
||||
|
||||
test("marker parsing keeps values that themselves contain separators", () => {
|
||||
const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__");
|
||||
assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" });
|
||||
});
|
||||
@@ -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