1import * as GitHub from "@/GitHub";
2import { GitHubCredentials } from "@/GitHub/Credentials";
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 RestOctokit } 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 Result from "effect/Result";
14
15const testOwner = (value: string) => {
16 if (value !== "alchemy-run-test" && value !== "alchemy-run-test-2") {
17 throw new Error(`Unsafe GITHUB_TEST_OWNER: ${value}`);
18 }
19 return value;
20};
21const owner = testOwner(process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test");
22testOwner(process.env.GITHUB_TEST_OWNER_2 ?? "alchemy-run-test-2");
23const repositoryName = "alchemy-pr-1572-team-access";
24const teamName = "alchemy-pr-1572-team-access";
25const { test } = Test.make({
26 providers: GitHub.providers({ baseUrl: "github.com" }),
27});
28
29const getTeams = (repo: string) =>
30 Effect.gen(function* () {
31 const octokit = yield* Octokit;
32 return yield* Effect.tryPromise({
33 try: () =>
34 octokit.paginate(octokit.rest.repos.listTeams, {
35 owner,
36 repo,
37 per_page: 100,
38 }),
39 catch: (error) => error as Error,
40 });
41 });
42
43const listTestOrgAccess = Effect.gen(function* () {
44 const credentials = yield* yield* GitHubCredentials;
45 const provider = yield* Provider.findProviderByType<GitHub.TeamAccess>(
46 GitHub.TeamAccess.Type,
47 );
48 return yield* provider.list().pipe(
49 Effect.provideService(
50 GitHubCredentials,
51 Effect.succeed({
52 ...credentials,
53 octokit: (override) => {
54 const octokit = credentials.octokit(override);
55 octokit.hook.before("request", (options) => {
56 const url = new URL(options.url, "https://api.github.com");
57 if (url.pathname === "/user/repos") {
58 url.pathname = `/orgs/${owner}/repos`;
59 options.url = url.href;
60 }
61 if (
62 url.hostname !== "api.github.com" ||
63 !(
64 url.pathname === `/orgs/${owner}/repos` ||
65 url.pathname.startsWith(`/repos/${owner}/`) ||
66 (options.url === "/repos/{owner}/{repo}/teams" &&
67 options.owner === owner)
68 )
69 ) {
70 throw new Error(
71 `Refusing GitHub enumeration outside ${owner}: ${options.url}`,
72 );
73 }
74 });
75 return octokit;
76 },
77 }),
78 ),
79 );
80});
81
82test.provider(
83 "grant, update, list, and revoke dedicated team access",
84 (stack) =>
85 Effect.gen(function* () {
86 yield* stack.destroy();
87 const octokit = yield* Octokit;
88 // Creating a fresh dedicated team requires admin:org; never use an existing team's access.
89 const { data: team } = yield* Effect.tryPromise({
90 try: () =>
91 octokit.rest.teams.create({
92 org: owner,
93 name: teamName,
94 privacy: "closed",
95 }),
96 catch: (error) => error as Error,
97 });
98 expect(team.slug).toBe(teamName);
99
100 const deployAccess = (
101 permission?: GitHub.TeamAccessProps["permission"],
102 ) =>
103 stack.deploy(
104 Effect.gen(function* () {
105 // Retained intentionally: the default gh token does not have delete_repo.
106 const repo = yield* GitHub.Repository("Repo", {
107 owner,
108 name: repositoryName,
109 visibility: "private",
110 autoInit: true,
111 });
112 return yield* GitHub.TeamAccess("TeamAccess", {
113 owner,
114 repository: Output.map(
115 repo.fullName,
116 (fullName) => fullName.split("/")[1]!,
117 ),
118 teamSlug: team.slug,
119 permission,
120 }).pipe(destroy());
121 }),
122 );
123
124 yield* Effect.gen(function* () {
125 const created = yield* deployAccess();
126 expect(created).toEqual({ teamSlug: team.slug, permission: "push" });
127 expect(
128 (yield* getTeams(repositoryName)).find(
129 (item) => item.slug === team.slug,
130 )?.permission,
131 ).toBe("push");
132 expect(yield* listTestOrgAccess).toContainEqual({
133 teamSlug: team.slug,
134 permission: "push",
135 });
136
137 const updated = yield* deployAccess("admin");
138 expect(updated.permission).toBe("admin");
139 expect(
140 (yield* getTeams(repositoryName)).find(
141 (item) => item.slug === team.slug,
142 )?.permission,
143 ).toBe("admin");
144 }).pipe(
145 Effect.onExit(() =>
146 Effect.gen(function* () {
147 yield* stack.destroy();
148 // Verify access removal while both the repository and team still exist.
149 expect(
150 (yield* getTeams(repositoryName)).find(
151 (item) => item.slug === team.slug,
152 ),
153 ).toBeUndefined();
154 yield* Effect.tryPromise({
155 try: () =>
156 octokit.rest.teams.deleteInOrg({
157 org: owner,
158 team_slug: team.slug,
159 }),
160 catch: (error) => error as Error,
161 });
162 const remaining = yield* Effect.tryPromise({
163 try: () =>
164 octokit.paginate(octokit.rest.teams.list, {
165 org: owner,
166 per_page: 100,
167 }),
168 catch: (error) => error as Error,
169 });
170 expect(remaining.some((item) => item.slug === team.slug)).toBe(
171 false,
172 );
173 }).pipe(Effect.orDie),
174 ),
175 );
176 yield* stack.destroy();
177 }),
178 {
179 tags: [
180 "provider:github",
181 "provider:github:repository",
182 "provider:github:teamaccess",
183 "live",
184 ],
185 timeout: 120_000,
186 },
187);
188
189test.provider(
190 "list team access only within the authorized test org",
191 (stack) =>
192 Effect.gen(function* () {
193 yield* stack.destroy();
194 expect(Array.isArray(yield* listTestOrgAccess)).toBe(true);
195 yield* stack.destroy();
196 }),
197 { tags: ["provider:github", "provider:github:teamaccess", "live"] },
198);
199
200const grants = new Map<string, string>();
201const requests: { method: string; path: string }[] = [];
202let rejectWrites = false;
203const mockedCredentials = Layer.succeed(
204 GitHubCredentials,
205 Effect.succeed({
206 token: Redacted.make("test-token"),
207 octokit: () =>
208 new RestOctokit({
209 auth: "test-token",
210 request: {
211 fetch: (input: string | URL | Request, init?: RequestInit) =>
212 Effect.runPromise(
213 Effect.sync(() => {
214 const url = new URL(
215 typeof input === "string"
216 ? input
217 : input instanceof URL
218 ? input.href
219 : input.url,
220 );
221 const method = init?.method ?? "GET";
222 requests.push({ method, path: url.pathname });
223 const json = (data: unknown, status = 200) =>
224 new Response(JSON.stringify(data), {
225 status,
226 headers: { "content-type": "application/json" },
227 });
228 const match = url.pathname.match(
229 /^\/orgs\/([^/]+)\/teams\/([^/]+)\/repos\/([^/]+)\/([^/]+)$/,
230 );
231 if (match) {
232 expect(match[1]).toBe(owner);
233 expect(match[3]).toBe(owner);
234 const key = `${match[2]}/${match[4]}`;
235 if (method === "PUT") {
236 if (rejectWrites)
237 return json(
238 { message: "Resource not accessible by integration" },
239 403,
240 );
241 grants.set(key, JSON.parse(String(init?.body)).permission);
242 return new Response(null, { status: 204 });
243 }
244 if (method === "DELETE") {
245 return grants.delete(key)
246 ? new Response(null, { status: 204 })
247 : json({ message: "Not Found" }, 404);
248 }
249 }
250 if (method === "GET" && url.pathname === "/user/repos") {
251 return json([
252 { owner: { login: owner }, name: repositoryName },
253 ]);
254 }
255 if (
256 method === "GET" &&
257 url.pathname === `/repos/${owner}/${repositoryName}/teams`
258 ) {
259 return json(
260 [...grants].map(([key, permission]) => ({
261 slug: key.split("/")[0],
262 permission,
263 })),
264 );
265 }
266 throw new Error(
267 `Unexpected mocked request: ${method} ${url.pathname}`,
268 );
269 }),
270 ),
271 },
272 }),
273 }),
274);
275const { test: unit } = Test.make({
276 providers: Layer.effect(
277 GitHub.Providers,
278 Provider.collection([GitHub.TeamAccess]),
279 ).pipe(
280 Layer.provide(GitHub.TeamAccessProvider()),
281 Layer.provideMerge(mockedCredentials),
282 ),
283});
284
285unit.provider(
286 "unit: defaults, updates, replacement, retention, errors, and idempotent deletion",
287 (stack) =>
288 Effect.gen(function* () {
289 yield* stack.destroy();
290 const deploy = (
291 teamSlug: string,
292 permission?: GitHub.TeamAccessProps["permission"],
293 remove = true,
294 ) =>
295 stack.deploy(
296 Effect.gen(function* () {
297 const access = GitHub.TeamAccess("Access", {
298 owner,
299 repository: repositoryName,
300 teamSlug,
301 permission,
302 });
303 return yield* remove ? access.pipe(destroy()) : access;
304 }),
305 );
306
307 expect((yield* deploy(teamName)).permission).toBe("push");
308 expect(grants.get(`${teamName}/${repositoryName}`)).toBe("push");
309 expect((yield* deploy(teamName, "admin")).permission).toBe("admin");
310 expect(grants.get(`${teamName}/${repositoryName}`)).toBe("admin");
311 const provider = yield* Provider.findProviderByType<GitHub.TeamAccess>(
312 GitHub.TeamAccess.Type,
313 );
314 expect(yield* provider.list()).toEqual([
315 { teamSlug: teamName, permission: "admin" },
316 ]);
317
318 const replacement = `${teamName}-replacement`;
319 yield* deploy(replacement, "pull");
320 expect(grants.has(`${teamName}/${repositoryName}`)).toBe(false);
321 expect(grants.get(`${replacement}/${repositoryName}`)).toBe("pull");
322
323 // An out-of-band revocation must not make stack deletion fail.
324 yield* Effect.sync(() => grants.clear());
325 yield* stack.destroy();
326 expect(requests).toContainEqual({
327 method: "DELETE",
328 path: `/orgs/${owner}/teams/${replacement}/repos/${owner}/${repositoryName}`,
329 });
330
331 yield* deploy(teamName, "triage", false);
332 yield* stack.destroy();
333 expect(grants.get(`${teamName}/${repositoryName}`)).toBe("triage");
334 yield* deploy(teamName, "maintain");
335 yield* stack.destroy();
336 expect(grants.size).toBe(0);
337
338 yield* Effect.sync(() => {
339 rejectWrites = true;
340 });
341 const rejected = yield* deploy(teamName).pipe(Effect.result);
342 expect(Result.isFailure(rejected)).toBe(true);
343 yield* Effect.sync(() => {
344 rejectWrites = false;
345 });
346 yield* stack.destroy();
347 expect(grants.size).toBe(0);
348 }),
349 { tags: ["unit", "provider:github", "provider:github:teamaccess", "local"] },
350);
351
352unit(
353 "unit: rejects production and arbitrary fixture owners",
354 Effect.sync(() => {
355 expect(() => testOwner("alchemy-run")).toThrow("Unsafe GITHUB_TEST_OWNER");
356 expect(() => testOwner("personal-account")).toThrow(
357 "Unsafe GITHUB_TEST_OWNER",
358 );
359 expect(() => testOwner("")).toThrow("Unsafe GITHUB_TEST_OWNER");
360 expect(testOwner("alchemy-run-test")).toBe("alchemy-run-test");
361 expect(testOwner("alchemy-run-test-2")).toBe("alchemy-run-test-2");
362 }),
363 { tags: ["unit", "provider:github", "provider:github:teamaccess", "local"] },
364);