1import * as GitHub from "@/GitHub";
2import { GitHubCredentials } from "@/GitHub/Credentials.ts";
3import { Octokit } from "@/GitHub/Octokit.ts";
4import * as Output from "@/Output";
5import * as Provider from "@/Provider";
6import { destroy } from "@/RemovalPolicy";
7import * as Test from "@/Test/Alchemy";
8import { Octokit as OctokitClient } from "@octokit/rest";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14
15const requireTestOwner = (owner: string) => {
16 if (owner !== "alchemy-run-test" && owner !== "alchemy-run-test-2") {
17 throw new Error(`Refusing GitHub collaborator tests for owner ${owner}`);
18 }
19 return owner;
20};
21
22const owner = requireTestOwner(
23 process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test",
24);
25const fixtureNames = [
26 "alchemy-pr-1571-collaborator-lifecycle",
27 "alchemy-pr-1571-collaborator-replacement",
28 "alchemy-pr-1571-collaborator-list",
29];
30
31const { test } = Test.make({
32 providers: GitHub.providers({ baseUrl: "github.com" }),
33});
34
35const verifiedMember = Effect.gen(function* () {
36 const client = yield* Octokit;
37 const requested = process.env.GITHUB_TEST_COLLABORATOR_USERNAME;
38 if (!requested) {
39 const members = yield* Effect.tryPromise({
40 try: () =>
41 client.paginate(client.rest.orgs.listMembers, {
42 org: owner,
43 role: "member",
44 per_page: 100,
45 }),
46 catch: (error) => error as Error,
47 });
48 return yield* Effect.fail(
49 new Error(
50 `Set GITHUB_TEST_COLLABORATOR_USERNAME to an explicitly authorized active non-owner member of ${owner}; eligible members: ${members.map((member) => member.login).join(", ") || "none"}. No invitations or owner access changes are permitted.`,
51 ),
52 );
53 }
54 const { data: membership } = yield* Effect.tryPromise({
55 try: () =>
56 client.rest.orgs.getMembershipForUser({
57 org: owner,
58 username: requested,
59 }),
60 catch: (error) => error as Error,
61 });
62 if (membership.state !== "active" || membership.role !== "member") {
63 return yield* Effect.fail(
64 new Error(
65 `Refusing collaborator ${requested}: ${owner} membership is ${membership.state}/${membership.role}; an authorized active non-owner member is required.`,
66 ),
67 );
68 }
69 return requested;
70});
71
72const repository = (index: number) =>
73 GitHub.Repository(`Repo${index}`, {
74 owner,
75 name: fixtureNames[index]!,
76 description:
77 "PR 1571 collaborator test fixture; retained because the test token lacks delete_repo",
78 visibility: "private",
79 autoInit: true,
80 });
81
82const directCollaborators = (repo: string) =>
83 Effect.gen(function* () {
84 const client = yield* Octokit;
85 return yield* Effect.tryPromise({
86 try: () =>
87 client.paginate(client.rest.repos.listCollaborators, {
88 owner,
89 repo,
90 affiliation: "direct",
91 per_page: 100,
92 }),
93 catch: (error) => error as Error,
94 });
95 });
96
97const assertRemoved = (repo: string, username: string) =>
98 Effect.gen(function* () {
99 const remaining = yield* directCollaborators(repo).pipe(
100 Effect.repeat({
101 until: (collaborators) =>
102 !collaborators.some(
103 (collaborator) => collaborator.login === username,
104 ),
105 schedule: Schedule.spaced("1 second"),
106 times: 8,
107 }),
108 );
109 expect(
110 remaining.some((collaborator) => collaborator.login === username),
111 ).toBe(false);
112 const client = yield* Octokit;
113 const invitations = yield* Effect.tryPromise({
114 try: () =>
115 client.paginate(client.rest.repos.listInvitations, {
116 owner,
117 repo,
118 per_page: 100,
119 }),
120 catch: (error) => error as Error,
121 });
122 expect(
123 invitations.some((invitation) => invitation.invitee?.login === username),
124 ).toBe(false);
125 });
126
127// Permission changes require an explicitly authorized non-owner test member.
128test.provider.skipIf(!process.env.GITHUB_TEST_COLLABORATOR_USERNAME)(
129 "add, update, replace, and remove an authorized collaborator",
130 (stack) =>
131 Effect.gen(function* () {
132 // Verify authorization before even replaying persisted deletes.
133 const username = yield* verifiedMember;
134 yield* stack.destroy();
135 const deploy = (
136 index: number,
137 permission?: GitHub.CollaboratorProps["permission"],
138 ) =>
139 stack.deploy(
140 Effect.gen(function* () {
141 // Keep both dependencies present while replacing the collaborator.
142 const first = yield* repository(0);
143 const second = yield* repository(1);
144 return yield* GitHub.Collaborator("Collab", {
145 owner,
146 repository: Output.map(
147 (index === 0 ? first : second).fullName,
148 (fullName) => fullName.split("/")[1]!,
149 ),
150 username,
151 permission,
152 }).pipe(destroy());
153 }),
154 );
155 const created = yield* deploy(0);
156 expect(created.username).toBe(username);
157 expect(created.permission).toBe("push");
158 expect(
159 (yield* directCollaborators(fixtureNames[0]!)).find(
160 (member) => member.login === username,
161 )?.permissions?.push,
162 ).toBe(true);
163
164 const updated = yield* deploy(0, "admin");
165 expect(updated.permission).toBe("admin");
166 expect(
167 (yield* directCollaborators(fixtureNames[0]!)).find(
168 (member) => member.login === username,
169 )?.permissions?.admin,
170 ).toBe(true);
171
172 const replaced = yield* deploy(1, "triage");
173 expect(replaced.permission).toBe("triage");
174 yield* assertRemoved(fixtureNames[0]!, username);
175 expect(
176 (yield* directCollaborators(fixtureNames[1]!)).some(
177 (member) => member.login === username,
178 ),
179 ).toBe(true);
180
181 // Remove access independently while both retained repositories still exist.
182 yield* stack.deploy(
183 Effect.gen(function* () {
184 yield* repository(0);
185 yield* repository(1);
186 }),
187 );
188 yield* assertRemoved(fixtureNames[1]!, username);
189 yield* stack.destroy();
190 }),
191 {
192 tags: [
193 "provider:github",
194 "provider:github:collaborator",
195 "provider:github:repository",
196 "live",
197 ],
198 timeout: 120_000,
199 },
200);
201
202test.provider(
203 "list enumerates collaborators only in dedicated test repositories",
204 (stack) =>
205 Effect.gen(function* () {
206 yield* stack.destroy();
207 yield* stack.deploy(repository(2));
208 const client = yield* Octokit;
209 const { data: expected } = yield* Effect.tryPromise({
210 try: () =>
211 client.rest.repos.listCollaborators({
212 owner,
213 repo: fixtureNames[2]!,
214 per_page: 100,
215 }),
216 catch: (error) => error as Error,
217 });
218 expect(expected.length).toBeGreaterThan(0);
219 const credentials = yield* yield* GitHubCredentials;
220 const provider = yield* Provider.findProvider(GitHub.Collaborator);
221 // list() enumerates /user/repos; confine it to this suite's fixture.
222 const listed = yield* provider.list().pipe(
223 Effect.provideService(
224 GitHubCredentials,
225 Effect.succeed({
226 ...credentials,
227 octokit: (override) => {
228 const octokit = credentials.octokit(override);
229 octokit.hook.before("request", (options) => {
230 const url = new URL(options.url, "https://api.github.com");
231 if (url.pathname === "/user/repos") {
232 url.pathname = `/orgs/${owner}/repos`;
233 options.url = url.toString();
234 }
235 if (
236 url.origin !== "https://api.github.com" ||
237 (url.pathname !== `/orgs/${owner}/repos` &&
238 url.pathname !==
239 `/repos/${owner}/${fixtureNames[2]!}/collaborators`)
240 ) {
241 throw new Error(`Unsafe Collaborator list request: ${url}`);
242 }
243 });
244 octokit.hook.after("request", (response, options) => {
245 const url = new URL(options.url, "https://api.github.com");
246 if (url.pathname === `/orgs/${owner}/repos`) {
247 response.data = (
248 response.data as Array<{ name: string }>
249 ).filter((repo) => repo.name === fixtureNames[2]!);
250 }
251 });
252 return octokit;
253 },
254 }),
255 ),
256 );
257 for (const member of expected) {
258 expect(
259 listed.some((collaborator) => collaborator.username === member.login),
260 ).toBe(true);
261 }
262 yield* stack.destroy();
263 }),
264 {
265 tags: [
266 "provider:github",
267 "provider:github:collaborator",
268 "provider:github:repository",
269 "live",
270 ],
271 timeout: 120_000,
272 },
273);
274
275const mockCredentials = (calls: string[], access: Map<string, string>) =>
276 Effect.succeed({
277 token: Redacted.make("test-token"),
278 octokit: () =>
279 new OctokitClient({
280 auth: "test-token",
281 request: {
282 fetch: (url: string | URL | Request, options?: RequestInit) =>
283 Effect.runPromise(
284 Effect.sync(() => {
285 const path = new URL(String(url)).pathname;
286 const method = options?.method ?? "GET";
287 calls.push(`${method} ${path}`);
288 if (
289 !/^\/repos\/alchemy-run-test\/alchemy-pr-1571-unit(?:-replacement)?\/collaborators\/test-member$/.test(
290 path,
291 )
292 ) {
293 throw new Error(`Unexpected mock request ${method} ${path}`);
294 }
295 if (method === "PUT") {
296 access.set(
297 path,
298 JSON.parse(String(options?.body)).permission,
299 );
300 return new Response(null, { status: 204 });
301 }
302 if (method === "DELETE") {
303 const existed = access.delete(path);
304 return existed
305 ? new Response(null, { status: 204 })
306 : new Response(JSON.stringify({ message: "Not Found" }), {
307 status: 404,
308 headers: { "content-type": "application/json" },
309 });
310 }
311 throw new Error(`Unexpected mock method ${method}`);
312 }),
313 ),
314 },
315 }),
316 });
317
318const unitTest = (
319 name: string,
320 body: (
321 stack: Test.ScratchStack,
322 calls: string[],
323 access: Map<string, string>,
324 ) => Effect.Effect<void, any, any>,
325) => {
326 const calls: string[] = [];
327 const access = new Map<string, string>();
328 const { test } = Test.make({
329 providers: Layer.succeed(
330 GitHubCredentials,
331 mockCredentials(calls, access),
332 ).pipe(Layer.provideMerge(GitHub.providers({ baseUrl: "github.com" }))),
333 });
334 test.provider(name, (stack) => body(stack, calls, access), {
335 tags: ["provider:github", "provider:github:collaborator", "live"],
336 });
337};
338
339unitTest(
340 "unit: default permission, updates, replacement, and idempotent deletion",
341 (stack, calls, access) =>
342 Effect.gen(function* () {
343 yield* stack.destroy();
344 const deploy = (
345 repo: string,
346 permission?: GitHub.CollaboratorProps["permission"],
347 ) =>
348 stack.deploy(
349 GitHub.Collaborator("Collab", {
350 owner: "alchemy-run-test",
351 repository: repo,
352 username: "test-member",
353 permission,
354 }).pipe(destroy()),
355 );
356 const created = yield* deploy("alchemy-pr-1571-unit");
357 expect(created.permission).toBe("push");
358 expect([...access.values()]).toEqual(["push"]);
359 const updated = yield* deploy("alchemy-pr-1571-unit", "admin");
360 expect(updated.permission).toBe("admin");
361 expect([...access.values()]).toEqual(["admin"]);
362 yield* deploy("alchemy-pr-1571-unit-replacement", "pull");
363 expect([...access.keys()]).toEqual([
364 "/repos/alchemy-run-test/alchemy-pr-1571-unit-replacement/collaborators/test-member",
365 ]);
366 expect([...access.values()]).toEqual(["pull"]);
367 // Simulate out-of-band removal; the provider's DELETE must tolerate 404.
368 access.clear();
369 yield* stack.destroy();
370 expect(calls.filter((call) => call.startsWith("DELETE "))).toHaveLength(
371 2,
372 );
373 expect(access.size).toBe(0);
374 }),
375);
376
377unitTest(
378 "unit: collaborator defaults to retain on destroy",
379 (stack, calls, access) =>
380 Effect.gen(function* () {
381 yield* stack.destroy();
382 yield* stack.deploy(
383 GitHub.Collaborator("Collab", {
384 owner: "alchemy-run-test",
385 repository: "alchemy-pr-1571-unit",
386 username: "test-member",
387 }),
388 );
389 yield* stack.destroy();
390 expect(access.size).toBe(1);
391 expect(calls.some((call) => call.startsWith("DELETE "))).toBe(false);
392 }),
393);
394
395test(
396 "unit: owner allowlist rejects production and unrelated owners",
397 Effect.sync(() => {
398 expect(requireTestOwner("alchemy-run-test")).toBe("alchemy-run-test");
399 expect(requireTestOwner("alchemy-run-test-2")).toBe("alchemy-run-test-2");
400 for (const unsafe of ["alchemy-run", "sam-goodwin", "", "other-org"]) {
401 expect(() => requireTestOwner(unsafe)).toThrow(
402 "Refusing GitHub collaborator tests",
403 );
404 }
405 }),
406 {
407 tags: ["unit", "provider:github", "provider:github:collaborator", "local"],
408 },
409);