1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import CodePipelineTestFunctionLive, {
11 APPROVAL_STAGE,
12 CodePipelineTestFunction,
13 SOURCE_ACTION,
14 SOURCE_STAGE,
15} from "./handler";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "CodePipelineBindings");
20
21// A fresh Lambda role can remain unauthorized by CodePipeline after the
22// function itself is active.
23const readinessPolicy = Schedule.max([
24 Schedule.fixed("2 seconds"),
25 Schedule.recurs(90),
26]);
27
28let baseUrl: string;
29
30class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
31 readonly status: number;
32 readonly body: string;
33}> {}
34
35class PipelineReadinessError extends Data.TaggedError(
36 "PipelineReadinessError",
37)<{
38 readonly status: number;
39 readonly errorTag?: string;
40 readonly errorMessage?: string;
41}> {
42 override get message() {
43 return `CodePipeline readiness failed (${this.errorTag ?? `HTTP ${this.status}`}): ${this.errorMessage ?? "no error details"}`;
44 }
45}
46
47// Retry transient 5xx from the shared Lambda fixture (cold re-init, IAM
48// propagation on the freshly attached codepipeline policy surfaced as a 500
49// by the handler's `Effect.orDie`). Genuine 4xx/assertion failures return
50// immediately.
51const send = (request: HttpClientRequest.HttpClientRequest) =>
52 HttpClient.execute(request).pipe(
53 Effect.flatMap((response) =>
54 response.status >= 500
55 ? response.text.pipe(
56 Effect.flatMap((body) =>
57 Effect.fail(
58 new TransientUpstream({ status: response.status, body }),
59 ),
60 ),
61 )
62 : Effect.succeed(response),
63 ),
64 Effect.retry({
65 while: (e) => e._tag === "TransientUpstream",
66 schedule: Schedule.max([
67 Schedule.exponential("500 millis"),
68 Schedule.recurs(6),
69 ]),
70 }),
71 );
72
73const getJson = (url: string) =>
74 send(HttpClientRequest.get(url)).pipe(Effect.flatMap((r) => r.json));
75
76const postJson = (url: string, body: unknown) =>
77 send(
78 HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)),
79 ).pipe(Effect.flatMap((r) => r.json));
80
81const post = (url: string) =>
82 send(HttpClientRequest.post(url)).pipe(Effect.flatMap((r) => r.json));
83
84/**
85 * A route answered with a typed error tag. The tag being present proves the
86 * binding produced a typed failure (untyped errors crash into a 500), and it
87 * not being an authorization tag proves the IAM grant covered the call.
88 */
89const expectTypedNonAuthz = (body: any) => {
90 expect(typeof body.errorTag).toBe("string");
91 expect(body.errorTag).not.toBe("AccessDeniedException");
92};
93
94/** A route that either succeeded or failed with a typed, authorized tag. */
95const expectAuthorized = (body: any) => {
96 if (body.errorTag !== undefined) expectTypedNonAuthz(body);
97};
98
99class ApprovalNotPending extends Data.TaggedError("ApprovalNotPending") {}
100
101class ExecutionNotVisible extends Data.TaggedError("ExecutionNotVisible") {}
102
103/**
104 * A freshly started execution is not immediately visible to
105 * GetPipelineExecution / ListPipelineExecutions (eventual consistency) —
106 * poll the route (bounded) until it stops answering not-found/absent.
107 */
108const untilExecutionVisible = <A, E, R>(
109 effect: Effect.Effect<A, E, R>,
110 visible: (body: A) => boolean,
111): Effect.Effect<A, E | ExecutionNotVisible, R> =>
112 effect.pipe(
113 Effect.flatMap((body) =>
114 visible(body)
115 ? Effect.succeed(body)
116 : Effect.fail(new ExecutionNotVisible()),
117 ),
118 Effect.retry({
119 while: (e): boolean => e instanceof ExecutionNotVisible,
120 schedule: Schedule.max([
121 Schedule.fixed("3 seconds"),
122 Schedule.recurs(10),
123 ]),
124 }),
125 );
126
127const FAKE_UUID = "00000000-0000-0000-0000-000000000000";
128
129describe.sequential(
130 "CodePipeline Bindings",
131 {
132 tags: [
133 "provider:aws",
134 "provider:aws:codepipeline",
135 "provider:aws:iam",
136 "provider:aws:lambda",
137 "provider:aws:s3",
138 "live",
139 ],
140 },
141 () => {
142 beforeAll(
143 Effect.gen(function* () {
144 yield* Effect.logInfo("CodePipeline test setup: destroying previous");
145 yield* sharedStack.destroy();
146
147 yield* Effect.logInfo("CodePipeline test setup: deploying fixture");
148 const { functionUrl } = yield* sharedStack.deploy(
149 Effect.gen(function* () {
150 return yield* CodePipelineTestFunction;
151 }).pipe(Effect.provide(CodePipelineTestFunctionLive)),
152 );
153
154 expect(functionUrl).toBeTruthy();
155 baseUrl = functionUrl!.replace(/\/+$/, "");
156 const readinessUrl = `${baseUrl}/state`;
157
158 yield* Effect.logInfo(
159 `CodePipeline test setup: probing readiness at ${readinessUrl}`,
160 );
161 // Readiness requires an authorized downstream call, not just HTTP 200.
162 yield* HttpClient.get(readinessUrl).pipe(
163 Effect.flatMap((response) =>
164 Effect.gen(function* () {
165 if (response.status !== 200) {
166 return yield* new PipelineReadinessError({
167 status: response.status,
168 });
169 }
170 return yield* response.json;
171 }),
172 ),
173 Effect.flatMap((body) => {
174 const result = body as { errorTag?: string; errorMessage?: string };
175 return result.errorTag === undefined
176 ? Effect.succeed(result)
177 : Effect.fail(
178 new PipelineReadinessError({ status: 200, ...result }),
179 );
180 }),
181 Effect.tapError((error) => Effect.logWarning(String(error))),
182 Effect.retry({ schedule: readinessPolicy }),
183 );
184 }),
185 { timeout: 300_000 },
186 );
187
188 afterAll.skipIf(!!process.env.NO_DESTROY)(sharedStack.destroy(), {
189 timeout: 240_000,
190 });
191
192 describe("GetPipelineState + ListPipelineExecutions + ListActionExecutions + ListRuleExecutions", () => {
193 test.provider("reads the pipeline's state and histories", (_stack) =>
194 Effect.gen(function* () {
195 const state = (yield* getJson(`${baseUrl}/state`)) as any;
196 expect(state.errorTag).toBeUndefined();
197 expect(state.stageNames).toEqual([SOURCE_STAGE, APPROVAL_STAGE]);
198
199 const executions = (yield* getJson(`${baseUrl}/executions`)) as any;
200 expect(executions.errorTag).toBeUndefined();
201 expect(Array.isArray(executions.ids)).toBe(true);
202
203 const actions = (yield* getJson(`${baseUrl}/actions`)) as any;
204 expect(actions.errorTag).toBeUndefined();
205
206 const rules = (yield* getJson(`${baseUrl}/rules`)) as any;
207 expectAuthorized(rules);
208 }),
209 );
210 });
211
212 describe("StartPipelineExecution + GetPipelineExecution + PutApprovalResult + StopPipelineExecution", () => {
213 test.provider(
214 "runs an execution to the manual gate and approves it",
215 (_stack) =>
216 Effect.gen(function* () {
217 // Seed the source object (S3.PutObject binding on the Lambda).
218 const uploaded = (yield* post(`${baseUrl}/source/upload`)) as any;
219 expect(uploaded.errorTag).toBeUndefined();
220
221 // Start an execution.
222 const started = (yield* post(`${baseUrl}/execution/start`)) as any;
223 expect(started.errorTag).toBeUndefined();
224 const executionId = started.executionId as string;
225 expect(executionId).toBeTruthy();
226
227 // GetPipelineExecution sees it (eventual consistency — poll).
228 const got = (yield* untilExecutionVisible(
229 getJson(
230 `${baseUrl}/execution/get?id=${encodeURIComponent(executionId)}`,
231 ),
232 (body: any) =>
233 body.errorTag !== "PipelineExecutionNotFoundException",
234 )) as any;
235 expect(got.errorTag).toBeUndefined();
236 expect(got.status).toBeTruthy();
237
238 // ListPipelineExecutions includes it (eventual consistency — poll).
239 const listed = (yield* untilExecutionVisible(
240 getJson(`${baseUrl}/executions`),
241 (body: any) => (body.ids ?? []).includes(executionId),
242 )) as any;
243 expect(listed.ids).toContain(executionId);
244
245 // Wait (bounded) for the source stage to hand the execution to the
246 // manual-approval action, which exposes an approval token.
247 const token = yield* getJson(`${baseUrl}/state`).pipe(
248 Effect.flatMap((state: any) =>
249 typeof state.approvalToken === "string"
250 ? Effect.succeed(state.approvalToken as string)
251 : Effect.fail(new ApprovalNotPending()),
252 ),
253 Effect.retry({
254 while: (e): boolean => e._tag === "ApprovalNotPending",
255 schedule: Schedule.max([
256 Schedule.fixed("5 seconds"),
257 Schedule.recurs(20),
258 ]),
259 }),
260 );
261
262 // ListActionExecutions shows the source action ran for this
263 // execution.
264 const actions = (yield* getJson(
265 `${baseUrl}/actions?executionId=${encodeURIComponent(executionId)}`,
266 )) as any;
267 expect(actions.errorTag).toBeUndefined();
268 expect(actions.actions).toContain(SOURCE_ACTION);
269
270 // Answer the approval.
271 const approved = (yield* postJson(`${baseUrl}/approval`, {
272 token,
273 status: "Approved",
274 })) as any;
275 expectAuthorized(approved);
276
277 // StopPipelineExecution — the execution just completed via the
278 // approval, so a typed not-stoppable rejection is the expected
279 // proof of wiring (success is also fine if timing races).
280 const stopped = (yield* postJson(`${baseUrl}/execution/stop`, {
281 id: executionId,
282 abandon: true,
283 })) as any;
284 expectAuthorized(stopped);
285 }),
286 { timeout: 240_000 },
287 );
288 });
289
290 describe("DisableStageTransition + EnableStageTransition", () => {
291 test.provider("freezes and re-opens the approval stage", (_stack) =>
292 Effect.gen(function* () {
293 const disabled = (yield* postJson(`${baseUrl}/transition/disable`, {
294 stageName: APPROVAL_STAGE,
295 })) as any;
296 expect(disabled.errorTag).toBeUndefined();
297
298 // GetPipelineState reflects the transition change eventually — poll
299 // (bounded) until the disabled transition is observed.
300 const frozen = (yield* untilExecutionVisible(
301 getJson(`${baseUrl}/state`),
302 (body: any) => body.inboundTransitionEnabled === false,
303 )) as any;
304 expect(frozen.inboundTransitionEnabled).toBe(false);
305
306 const enabled = (yield* postJson(`${baseUrl}/transition/enable`, {
307 stageName: APPROVAL_STAGE,
308 })) as any;
309 expect(enabled.errorTag).toBeUndefined();
310
311 const open = (yield* untilExecutionVisible(
312 getJson(`${baseUrl}/state`),
313 (body: any) => body.inboundTransitionEnabled === true,
314 )) as any;
315 expect(open.inboundTransitionEnabled).toBe(true);
316 }),
317 );
318 });
319
320 describe("RetryStageExecution + RollbackStage + OverrideStageCondition + PutActionRevision + ListDeployActionExecutionTargets", () => {
321 test.provider(
322 "stage operations answer typed for invalid targets",
323 (_stack) =>
324 Effect.gen(function* () {
325 // Nothing failed — retrying is a typed StageNotRetryable /
326 // Conflict / NotLatestPipelineExecution rejection.
327 const retried = (yield* postJson(`${baseUrl}/stage/retry`, {
328 stageName: APPROVAL_STAGE,
329 executionId: FAKE_UUID,
330 })) as any;
331 expectTypedNonAuthz(retried);
332
333 const rolledBack = (yield* postJson(`${baseUrl}/stage/rollback`, {
334 stageName: APPROVAL_STAGE,
335 targetExecutionId: FAKE_UUID,
336 })) as any;
337 expectTypedNonAuthz(rolledBack);
338
339 // The fixture pipeline declares no stage conditions.
340 const overridden = (yield* postJson(
341 `${baseUrl}/condition/override`,
342 {
343 stageName: APPROVAL_STAGE,
344 executionId: FAKE_UUID,
345 },
346 )) as any;
347 expectTypedNonAuthz(overridden);
348
349 // Unknown action name — typed ActionNotFoundException.
350 const revision = (yield* postJson(`${baseUrl}/action/revision`, {
351 actionName: "NoSuchAction",
352 revisionId: FAKE_UUID,
353 })) as any;
354 expectTypedNonAuthz(revision);
355
356 // No deploy actions in the fixture — success-empty or a typed
357 // rejection both prove the wiring.
358 const targets = (yield* getJson(
359 `${baseUrl}/deploy-targets?actionExecutionId=${FAKE_UUID}`,
360 )) as any;
361 expectAuthorized(targets);
362
363 // Approval with a fake token — typed InvalidApprovalToken /
364 // ApprovalAlreadyCompleted rejection.
365 const approval = (yield* postJson(`${baseUrl}/approval`, {
366 token: FAKE_UUID,
367 status: "Rejected",
368 })) as any;
369 expectTypedNonAuthz(approval);
370 }),
371 { timeout: 120_000 },
372 );
373 });
374
375 describe("GetJobDetails + PutJobSuccessResult + PutJobFailureResult + PollForJobs + AcknowledgeJob", () => {
376 test.provider(
377 "job-worker bindings answer typed for unknown jobs",
378 (_stack) =>
379 Effect.gen(function* () {
380 const details = (yield* getJson(
381 `${baseUrl}/job/get?id=${FAKE_UUID}`,
382 )) as any;
383 expectAuthorized(details);
384
385 const success = (yield* postJson(`${baseUrl}/job/success`, {
386 jobId: FAKE_UUID,
387 })) as any;
388 expectTypedNonAuthz(success);
389
390 const failure = (yield* postJson(`${baseUrl}/job/failure`, {
391 jobId: FAKE_UUID,
392 })) as any;
393 expectTypedNonAuthz(failure);
394
395 // No custom action type exists — typed ActionTypeNotFoundException.
396 const polled = (yield* post(`${baseUrl}/job/poll`)) as any;
397 expectTypedNonAuthz(polled);
398
399 // Unknown job/nonce — typed JobNotFound / InvalidNonce rejection.
400 const acked = (yield* postJson(`${baseUrl}/job/ack`, {
401 jobId: FAKE_UUID,
402 nonce: FAKE_UUID,
403 })) as any;
404 expectTypedNonAuthz(acked);
405 }),
406 );
407 });
408 },
409);