1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/process";
11
12import { awaitCliNotarization, signMacArchiveContents } from "./build-cli-archive.ts";
13
14const signedEnv = {
15 T3CODE_CLI_MAC_SIGN_IDENTITY: "Developer ID Application: Test (TEAM)",
16 APPLE_API_KEY: "/tmp/AuthKey_TEST.p8",
17 APPLE_API_KEY_ID: "TEST",
18 APPLE_API_ISSUER: "issuer",
19};
20
21type NotaryReply = { readonly exitCode: number; readonly stdout?: string };
22
23/** Stands in for `xcrun notarytool`, codesign and ditto; records each invocation. */
24function stubSpawner(notary: (subcommand: string) => NotaryReply) {
25 const calls: Array<ReadonlyArray<string>> = [];
26 const spawner = ChildProcessSpawner.make((command) => {
27 const argv = ChildProcess.isStandardCommand(command)
28 ? [command.command, ...command.args.map(String)]
29 : [];
30 calls.push(argv);
31 const reply = argv[1] === "notarytool" ? notary(argv[2] ?? "") : { exitCode: 0 };
32 return Effect.succeed(
33 ChildProcessSpawner.makeHandle({
34 pid: ChildProcessSpawner.ProcessId(1),
35 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(reply.exitCode)),
36 isRunning: Effect.succeed(false),
37 kill: () => Effect.void,
38 unref: Effect.succeed(Effect.void),
39 stdin: Sink.drain,
40 stdout: reply.stdout ? Stream.make(new TextEncoder().encode(reply.stdout)) : Stream.empty,
41 stderr: Stream.empty,
42 all: Stream.empty,
43 getInputFd: () => Sink.drain,
44 getOutputFd: () => Stream.empty,
45 }),
46 );
47 });
48 return { calls, spawner };
49}
50
51const withStubs = <A, E, R>(
52 effect: Effect.Effect<A, E, R>,
53 stub: ReturnType<typeof stubSpawner>,
54 env: Record<string, string> = signedEnv,
55) =>
56 effect.pipe(
57 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, stub.spawner),
58 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))),
59 );
60
61const makeContent = Effect.fn("test.makeContent")(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const path = yield* Path.Path;
64 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-notary-" });
65 const contentDir = path.join(root, "content");
66 yield* fs.makeDirectory(contentDir);
67 const executablePath = path.join(contentDir, "t3");
68 yield* fs.writeFileString(executablePath, "t3");
69 return { root, contentDir, executablePath, submissionFile: path.join(root, "submission") };
70});
71
72const notarySubcommands = (calls: ReadonlyArray<ReadonlyArray<string>>) =>
73 calls.filter((argv) => argv[1] === "notarytool").map((argv) => argv.slice(2));
74
75it.layer(NodeServices.layer)("build-cli-archive notarization", (it) => {
76 it.effect("records a deferred submission without waiting, then gates on Accepted", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 // Apple documents id and path for an unwaited submit; a pending status must not fail it.
80 for (const submitted of [
81 '{"id":"new-id","message":"Successfully uploaded file","path":"t3.zip"}',
82 '{"id":"new-id","status":"In Progress"}',
83 ]) {
84 const content = yield* makeContent();
85 yield* fs.writeFileString(content.submissionFile, "stale-id");
86 const stub = stubSpawner((subcommand) =>
87 subcommand === "submit"
88 ? { exitCode: 0, stdout: submitted }
89 : { exitCode: 0, stdout: '{"id":"new-id","status":"Accepted"}' },
90 );
91
92 yield* withStubs(
93 signMacArchiveContents({
94 repoRoot: content.root,
95 contentDir: content.contentDir,
96 executablePath: content.executablePath,
97 notarySubmissionFile: Option.some(content.submissionFile),
98 }),
99 stub,
100 );
101 assert.equal(yield* fs.readFileString(content.submissionFile), "new-id");
102 const [submit] = notarySubcommands(stub.calls);
103 assert.equal(submit?.[0], "submit");
104 assert.isFalse(submit?.includes("--wait"));
105
106 yield* withStubs(awaitCliNotarization(content.submissionFile), stub);
107 assert.deepEqual(notarySubcommands(stub.calls)[1]?.slice(0, 2), ["wait", "new-id"]);
108 }
109 }).pipe(Effect.scoped),
110 );
111
112 it.effect("fails the gate unless Apple accepted the submission", () =>
113 Effect.gen(function* () {
114 const fs = yield* FileSystem.FileSystem;
115 const cases: ReadonlyArray<{
116 readonly name: string;
117 readonly wait: NotaryReply;
118 readonly submitted?: boolean;
119 readonly env?: Record<string, string>;
120 readonly error: string | undefined;
121 readonly fetchesLog?: boolean;
122 }> = [
123 {
124 name: "invalid",
125 wait: { exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' },
126 error: "CliArchiveNotarizationRejectedError",
127 fetchesLog: true,
128 },
129 {
130 name: "rejected with a non-zero exit",
131 wait: { exitCode: 1, stdout: '{"id":"sub","status":"Rejected"}' },
132 error: "CliArchiveNotarizationRejectedError",
133 fetchesLog: true,
134 },
135 {
136 name: "still in progress",
137 wait: { exitCode: 0, stdout: '{"id":"sub","status":"In Progress"}' },
138 error: "CliArchiveNotarizationRejectedError",
139 },
140 {
141 name: "still in progress with a non-zero exit",
142 wait: { exitCode: 1, stdout: '{"id":"sub","status":"In Progress"}' },
143 error: "CliArchiveCommandFailedError",
144 },
145 {
146 name: "notarytool failed",
147 wait: { exitCode: 69 },
148 error: "CliArchiveCommandFailedError",
149 },
150 {
151 name: "no status",
152 wait: { exitCode: 0, stdout: '{"id":"sub"}' },
153 error: "CliArchiveNotarizationRejectedError",
154 },
155 {
156 name: "accepted with a non-zero exit",
157 wait: { exitCode: 1, stdout: '{"id":"sub","status":"Accepted"}' },
158 error: "CliArchiveCommandFailedError",
159 },
160 {
161 name: "submission missing",
162 wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' },
163 submitted: false,
164 error: "CliArchiveInputMissingError",
165 },
166 {
167 name: "submission without credentials",
168 wait: { exitCode: 0, stdout: '{"id":"sub","status":"Accepted"}' },
169 env: { T3CODE_CLI_MAC_SIGN_IDENTITY: signedEnv.T3CODE_CLI_MAC_SIGN_IDENTITY },
170 error: "CliArchiveInputMissingError",
171 },
172 {
173 name: "notarization not configured",
174 wait: { exitCode: 0 },
175 submitted: false,
176 env: {},
177 error: undefined,
178 },
179 ];
180 for (const testCase of cases) {
181 const content = yield* makeContent();
182 if (testCase.submitted !== false) yield* fs.writeFileString(content.submissionFile, "sub");
183 const stub = stubSpawner(() => testCase.wait);
184 const exit = yield* withStubs(
185 awaitCliNotarization(content.submissionFile),
186 stub,
187 testCase.env,
188 ).pipe(Effect.flip, Effect.option);
189 assert.equal(Option.getOrUndefined(exit)?._tag, testCase.error, testCase.name);
190 assert.equal(
191 notarySubcommands(stub.calls).some((argv) => argv[0] === "log"),
192 testCase.fetchesLog ?? false,
193 testCase.name,
194 );
195 }
196 }).pipe(Effect.scoped),
197 );
198
199 it.effect("fails a standalone build whose waited submission is not Accepted", () =>
200 Effect.gen(function* () {
201 const content = yield* makeContent();
202 const stub = stubSpawner(() => ({ exitCode: 0, stdout: '{"id":"sub","status":"Invalid"}' }));
203 const error = yield* withStubs(
204 signMacArchiveContents({
205 repoRoot: content.root,
206 contentDir: content.contentDir,
207 executablePath: content.executablePath,
208 notarySubmissionFile: Option.none(),
209 }),
210 stub,
211 ).pipe(Effect.flip);
212 assert.equal(error._tag, "CliArchiveNotarizationRejectedError");
213 assert.isTrue(notarySubcommands(stub.calls)[0]?.includes("--wait"));
214 }).pipe(Effect.scoped),
215 );
216});