1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as codebuild from "@distilled.cloud/aws/codebuild";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import CodeBuildTestFunctionLive, {
12 CodeBuildTestFunction,
13 FIXTURE_PROJECT_NAME,
14 FIXTURE_REPORT_GROUP_NAME,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "CodeBuildBindings");
20
21// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
22// over 60s on a fresh deploy under parallel-suite load.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(75),
26]);
27
28let baseUrl: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35// Retry transient 5xx from the shared Lambda fixture (cold re-init, IAM
36// propagation on the freshly attached codebuild policy surfaced as a 500 by
37// the handler's `Effect.orDie`). Genuine 4xx/assertion failures return
38// immediately.
39const send = (request: HttpClientRequest.HttpClientRequest) =>
40 HttpClient.execute(request).pipe(
41 Effect.flatMap((response) =>
42 response.status >= 500
43 ? response.text.pipe(
44 Effect.flatMap((body) =>
45 Effect.fail(
46 new TransientUpstream({ status: response.status, body }),
47 ),
48 ),
49 )
50 : Effect.succeed(response),
51 ),
52 Effect.retry({
53 while: (e) => e._tag === "TransientUpstream",
54 schedule: Schedule.max([
55 Schedule.exponential("500 millis"),
56 Schedule.recurs(6),
57 ]),
58 }),
59 );
60
61const getJson = (url: string) =>
62 send(HttpClientRequest.get(url)).pipe(Effect.flatMap((r) => r.json));
63
64const postJson = (url: string, body: unknown) =>
65 send(
66 HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
67 ).pipe(Effect.flatMap((r) => r.json));
68
69const post = (url: string) =>
70 send(HttpClientRequest.post(url)).pipe(Effect.flatMap((r) => r.json));
71
72/**
73 * A route answered with a typed error tag. The tag being present proves the
74 * binding produced a typed failure (untyped errors crash into a 500), and it
75 * not being an authorization tag proves the IAM grant covered the call.
76 */
77const expectTypedNonAuthz = (body: any) => {
78 expect(typeof body.errorTag).toBe("string");
79 expect(body.errorTag).not.toBe("AccessDeniedException");
80};
81
82/** A route that either succeeded or failed with a typed, authorized tag. */
83const expectAuthorized = (body: any) => {
84 if (body.errorTag !== undefined) expectTypedNonAuthz(body);
85};
86
87class BuildNotTerminal extends Data.TaggedError("BuildNotTerminal") {}
88
89/** Poll a build until it leaves IN_PROGRESS (bounded). */
90const waitForTerminal = (buildId: string) =>
91 getJson(`${baseUrl}/build/get?id=${encodeURIComponent(buildId)}`).pipe(
92 Effect.flatMap((body: any) =>
93 body.status === undefined || body.status === "IN_PROGRESS"
94 ? Effect.fail(new BuildNotTerminal())
95 : Effect.succeed(body.status as string),
96 ),
97 Effect.retry({
98 while: (e): boolean => e._tag === "BuildNotTerminal",
99 schedule: Schedule.max([
100 Schedule.fixed("5 seconds"),
101 Schedule.recurs(20),
102 ]),
103 }),
104 );
105
106const FAKE_UUID = "00000000-0000-0000-0000-000000000000";
107
108describe.sequential(
109 "CodeBuild Bindings",
110 {
111 tags: [
112 "provider:aws",
113 "provider:aws:batch",
114 "provider:aws:codebuild",
115 "provider:aws:iam",
116 "provider:aws:lambda",
117 "provider:aws:logs",
118 "live",
119 ],
120 },
121 () => {
122 beforeAll(
123 Effect.gen(function* () {
124 yield* Effect.logInfo("CodeBuild test setup: destroying previous");
125 yield* sharedStack.destroy();
126
127 yield* Effect.logInfo("CodeBuild test setup: deploying fixture");
128 const { functionUrl } = yield* sharedStack.deploy(
129 Effect.gen(function* () {
130 return yield* CodeBuildTestFunction;
131 }).pipe(Effect.provide(CodeBuildTestFunctionLive)),
132 );
133
134 expect(functionUrl).toBeTruthy();
135 baseUrl = functionUrl!.replace(/\/+$/, "");
136 const readinessUrl = `${baseUrl}/build/list`;
137
138 yield* Effect.logInfo(
139 `CodeBuild test setup: probing readiness at ${readinessUrl}`,
140 );
141 // Ready = the function answers 200 AND the freshly attached codebuild
142 // policy has propagated (an AccessDeniedException errorTag means IAM
143 // is still converging — keep probing).
144 yield* HttpClient.get(readinessUrl).pipe(
145 Effect.flatMap((response) =>
146 response.status === 200
147 ? response.json
148 : Effect.fail(
149 new Error(`Function not ready: ${response.status}`),
150 ),
151 ),
152 Effect.flatMap((body: any) =>
153 body.errorTag === undefined
154 ? Effect.succeed(body)
155 : Effect.fail(new Error(`IAM not propagated: ${body.errorTag}`)),
156 ),
157 Effect.retry({ schedule: readinessPolicy }),
158 );
159 }),
160 { timeout: 300_000 },
161 );
162
163 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
164 timeout: 240_000,
165 });
166
167 describe("StartBuild + ListBuildsForProject + BatchGetBuilds + StopBuild + RetryBuild + BatchDeleteBuilds", () => {
168 test.provider(
169 "starts a build, observes it, stops it, retries it",
170 (_stack) =>
171 Effect.gen(function* () {
172 // StartBuild
173 const started = (yield* post(`${baseUrl}/build/start`)) as any;
174 expect(started.errorTag).toBeUndefined();
175 expect(started.buildId).toContain(FIXTURE_PROJECT_NAME);
176 const buildId = started.buildId as string;
177
178 // ListBuildsForProject sees it
179 const listed = (yield* getJson(`${baseUrl}/build/list`)) as any;
180 expect(listed.ids).toContain(buildId);
181
182 // StopBuild — a just-started build is stoppable in any
183 // pre-terminal phase.
184 const stopped = (yield* postJson(`${baseUrl}/build/stop`, {
185 id: buildId,
186 })) as any;
187 expectAuthorized(stopped);
188
189 // BatchGetBuilds — poll to a terminal status (bounded).
190 const terminal = yield* waitForTerminal(buildId);
191 expect(["STOPPED", "SUCCEEDED", "FAILED", "STOPPING"]).toContain(
192 terminal,
193 );
194
195 // RetryBuild — restarts the finished build (typed error is
196 // acceptable if the build is still finalizing).
197 const retried = (yield* postJson(`${baseUrl}/build/retry`, {
198 id: buildId,
199 })) as any;
200 expectAuthorized(retried);
201 if (retried.buildId !== undefined) {
202 // Don't leave the retried build running.
203 const restopped = (yield* postJson(`${baseUrl}/build/stop`, {
204 id: retried.buildId,
205 })) as any;
206 expectAuthorized(restopped);
207 }
208
209 // BatchDeleteBuilds — regular builds are typically reported in
210 // `buildsNotDeleted` (only batch-build members are deletable);
211 // the call completing proves the IAM + binding wiring.
212 const deleted = (yield* postJson(`${baseUrl}/build/delete`, {
213 ids: [buildId],
214 })) as any;
215 expectAuthorized(deleted);
216 }),
217 { timeout: 240_000 },
218 );
219 });
220
221 describe("InvalidateProjectCache", () => {
222 test.provider("invalidates the project's build cache", (_stack) =>
223 Effect.gen(function* () {
224 // The fixture project has no cache configured — success and a typed
225 // rejection both prove the wiring.
226 const body = (yield* post(`${baseUrl}/cache/invalidate`)) as any;
227 expectAuthorized(body);
228 }),
229 );
230 });
231
232 describe("Batch builds", () => {
233 test.provider(
234 "StartBuildBatch rejects a project without batch config (typed)",
235 (_stack) =>
236 Effect.gen(function* () {
237 const body = (yield* post(`${baseUrl}/batch/start`)) as any;
238 // The fixture project has no build-batch configuration —
239 // CodeBuild must reject with the TYPED InvalidInputException.
240 expect(body.errorTag).toBe("InvalidInputException");
241 }),
242 );
243
244 test.provider("ListBuildBatchesForProject lists (empty)", (_stack) =>
245 Effect.gen(function* () {
246 const body = (yield* getJson(`${baseUrl}/batch/list`)) as any;
247 expect(body.errorTag).toBeUndefined();
248 expect(body.ids).toEqual([]);
249 }),
250 );
251
252 test.provider("BatchGetBuildBatches reports unknown ids", (_stack) =>
253 Effect.gen(function* () {
254 const fakeId = `${FIXTURE_PROJECT_NAME}:${FAKE_UUID}`;
255 const body = (yield* getJson(
256 `${baseUrl}/batch/get?id=${encodeURIComponent(fakeId)}`,
257 )) as any;
258 expect(body.errorTag).toBeUndefined();
259 // The unknown id is reported back (in `buildBatchesNotFound`) rather
260 // than yielding a batch.
261 expect(body.found).toEqual([]);
262 expect(JSON.stringify(body)).toContain(FAKE_UUID);
263 }),
264 );
265
266 test.provider(
267 "Stop/Retry/DeleteBuildBatch answer typed for unknown ids",
268 (_stack) =>
269 Effect.gen(function* () {
270 const fakeId = `${FIXTURE_PROJECT_NAME}:${FAKE_UUID}`;
271 const stopped = (yield* postJson(`${baseUrl}/batch/stop`, {
272 id: fakeId,
273 })) as any;
274 expectTypedNonAuthz(stopped);
275
276 const retried = (yield* postJson(`${baseUrl}/batch/retry`, {
277 id: fakeId,
278 })) as any;
279 expectTypedNonAuthz(retried);
280
281 const deleted = (yield* postJson(`${baseUrl}/batch/delete`, {
282 id: fakeId,
283 })) as any;
284 expectAuthorized(deleted);
285 }),
286 );
287 });
288
289 describe("Sandboxes", () => {
290 test.provider(
291 "StartSandbox + BatchGetSandboxes + ListSandboxesForProject + StopSandbox",
292 (_stack) =>
293 Effect.gen(function* () {
294 const started = (yield* post(`${baseUrl}/sandbox/start`)) as any;
295 expectAuthorized(started);
296
297 if (started.sandboxId !== undefined) {
298 // Stop it right away — sandboxes bill while running.
299 const stopped = (yield* postJson(`${baseUrl}/sandbox/stop`, {
300 id: started.sandboxId,
301 })) as any;
302 expectAuthorized(stopped);
303
304 const got = (yield* getJson(
305 `${baseUrl}/sandbox/get?id=${encodeURIComponent(started.sandboxId)}`,
306 )) as any;
307 expectAuthorized(got);
308
309 const listed = (yield* getJson(`${baseUrl}/sandbox/list`)) as any;
310 expect(listed.errorTag).toBeUndefined();
311 expect(listed.ids).toContain(started.sandboxId);
312 } else {
313 // Sandboxes are not available for every project configuration —
314 // the typed rejection still proves IAM + binding wiring.
315 const listed = (yield* getJson(`${baseUrl}/sandbox/list`)) as any;
316 expectAuthorized(listed);
317 }
318 }),
319 { timeout: 120_000 },
320 );
321
322 test.provider(
323 "command-execution bindings answer typed for an unknown sandbox",
324 (_stack) =>
325 Effect.gen(function* () {
326 const fakeSandbox = `${FIXTURE_PROJECT_NAME}:${FAKE_UUID}`;
327 const command = (yield* postJson(`${baseUrl}/sandbox/command`, {
328 sandboxId: fakeSandbox,
329 command: "echo hello",
330 })) as any;
331 expectTypedNonAuthz(command);
332
333 const got = (yield* getJson(
334 `${baseUrl}/sandbox/command-get?sandboxId=${encodeURIComponent(fakeSandbox)}&commandId=${FAKE_UUID}`,
335 )) as any;
336 expectAuthorized(got);
337
338 const listed = (yield* getJson(
339 `${baseUrl}/sandbox/commands?sandboxId=${encodeURIComponent(fakeSandbox)}`,
340 )) as any;
341 expectAuthorized(listed);
342 }),
343 );
344 });
345
346 describe("Reports", () => {
347 test.provider(
348 "report-read bindings are wired against the bound group",
349 (_stack) =>
350 Effect.gen(function* () {
351 // The fixture group is fresh — no reports yet.
352 const listed = (yield* getJson(`${baseUrl}/reports/list`)) as any;
353 expect(listed.errorTag).toBeUndefined();
354 expect(listed.reports).toEqual([]);
355
356 // Fabricate a report ARN inside the bound group (IAM authorizes
357 // report reads against the report-group ARN).
358 const groupArn = yield* codebuild
359 .listReportGroups({})
360 .pipe(
361 Effect.map((res) =>
362 res.reportGroups?.find((arn) =>
363 arn.endsWith(`report-group/${FIXTURE_REPORT_GROUP_NAME}`),
364 ),
365 ),
366 );
367 expect(groupArn).toBeDefined();
368 const fakeReportArn = `${groupArn!.replace(
369 ":report-group/",
370 ":report/",
371 )}:${FAKE_UUID}`;
372
373 const got = (yield* getJson(
374 `${baseUrl}/reports/get?arn=${encodeURIComponent(fakeReportArn)}`,
375 )) as any;
376 expectAuthorized(got);
377 if (got.errorTag === undefined) {
378 expect(got.notFound).toContain(fakeReportArn);
379 }
380
381 const testCases = (yield* getJson(
382 `${baseUrl}/reports/test-cases?arn=${encodeURIComponent(fakeReportArn)}`,
383 )) as any;
384 expectAuthorized(testCases);
385
386 const coverage = (yield* getJson(
387 `${baseUrl}/reports/coverage?arn=${encodeURIComponent(fakeReportArn)}`,
388 )) as any;
389 expectAuthorized(coverage);
390
391 const trend = (yield* getJson(`${baseUrl}/reports/trend`)) as any;
392 expectAuthorized(trend);
393
394 const deleted = (yield* postJson(`${baseUrl}/reports/delete`, {
395 arn: fakeReportArn,
396 })) as any;
397 expectAuthorized(deleted);
398 }),
399 { timeout: 120_000 },
400 );
401 });
402 },
403);