返回 DeepSeek-Reasonix
notarize-desktop.test.mjs
根目录 / scripts / notarize-desktop.test.mjs
1 import assert from "node:assert/strict";
2 import { spawnSync } from "node:child_process";
3 import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import test from "node:test";
7 import { notarizeDesktop } from "./notarize-desktop.mjs";
8
9 const id = "00000000-0000-4000-8000-000000000001";
10 const env = { APPLE_API_KEY_PATH: "private/key.p8", APPLE_API_KEY_ID: "key-id", APPLE_API_ISSUER_ID: "issuer" };
11 const ok = (value = {}) => ({ status: 0, stdout: JSON.stringify(value) });
12
13 function fixture(t, { kind = "app", status = "Accepted", submit, log, fail } = {}) {
14 const diagnosticsDir = mkdtempSync(join(tmpdir(), "reasonix-notary-test-"));
15 t.after(() => rmSync(diagnosticsDir, { recursive: true, force: true }));
16 const calls = [], warnings = [];
17 const execute = () => notarizeDesktop({
18 archive: kind === "app" ? "upload.zip" : "Reasonix.dmg",
19 target: kind === "app" ? "Reasonix.app" : "Reasonix.dmg",
20 kind, diagnosticsDir, env, warn: (message) => warnings.push(message),
21 run: (command, args) => {
22 calls.push([command, ...args]);
23 if (fail?.(command, args)) return { status: 65, stdout: "" };
24 if (args[0] === "notarytool" && args[1] === "submit") return submit ?? ok({ id, status, path: "private/upload.zip" });
25 if (args[0] === "notarytool" && args[1] === "log") return log ?? ok({ jobId: id, status, issues: [] });
26 return { status: 0, stdout: "" };
27 },
28 });
29 const report = (name) => JSON.parse(readFileSync(join(diagnosticsDir, `${kind}-${name}.json`), "utf8"));
30 return { execute, calls, warnings, report, diagnosticsDir };
31 }
32
33 for (const kind of ["app", "dmg"]) {
34 test(`${kind}: verify before submit, then log, staple, validate and assess`, (t) => {
35 const f = fixture(t, { kind });
36 f.execute();
37 assert.deepEqual(f.calls.map((call) => call.slice(0, 3)), [
38 ["codesign", "--verify", kind === "app" ? "--deep" : "--strict"],
39 ["xcrun", "notarytool", "submit"], ["xcrun", "notarytool", "log"],
40 ["xcrun", "stapler", "staple"], ["xcrun", "stapler", "validate"],
41 ["spctl", "--assess", "--verbose=4"],
42 ]);
43 assert.ok(f.calls[0].includes("--strict"));
44 assert.deepEqual(f.calls[1].slice(-3), ["--wait", "--output-format", "json"]);
45 assert.equal(f.calls[2][3], id);
46 assert.deepEqual(f.calls.at(-1), kind === "app"
47 ? ["spctl", "--assess", "--verbose=4", "--type", "exec", "Reasonix.app"]
48 : ["spctl", "--assess", "--verbose=4", "--type", "open", "--context", "context:primary-signature", "Reasonix.dmg"]);
49 assert.deepEqual(f.report("submission"), { id, status: "Accepted", exitCode: 0, signal: null });
50 assert.equal(f.report("notary-log").jobId, id);
51 for (const name of readdirSync(f.diagnosticsDir)) {
52 const text = readFileSync(join(f.diagnosticsDir, name), "utf8");
53 assert.doesNotMatch(text, /private\/|key-id|issuer/);
54 }
55 });
56 }
57
58 for (const status of ["Invalid", "Rejected", "In Progress", undefined]) {
59 test(`zero exit with ${status} must fetch log and stop before stapling`, (t) => {
60 const f = fixture(t, { submit: ok({ id, status }) });
61 assert.throws(f.execute, /Notarization/);
62 assert.equal(f.report("submission").status, status ?? null);
63 assert.equal(f.report("notary-log").jobId, id);
64 assert.equal(f.calls.length, 3);
65 });
66 }
67
68 test("Apple rejection details are preserved in the log artifact", (t) => {
69 const issues = [{ severity: "error", path: "Reasonix.app/Contents/MacOS/Reasonix", message: "The signature is invalid." }];
70 const f = fixture(t, { status: "Invalid", log: ok({ jobId: id, issues }) });
71 assert.throws(f.execute, /Invalid/);
72 assert.deepEqual(f.report("notary-log").issues, issues);
73 });
74
75 test("a repeated local build cannot retain an older submission's log", (t) => {
76 const f = fixture(t, { status: "Invalid", log: { status: 1, stdout: "" } });
77 writeFileSync(join(f.diagnosticsDir, "app-notary-log.json"), JSON.stringify({ status: "Accepted" }));
78 writeFileSync(join(f.diagnosticsDir, "dmg-notary-log.json"), JSON.stringify({ status: "Accepted" }));
79 assert.throws(f.execute, /Invalid/);
80 assert.deepEqual(readdirSync(f.diagnosticsDir).sort(), ["app-submission.json", "dmg-notary-log.json"]);
81 });
82
83 test("nonzero submit still fetches log and cannot pass with Accepted", (t) => {
84 const f = fixture(t, { submit: { status: 1, stdout: JSON.stringify({ id, status: "Accepted" }) } });
85 assert.throws(f.execute, /Notarization/);
86 assert.equal(f.report("submission").exitCode, 1);
87 assert.equal(f.calls.length, 3);
88 });
89
90 for (const submit of [
91 { status: 1, stdout: "not JSON" }, ok({ status: "Accepted" }),
92 ok({ id: "--unexpected-option", status: "Accepted" }),
93 { status: null, signal: "SIGTERM", stdout: "" },
94 { status: null, error: new Error("spawn failed"), stdout: "" },
95 ]) {
96 test(`unusable submit response fails without fetching an unknown ID: ${JSON.stringify(submit)}`, (t) => {
97 const f = fixture(t, { submit });
98 assert.throws(f.execute, /no submission ID/);
99 assert.equal(f.report("submission").id, null);
100 assert.equal(f.calls.length, 2);
101 });
102 }
103
104 for (const status of ["Accepted", "Invalid"]) {
105 for (const log of [{ status: 1, stdout: "" }, { status: 0, stdout: "not JSON" }]) {
106 test(`unavailable log does not replace the ${status} verdict (${log.status})`, (t) => {
107 const f = fixture(t, { status, log });
108 if (status === "Accepted") f.execute();
109 else assert.throws(f.execute, /Invalid/);
110 assert.equal(f.warnings.length, 1);
111 assert.match(f.warnings[0], new RegExp(id));
112 assert.deepEqual(readdirSync(f.diagnosticsDir), ["app-submission.json"]);
113 });
114 }
115 }
116
117 for (const stage of ["codesign", "staple", "validate", "spctl"]) {
118 test(`${stage} failure stops the pipeline`, (t) => {
119 const f = fixture(t, { fail: (command, args) => command === stage || args[1] === stage });
120 assert.throws(f.execute, /failed/);
121 assert.equal(f.calls.length, { codesign: 1, staple: 4, validate: 5, spctl: 6 }[stage]);
122 });
123 }
124
125 test("both release artifacts use the shared notarization gate and diagnostics survive failure", () => {
126 const build = readFileSync(new URL("./desktop-build.sh", import.meta.url), "utf8");
127 const workflow = readFileSync(new URL("../.github/workflows/release-desktop.yml", import.meta.url), "utf8");
128 assert.match(build, /notarize-desktop\.mjs" "\$staging\/notarize\.zip" "\$app" app "\$notary_diagnostics"/);
129 assert.match(build, /notarize-desktop\.mjs" "\$dmg" "\$dmg" dmg "\$notary_diagnostics"/);
130 assert.doesNotMatch(build, /xcrun (notarytool|stapler)/);
131 const upload = workflow.split("- name: Upload Apple notarization diagnostics")[1]?.split("\n #")[0];
132 assert.ok(upload);
133 assert.match(upload, /always\(\) && runner.os == 'macOS'/);
134 assert.match(upload, /uses: actions\/upload-artifact@v7/);
135 assert.match(upload, /path: \$\{\{ runner.temp \}\}\/apple-notarization\/\*\.json/);
136 assert.match(workflow, /APPLE_NOTARIZATION_LOG_DIR: \$\{\{ runner.temp \}\}\/apple-notarization/);
137 });
138
139 test("historical log workflow only reads Apple submissions from the protected release environment", () => {
140 const workflow = readFileSync(new URL("../.github/workflows/apple-notary-log.yml", import.meta.url), "utf8");
141 assert.match(workflow, /workflow_dispatch:/);
142 assert.match(workflow, /github\.ref == 'refs\/heads\/main-v2' && github\.ref_protected/);
143 assert.match(workflow, /environment: release/);
144 assert.match(workflow, /contents: read/);
145 assert.match(workflow, /SUBMISSION_ID: \$\{\{ inputs.submission_id \}\}/);
146 assert.match(workflow, /\[\[ "\$SUBMISSION_ID" =~ \^\[0-9a-fA-F\]/);
147 assert.match(workflow, /trap 'rm -f "\$key_path"' EXIT/);
148 assert.match(workflow, /umask 077/);
149 assert.match(workflow, /xcrun notarytool log "\$SUBMISSION_ID"/);
150 assert.match(workflow, /if: always\(\)/);
151 assert.match(workflow, /path: \$\{\{ runner.temp \}\}\/apple-notarization\/\*\.json/);
152 assert.doesNotMatch(workflow, /notarytool submit|codesign|stapler|actions\/checkout|contents: write|pull_request_target/);
153 });
154
155 for (const scenario of ["success", "log-failure", "invalid-id"]) {
156 test(`historical workflow shell: ${scenario}, with credential cleanup`, (t) => {
157 const root = mkdtempSync(join(tmpdir(), "reasonix-notary-workflow-"));
158 t.after(() => rmSync(root, { recursive: true, force: true }));
159 const xcrun = join(root, "xcrun");
160 writeFileSync(xcrun, `#!${process.execPath}
161 import fs from 'node:fs';
162 const args = process.argv.slice(2);
163 if (args[0] !== 'notarytool' || !['info', 'log'].includes(args[1])) process.exit(99);
164 const key = args[args.indexOf('--key') + 1];
165 if (fs.readFileSync(key, 'utf8') !== 'fixture-key') process.exit(98);
166 if ((fs.statSync(key).mode & 0o777) !== 0o600) process.exit(97);
167 fs.appendFileSync(process.env.RUNNER_TEMP + '/calls', args[1] + '\\n');
168 if (args[1] === 'info') console.log(JSON.stringify({id: args[2], status: 'Invalid'}));
169 else if (process.env.FAIL_LOG === 'true') process.exit(1);
170 else fs.writeFileSync(args.at(-1), JSON.stringify({jobId: args[2], status: 'Invalid'}));
171 `);
172 chmodSync(xcrun, 0o755);
173 const workflow = readFileSync(new URL("../.github/workflows/apple-notary-log.yml", import.meta.url), "utf8");
174 const script = workflow.split(" run: |\n")[1].split("\n - name:")[0]
175 .split("\n").map((line) => line.replace(/^ /, "")).join("\n");
176 const result = spawnSync("bash", ["-c", script], { encoding: "utf8", env: {
177 ...process.env, PATH: `${root}:${process.env.PATH}`, RUNNER_TEMP: root,
178 SUBMISSION_ID: scenario === "invalid-id" ? "$(touch should-not-exist)" : id,
179 APPLE_API_KEY_P8: Buffer.from("fixture-key").toString("base64"),
180 APPLE_API_KEY_ID: "fixture-id", APPLE_API_ISSUER_ID: "fixture-issuer",
181 FAIL_LOG: String(scenario === "log-failure"),
182 } });
183 assert.equal(result.status, scenario === "success" ? 0 : 1, result.stderr);
184 assert.ok(!readdirSync(root).some((name) => name.startsWith("apple-notary-key.")));
185 assert.doesNotMatch(result.stdout + result.stderr, /fixture-key/);
186 if (scenario === "invalid-id") assert.deepEqual(readdirSync(root), ["xcrun"]);
187 else assert.equal(readFileSync(join(root, "calls"), "utf8"), "info\nlog\n");
188 if (scenario === "success") {
189 assert.equal(JSON.parse(readFileSync(join(root, "apple-notarization/notary-log.json"))).status, "Invalid");
190 }
191 });
192 }
193
193 lines Plain Text