返回 DeepSeek-Reasonix
signpath-checkpoint.test.mjs
根目录 / scripts / signpath-checkpoint.test.mjs
1 import assert from "node:assert/strict";
2 import { mkdtemp, mkdir, readFile, rename, rm, symlink, writeFile } from "node:fs/promises";
3 import os from "node:os";
4 import path from "node:path";
5 import test from "node:test";
6 import { spawnSync } from "node:child_process";
7 import { canReuseRequest, findReceipt, identity, identityDigest, inputDigest, receiptName, requireRecoverable, validateReceipt } from "./signpath-checkpoint.mjs";
8
9 const env = {
10 GITHUB_REPOSITORY: "example/project", GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "1",
11 SIGNPATH_SOURCE_SHA: "a".repeat(40), SIGNPATH_CONTROL_SHA: "b".repeat(40),
12 SIGNPATH_FINGERPRINT: `v1:${"c".repeat(64)}`, SIGNPATH_VERSION: "1.2.3", SIGNPATH_CHANNEL: "stable",
13 SIGNPATH_MODE: "preflight", SIGNPATH_PLATFORM: "windows-amd64",
14 SIGNPATH_ORGANIZATION_ID: "11111111-1111-1111-1111-111111111111",
15 SIGNPATH_PROJECT: "project", SIGNPATH_POLICY: "release-signing", SIGNPATH_CONFIGURATION: "windows-payload",
16 };
17 const expected = identity(env);
18 const requestId = "22222222-2222-2222-2222-222222222222";
19 const receipt = { schema: 1, identityDigest: identityDigest(expected), digest: "digest", attempt: "1", requestId };
20
21 test("an interrupted submission without a saved request cannot be blindly repeated", () => {
22 assert.doesNotThrow(() => requireRecoverable(false, "1"));
23 assert.doesNotThrow(() => requireRecoverable(true, "2"));
24 assert.throws(() => requireRecoverable(false, "2"), /check SignPath request history/);
25 assert.throws(() => requireRecoverable(true, ""), /invalid or excessive workflow attempt/);
26 });
27
28 const payloadStep = "Submit Windows payload for Authenticode signing";
29 const installerStep = "Submit installer for Authenticode signing";
30 const completedStep = (name, conclusion) => ({ name, status: "completed", conclusion });
31 const previousJob = steps => ({ name: "verify stable SignPath control plane / build (windows-amd64, preflight)",
32 status: "completed", conclusion: "failure", steps });
33 function historyAPI(receipts, attempts, calls = []) {
34 return async url => {
35 calls.push(url);
36 const parsed = new URL(url);
37 if (parsed.pathname.endsWith("/artifacts")) {
38 const artifacts = receipts.filter(item => item.name === parsed.searchParams.get("name"));
39 return { ok: true, json: async () => ({ artifacts, total_count: artifacts.length }) };
40 }
41 const attempt = /\/attempts\/(\d+)\/jobs$/.exec(parsed.pathname)?.[1];
42 assert.ok(attempt, url);
43 const jobs = attempts[attempt];
44 if (!jobs) return { ok: false, status: 404 };
45 const page = Number(parsed.searchParams.get("page"));
46 return { ok: true, json: async () => ({ jobs: jobs.slice((page - 1) * 100, page * 100), total_count: jobs.length }) };
47 };
48 }
49
50 test("retry restores the signed payload and first-submits the installer skipped after a download failure", async () => {
51 const installer = identity({ ...env, SIGNPATH_CONFIGURATION: "windows-installer-v2" });
52 const calls = [];
53 const api = historyAPI([{ name: receiptName(expected), expired: false }], {
54 1: [previousJob([completedStep(payloadStep, "success"), completedStep(installerStep, "skipped")])],
55 }, calls);
56 assert.equal(await canReuseRequest(expected, "2", "test-only", api), true);
57 assert.equal(calls.length, 1, "known receipt does not need step history");
58 assert.equal(await canReuseRequest(installer, "2", "test-only", api), false, "allow the first installer submission");
59 assert.ok(calls.some(url => url.includes("/attempts/1/jobs")));
60 });
61
62 test("a pre-signing build or input-upload failure permits the previously skipped submission", async () => {
63 for (const steps of [
64 [completedStep("Build desktop", "failure"), completedStep(payloadStep, "skipped")],
65 [completedStep("Upload unsigned Windows payload for SignPath", "success"), completedStep(payloadStep, "skipped")],
66 ]) assert.equal(await canReuseRequest(expected, "2", "test-only", historyAPI([], { 1: [previousJob(steps)] })), false);
67 });
68
69 test("a missing receipt after any attempted submission remains blocked, even if later attempts skipped it", async () => {
70 for (const conclusion of ["success", "failure", "cancelled", null]) {
71 const api = historyAPI([], {
72 1: [previousJob([completedStep(payloadStep, conclusion)])],
73 2: [previousJob([completedStep(payloadStep, "skipped")])],
74 });
75 await assert.rejects(canReuseRequest(expected, "3", "test-only", api), /check SignPath request history/);
76 }
77 });
78
79 test("all attempt pages are read and unrelated platform/mode jobs cannot authorize resubmission", async () => {
80 const unrelated = Array.from({ length: 100 }, (_, index) => ({ name: `unrelated-${index}` }));
81 const target = previousJob([completedStep(payloadStep, "success")]);
82 const api = historyAPI([], { 1: [...unrelated, target] });
83 await assert.rejects(canReuseRequest(expected, "2", "test-only", api), /check SignPath request history/);
84 const skipped = previousJob([completedStep(payloadStep, "skipped")]);
85 const jobs = [skipped, { ...target, name: "build (windows-arm64, preflight)" }, { ...target, name: "build (windows-amd64, release)" }];
86 assert.equal(await canReuseRequest(expected, "3", "test-only", historyAPI([], { 1: jobs, 2: [] })), false);
87 });
88
89 test("unavailable, nonterminal or ambiguous step evidence fails closed", async () => {
90 const skipped = previousJob([completedStep(payloadStep, "skipped")]);
91 for (const jobs of [
92 [{ ...skipped, status: "in_progress" }], [skipped, skipped],
93 [{ ...skipped, steps: [] }], [{ ...skipped, steps: undefined }],
94 [{ ...skipped, steps: [completedStep(payloadStep, "skipped"), completedStep(payloadStep, "success")] }],
95 ]) await assert.rejects(canReuseRequest(expected, "2", "test-only", historyAPI([], { 1: jobs })));
96 await assert.rejects(canReuseRequest(expected, "2", "test-only", historyAPI([], {})), /HTTP 404/);
97 });
98
99 test("receipt resumes one request across attempts but rejects changed release identity or bytes", () => {
100 assert.equal(validateReceipt(receipt, expected, "digest", "2"), requestId);
101 assert.equal(receiptName(expected), receiptName(identity({ ...env, GITHUB_RUN_ATTEMPT: "2" })));
102 for (const key of Object.keys(expected)) {
103 assert.throws(() => validateReceipt(receipt, { ...expected, [key]: `${expected[key]}changed` }, "digest", "2"), /identity/);
104 }
105 assert.throws(() => validateReceipt(receipt, expected, "changed", "2"), /bytes changed/);
106 for (const change of [{ schema: 2 }, { attempt: "3" }, { attempt: "" }, { requestId: "bad\nrequest_id=unsafe" }]) {
107 assert.throws(() => validateReceipt({ ...receipt, ...change }, expected, "digest", "2"), /invalid/);
108 }
109 });
110
111 test("input digest covers nested filenames and bytes, ignores mtime, rejects symlinks", async t => {
112 const root = await mkdtemp(path.join(os.tmpdir(), "signpath-input-"));
113 t.after(() => rm(root, { recursive: true, force: true }));
114 await assert.rejects(inputDigest(root), /empty/);
115 await mkdir(path.join(root, "app"));
116 const file = path.join(root, "app", "app.exe");
117 await writeFile(file, "original");
118 const digest = await inputDigest(root);
119 await writeFile(file, "original");
120 assert.equal(await inputDigest(root), digest);
121 await writeFile(file, "modified");
122 assert.notEqual(await inputDigest(root), digest);
123 await writeFile(file, "original");
124 await rename(file, path.join(root, "app", "renamed.exe"));
125 assert.notEqual(await inputDigest(root), digest);
126 await symlink("renamed.exe", file);
127 await assert.rejects(inputDigest(root), /symbolic link/);
128 });
129
130 test("receipt lookup is same-run and fails closed on API failures, expiry and ambiguity", async () => {
131 const item = { name: receiptName(expected), expired: false };
132 const fetcher = artifacts => async (url, options) => {
133 assert.match(url, /repos\/example\/project\/actions\/runs\/123\/artifacts\?name=signpath-request-123-/);
134 assert.equal(options.headers.Authorization, "Bearer test-only");
135 return { ok: true, json: async () => ({ artifacts, total_count: artifacts.length }) };
136 };
137 assert.equal(await findReceipt(expected, "test-only", fetcher([])), false);
138 assert.equal(await findReceipt(expected, "test-only", fetcher([item])), true);
139 for (const artifacts of [[item, item], [{ ...item, expired: true }]]) {
140 await assert.rejects(findReceipt(expected, "test-only", fetcher(artifacts)), /ambiguous or expired/);
141 }
142 await assert.rejects(findReceipt(expected, "test-only", async () => ({ ok: false, status: 403 })), /refusing to resubmit/);
143 await assert.rejects(findReceipt(expected, "test-only", async () => ({ ok: true, json: async () => ({ total_count: 101, artifacts: [] }) })), /incomplete/);
144 await assert.rejects(findReceipt(expected, ""), /GH_TOKEN/);
145 });
146
147 test("CLI records and restores the original request without a signing API call", async t => {
148 const root = await mkdtemp(path.join(os.tmpdir(), "signpath-receipt-"));
149 t.after(() => rm(root, { recursive: true, force: true }));
150 const input = path.join(root, "input");
151 const checkpoint = path.join(root, "receipt");
152 const output = path.join(root, "output");
153 await mkdir(input);
154 await writeFile(path.join(input, "app.exe"), "test artifact");
155 const run = (command, extra = {}) => spawnSync(process.execPath,
156 [new URL("./signpath-checkpoint.mjs", import.meta.url).pathname, command, input, checkpoint, env.SIGNPATH_CONFIGURATION],
157 { env: { ...process.env, ...env, GITHUB_OUTPUT: output, SIGNPATH_REQUEST_ID: requestId, ...extra }, encoding: "utf8" });
158 assert.equal(run("record").status, 0);
159 assert.ok(!(await readFile(path.join(checkpoint, "request.json"), "utf8")).includes(env.SIGNPATH_ORGANIZATION_ID));
160 assert.equal(run("restore", { GITHUB_RUN_ATTEMPT: "2" }).status, 0);
161 assert.equal(await readFile(output, "utf8"), `request_id=${requestId}\n`);
162 await writeFile(path.join(input, "app.exe"), "different artifact");
163 const failed = run("restore", { GITHUB_RUN_ATTEMPT: "2" });
164 assert.notEqual(failed.status, 0);
165 assert.match(failed.stderr, /input bytes changed/);
166 assert.equal(await readFile(output, "utf8"), `request_id=${requestId}\n`);
167 assert.notEqual(run("record").status, 0, "cannot overwrite an existing receipt");
168 });
169
170 test("release no longer consumes SignPath quota while legacy receipts remain readable", async () => {
171 const workflow = await readFile(new URL("../.github/workflows/release-desktop.yml", import.meta.url), "utf8");
172 assert.ok(!workflow.includes("signpath/github-action-submit-signing-request"));
173 assert.ok(!workflow.includes("secrets.SIGNPATH_API_TOKEN"));
174 assert.ok(!workflow.includes("node release-control/scripts/signpath-checkpoint.mjs"));
175 assert.match(workflow, /uses: \.\/release-control\/\.github\/actions\/setup-certum/);
176 });
177
177 lines Plain Text