feat: add transactional deploy key lifecycle
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DeployKeyLifecycleService } = require("../src/main/deploy-key-lifecycle-service.cjs");
|
||||
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
|
||||
|
||||
const repository = { fullName: "Jens/Portfolio" };
|
||||
const baseProfile = { id: "production", serverId: "unraid", deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: 7, keyFingerprint: "SHA256:old", hostFingerprint: "SHA256:host" } };
|
||||
|
||||
function fixture(overrides = {}) {
|
||||
let profile = structuredClone(baseProfile);
|
||||
const events = [];
|
||||
const remoteKeys = overrides.remoteKeys || [{ id: 7, title: "ForgeFlow old", read_only: true, key: "ssh-ed25519 T0xE old" }];
|
||||
const store = {
|
||||
getDeploymentProfile: () => profile,
|
||||
getServer: () => ({ id: "unraid", name: "Unraid" }),
|
||||
getRepositories: () => [{ fullName: repository.fullName }],
|
||||
getDeploymentProfiles: () => [profile, ...(overrides.otherProfiles || [])],
|
||||
saveDeploymentProfile: async (_name, value) => { if (overrides.saveFails) throw new Error("switch failed"); profile = structuredClone(value); events.push("profile-saved"); return profile; },
|
||||
createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }),
|
||||
};
|
||||
const gitea = {
|
||||
listDeployKeys: async () => structuredClone(remoteKeys),
|
||||
createReadOnlyDeployKey: async () => { if (overrides.registrationFails) throw new Error("registration failed"); return { id: 8, title: "new", read_only: overrides.writable !== true, key: "ssh-ed25519 TkVX new" }; },
|
||||
deleteDeployKey: async (_owner, _repo, id) => { events.push(`delete:${id}`); if (overrides.deleteOldFails && id === 7) throw new Error("old revoke failed"); return { deleted: true }; },
|
||||
};
|
||||
const keyHost = {
|
||||
inspect: async () => overrides.inspect || ({ privateKeyPresent: true, publicKey: "ssh-ed25519 T0xE old", fingerprint: overrides.changedFingerprint ? "SHA256:changed" : "SHA256:old", hostFingerprint: "SHA256:host" }),
|
||||
backup: async () => ({ recovery: "server-backup", publicKey: "ssh-ed25519 T0xE old" }),
|
||||
generate: async () => ({ publicKey: "ssh-ed25519 TkVX new", fingerprint: "SHA256:new" }),
|
||||
verifyCandidate: async () => overrides.verifyFails ? ({ ready: false, fingerprint: "SHA256:new" }) : ({ ready: true, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host", remoteSha: "a".repeat(40) }),
|
||||
preflightCandidate: async () => { if (overrides.preflightFails) throw new Error("preflight failed"); events.push("preflight"); },
|
||||
promote: async () => { events.push("promote"); },
|
||||
verifyActive: async () => overrides.postFails ? ({ ready: false }) : ({ ready: true, fingerprint: "SHA256:new" }),
|
||||
commit: async () => { events.push("commit"); },
|
||||
rollback: async () => { events.push("rollback"); },
|
||||
revoke: async () => { events.push("revoke-server"); if (overrides.revokeFails) throw new Error("server revoke failed"); },
|
||||
restore: async () => ({ publicKey: "ssh-ed25519 UkVTVE9SRQ restored", fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }),
|
||||
};
|
||||
const audit = { append: async (name) => events.push(name) };
|
||||
const service = new DeployKeyLifecycleService({ store, gitea, keyHost, audit, clock: () => "2026-07-29T00:00:00.000Z" });
|
||||
return { service, events, getProfile: () => profile };
|
||||
}
|
||||
|
||||
test("deploy-key rotation verifies, switches, revokes and post-verifies in order", async () => {
|
||||
const { service, events, getProfile } = fixture();
|
||||
const plan = await service.planRotation({ repository, profileId: "production" });
|
||||
const result = await service.rotate({ repository, profileId: "production", expectedPlanId: plan.id });
|
||||
assert.equal(result.profile.serverGitAccess.deployKeyId, 8);
|
||||
assert.equal(getProfile().serverGitAccess.keyFingerprint, "SHA256:new");
|
||||
assert.deepEqual(events.filter((event) => ["preflight", "promote", "profile-saved", "delete:7", "commit"].includes(event)), ["preflight", "promote", "profile-saved", "delete:7", "commit"]);
|
||||
});
|
||||
|
||||
for (const [name, overrides, message] of [
|
||||
["registration failure", { registrationFails: true }, /registration failed/],
|
||||
["writable candidate", { writable: true }, /write access/],
|
||||
["candidate verification failure", { verifyFails: true }, /could not prove/],
|
||||
["candidate preflight failure", { preflightFails: true }, /preflight failed/],
|
||||
["atomic profile switch failure", { saveFails: true }, /switch failed/],
|
||||
["old key revocation failure", { deleteOldFails: true }, /old revoke failed/],
|
||||
["post-rotation failure", { postFails: true }, /Post-rotation verification failed/],
|
||||
]) test(`deploy-key rotation rolls back on ${name}`, async () => {
|
||||
const { service, events } = fixture(overrides);
|
||||
const plan = await service.planRotation({ repository, profileId: "production" });
|
||||
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }), message);
|
||||
assert.ok(events.includes("rollback"));
|
||||
});
|
||||
|
||||
test("rotation rejects a stale content-addressed plan", async () => {
|
||||
const { service } = fixture();
|
||||
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: "stale" }), (error) => error.code === "DEPLOY_KEY_ROTATION_PLAN_STALE");
|
||||
});
|
||||
|
||||
test("inventory detects stale, orphaned, shared, conflicting and changed-fingerprint keys", async () => {
|
||||
const { service } = fixture({
|
||||
changedFingerprint: true,
|
||||
remoteKeys: [{ id: 9, title: "ForgeFlow orphan", read_only: true, key: "ssh-ed25519 T1JQSEFO orphan" }, { id: 10, title: "writable", read_only: false, key: "ssh-ed25519 T0xE old" }],
|
||||
otherProfiles: [{ id: "staging", serverId: "unraid", serverGitAccess: { deployKeyId: 11, keyFingerprint: "SHA256:changed" } }],
|
||||
});
|
||||
const report = await service.inventory({ repository, profileId: "production" });
|
||||
assert.equal(report.stale, true);
|
||||
assert.equal(report.orphaned.length, 1);
|
||||
assert.equal(report.shared.length, 1);
|
||||
assert.equal(report.conflicts.length, 1);
|
||||
assert.equal(report.ready, false);
|
||||
});
|
||||
|
||||
test("revocation requires reviewed impact, disables pull and preserves recovery", async () => {
|
||||
const { service, events, getProfile } = fixture();
|
||||
const plan = await service.planRevocation({ repository, profileId: "production" });
|
||||
assert.equal(plan.containersUnaffected, true);
|
||||
await assert.rejects(service.revoke({ repository, profileId: "production" }), (error) => error.code === "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED");
|
||||
const result = await service.revoke({ repository, profileId: "production", expectedPlanId: plan.id });
|
||||
assert.equal(result.recovery, "server-backup");
|
||||
assert.equal(getProfile().deploymentMode, "monitor-only");
|
||||
assert.ok(events.includes("delete:7"));
|
||||
assert.ok(events.includes("revoke-server"));
|
||||
});
|
||||
|
||||
test("revoked access can be restored and verified", async () => {
|
||||
const { service } = fixture();
|
||||
const result = await service.restore({ repository, profileId: "production" });
|
||||
assert.equal(result.profile.deploymentMode, "server-git");
|
||||
assert.equal(result.profile.serverGitAccess.keyFingerprint, "SHA256:restored");
|
||||
assert.equal(result.proof.ready, true);
|
||||
});
|
||||
|
||||
test("failed server revocation restores repository access", async () => {
|
||||
const { service, events, getProfile } = fixture({ revokeFails: true });
|
||||
const plan = await service.planRevocation({ repository, profileId: "production" });
|
||||
await assert.rejects(service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }), /server revoke failed/);
|
||||
assert.equal(getProfile().deploymentMode, "server-git");
|
||||
assert.ok(events.includes("delete:7"));
|
||||
});
|
||||
|
||||
test("Unraid key host parser rejects unverifiable output", () => {
|
||||
assert.throws(() => parseDeployKeyMarker("ordinary ssh output", "__FORGEFLOW_KEY__"), /did not return/);
|
||||
});
|
||||
|
||||
test("Unraid candidate generation returns public evidence and paths but never private key content", async () => {
|
||||
const publicKey = "ssh-ed25519 TkVX forgeflow";
|
||||
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_CANDIDATE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n` }) };
|
||||
const host = new UnraidDeployKeyHost({ ssh });
|
||||
const candidate = await host.generate({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
|
||||
assert.equal(candidate.publicKey, publicKey);
|
||||
assert.equal(candidate.fingerprint, "SHA256:new");
|
||||
assert.equal(candidate.privateKey, undefined);
|
||||
assert.match(candidate.paths.privateKey, /candidate-[0-9a-f-]+\/deploy-key$/);
|
||||
});
|
||||
|
||||
test("Unraid active key inspection exposes only public metadata", async () => {
|
||||
const publicKey = "ssh-ed25519 T0xE forgeflow";
|
||||
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_INSPECT__\nprivateKeyPresent=true\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:old\nhostFingerprint=SHA256:host\n` }) };
|
||||
const host = new UnraidDeployKeyHost({ ssh });
|
||||
const evidence = await host.inspect({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
|
||||
assert.deepEqual(evidence, { privateKeyPresent: true, publicKey, fingerprint: "SHA256:old", hostFingerprint: "SHA256:host" });
|
||||
});
|
||||
Reference in New Issue
Block a user