| 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 |