1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as amplify from "@distilled.cloud/aws/amplify";
5import * as eventbridge from "@distilled.cloud/aws/eventbridge";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import AmplifyTestFunctionLive, {
13 AmplifyTestFunction,
14} from "./fixtures/handler";
15import { makeAmplifyTestLease } from "./TestLease.ts";
16
17const testOptions = { providers: AWS.providers() };
18const { test, beforeAll, afterAll } = Test.make(testOptions);
19const sharedStack = Core.scratchStack(testOptions, "AmplifyBindings");
20const testLease = makeAmplifyTestLease();
21
22beforeAll(testLease.acquire, { timeout: 240_000 });
23afterAll(testLease.release);
24
25const branchName = "main";
26
27// Minimal deployable site: a stored zip containing a single `index.html`
28// ("hello amplify"). Generated once and checked in as a constant so the test
29// never zips at runtime.
30const SITE_ZIP_BASE64 =
31 "UEsDBBQAAAAAAAAAIVwZ1jYHJwAAACcAAAAKAAAAaW5kZXguaHRtbDxodG1sPjxib2R5PmhlbGxvIGFtcGxpZnk8L2JvZHk+PC9odG1sPlBLAQIUAxQAAAAAAAAAIVwZ1jYHJwAAACcAAAAKAAAAAAAAAAAAAACAAQAAAABpbmRleC5odG1sUEsFBgAAAAABAAEAOAAAAE8AAAAAAA==";
32
33const readinessPolicy = Schedule.max([
34 Schedule.fixed("2 seconds"),
35 Schedule.recurs(10),
36]);
37
38let baseUrl: string;
39let appId: string;
40
41class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
42 readonly status: number;
43 readonly body: string;
44 readonly message: string;
45}> {}
46
47// Retry transient 5xx from the fixture (cold re-init under parallel-suite
48// load); genuine 4xx surfaces immediately.
49const send = (request: HttpClientRequest.HttpClientRequest) =>
50 HttpClient.execute(request).pipe(
51 Effect.flatMap((response) =>
52 response.status >= 500
53 ? response.text.pipe(
54 Effect.flatMap((body) =>
55 Effect.fail(
56 new TransientUpstream({
57 status: response.status,
58 body,
59 message: `fixture returned ${response.status}: ${body}`,
60 }),
61 ),
62 ),
63 )
64 : Effect.succeed(response),
65 ),
66 Effect.retry({
67 while: (e) => e._tag === "TransientUpstream",
68 schedule: Schedule.max([
69 Schedule.exponential("500 millis"),
70 Schedule.recurs(6),
71 ]),
72 }),
73 );
74
75const postJson = (path: string, body: unknown) =>
76 send(
77 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
78 HttpClientRequest.bodyJsonUnsafe(body),
79 ),
80 ).pipe(Effect.flatMap((response) => response.json));
81
82const getJson = (path: string) =>
83 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
84 Effect.flatMap((response) => response.json),
85 );
86
87const deploySite = (branchName: string) =>
88 Effect.gen(function* () {
89 const staged = (yield* postJson("/deployments", { branchName })) as {
90 jobId: string;
91 zipUploadUrl: string;
92 };
93 expect(staged.jobId).toBeTruthy();
94 expect(staged.zipUploadUrl).toContain("https://");
95
96 const zipBytes = yield* Effect.sync(() =>
97 Uint8Array.from(Buffer.from(SITE_ZIP_BASE64, "base64")),
98 );
99 const upload = yield* HttpClient.execute(
100 HttpClientRequest.put(staged.zipUploadUrl).pipe(
101 HttpClientRequest.bodyUint8Array(zipBytes, "application/zip"),
102 ),
103 );
104 expect(upload.status).toBe(200);
105
106 const released = (yield* postJson("/deployments/start", {
107 branchName,
108 jobId: staged.jobId,
109 })) as { jobSummary: { jobId: string; status: string } };
110 expect(released.jobSummary.jobId).toBe(staged.jobId);
111
112 const settled = yield* getJson(
113 `/job?branchName=${branchName}&jobId=${staged.jobId}`,
114 ).pipe(
115 Effect.map((body) => body as { status: string; stepCount: number }),
116 Effect.repeat({
117 schedule: Schedule.spaced("5 seconds"),
118 until: (job): boolean =>
119 job.status === "SUCCEED" ||
120 job.status === "FAILED" ||
121 job.status === "CANCELLED",
122 times: 10,
123 }),
124 );
125 expect(settled.status).toBe("SUCCEED");
126
127 return { ...staged, ...settled };
128 });
129
130describe.sequential(
131 "Amplify Bindings",
132 {
133 tags: [
134 "provider:aws",
135 "provider:aws:amplify",
136 "provider:aws:lambda",
137 "live",
138 ],
139 },
140 () => {
141 beforeAll(
142 Effect.gen(function* () {
143 yield* sharedStack.destroy();
144
145 const { functionUrl } = yield* sharedStack.deploy(
146 Effect.gen(function* () {
147 return yield* AmplifyTestFunction;
148 }).pipe(Effect.provide(AmplifyTestFunctionLive)),
149 );
150
151 expect(functionUrl).toBeTruthy();
152 baseUrl = functionUrl!.replace(/\/+$/, "");
153
154 yield* HttpClient.get(`${baseUrl}/ping`).pipe(
155 Effect.flatMap((response) =>
156 response.status === 200
157 ? Effect.succeed(response)
158 : Effect.fail(
159 new Error(`Function not ready: ${response.status}`),
160 ),
161 ),
162 Effect.retry({ schedule: readinessPolicy }),
163 );
164
165 const meta = (yield* getJson("/meta")) as { appId: string };
166 appId = meta.appId;
167 expect(appId).toBeTruthy();
168
169 // Manual-deploy branch (no repo needed) for the deployment bindings.
170 // The fixture app is freshly created above, so the branch never
171 // pre-exists; app deletion on destroy removes it.
172 // `beforeAll` doesn't run inside `test.provider`'s environment, so
173 // provide the AWS providers (Credentials/Region) explicitly for the
174 // out-of-band distilled call.
175 yield* Core.withProviders(
176 amplify.createBranch({
177 appId,
178 branchName,
179 enableAutoBuild: false,
180 }),
181 testOptions,
182 "AmplifyBindings",
183 );
184 }),
185 { timeout: 240_000 },
186 );
187
188 afterAll(sharedStack.destroy(), { timeout: 120_000 });
189
190 test(
191 "manual deployment lifecycle through the bindings",
192 Effect.gen(function* () {
193 // CreateDeployment, StartDeployment, and GetJob — stage the site,
194 // upload it through the pre-signed URL, release it, and wait for the
195 // deployment to settle.
196 const deployed = yield* deploySite(branchName);
197
198 // ListJobs — the deployment shows up in the branch's job history.
199 const jobs = (yield* getJson(`/jobs?branchName=${branchName}`)) as {
200 jobSummaries: Array<{ jobId: string }>;
201 };
202 expect(jobs.jobSummaries.map((j) => j.jobId)).toContain(deployed.jobId);
203
204 // ListArtifacts — manual deploys typically produce none; the call
205 // itself (auth + wiring) is what's under test.
206 const artifacts = (yield* getJson(
207 `/artifacts?branchName=${branchName}&jobId=${deployed.jobId}`,
208 )) as {
209 artifacts: Array<{ artifactId: string; artifactFileName: string }>;
210 };
211 expect(Array.isArray(artifacts.artifacts)).toBe(true);
212
213 // GetArtifactUrl — only exercisable when the job produced artifacts.
214 if (artifacts.artifacts.length > 0) {
215 const artifactUrl = (yield* getJson(
216 `/artifact-url?artifactId=${artifacts.artifacts[0].artifactId}`,
217 )) as { artifactUrl: string };
218 expect(artifactUrl.artifactUrl).toContain("https://");
219 }
220
221 // StopJob — the job already settled, so the service rejects the stop
222 // with a typed tag; either outcome proves the binding + IAM wiring.
223 const stopped = (yield* postJson("/jobs/stop", {
224 branchName,
225 jobId: deployed.jobId,
226 })) as { stopped: boolean; errorTag?: string };
227 if (!stopped.stopped) {
228 expect(stopped.errorTag).toBe("BadRequestException");
229 }
230
231 // DeleteJob correctly rejects the active hosted deployment with a typed
232 // BadRequestException. Deploy a successor, then prune the old job.
233 const activeDelete = (yield* postJson("/jobs/delete", {
234 branchName,
235 jobId: deployed.jobId,
236 })) as { deleted: boolean; errorTag?: string; message?: string };
237 expect(activeDelete.deleted).toBe(false);
238 expect(activeDelete.errorTag).toBe("BadRequestException");
239 expect(activeDelete.message).toContain("active job");
240
241 const successor = yield* deploySite(branchName);
242 expect(successor.jobId).not.toBe(deployed.jobId);
243
244 // Amplify marks the superseded job deletable eventually — the successor
245 // can settle while the old job still reads "active" for a beat, so
246 // retry the delete until it lands (bounded).
247 const deleted = (yield* postJson("/jobs/delete", {
248 branchName,
249 jobId: deployed.jobId,
250 }).pipe(
251 Effect.repeat({
252 schedule: Schedule.spaced("3 seconds"),
253 until: (r) => (r as { deleted: boolean }).deleted,
254 times: 10,
255 }),
256 )) as { deleted: boolean; errorTag?: string };
257 expect(deleted.deleted).toBe(true);
258
259 // Out-of-band: the job is gone from the branch history.
260 const remaining = yield* Core.withProviders(
261 amplify.listJobs({ appId, branchName }),
262 testOptions,
263 "AmplifyBindings",
264 );
265 expect(remaining.jobSummaries.map((j) => j.jobId)).not.toContain(
266 deployed.jobId,
267 );
268 }),
269 { timeout: 120_000, retry: 0 },
270 );
271
272 test(
273 "startJob surfaces the typed rejection for a repo-less branch",
274 Effect.gen(function* () {
275 // RELEASE jobs need a connected Git repository; a manual-deploy branch
276 // rejects them with BadRequestException. Reaching that typed tag proves
277 // the binding executed against the service with the granted IAM.
278 const result = (yield* postJson("/jobs/start", {
279 branchName,
280 jobType: "RELEASE",
281 })) as { started: boolean; errorTag?: string };
282 expect(result.started).toBe(false);
283 expect(result.errorTag).toBe("BadRequestException");
284 }),
285 { timeout: 60_000 },
286 );
287
288 test(
289 "consumeDeploymentStatusChanges creates the EventBridge subscription",
290 Effect.gen(function* () {
291 // Deploy-time proof: the event source registered a rule on the default
292 // bus matching Amplify deployment status changes. (Actual delivery is
293 // recorded best-effort on the /events route — a different Lambda
294 // instance may serve it, so delivery is not hard-asserted here.)
295 const rules = yield* Core.withProviders(
296 eventbridge
297 .listRules({ NamePrefix: "AmplifyBindings" })
298 .pipe(Effect.map((page) => page.Rules ?? [])),
299 testOptions,
300 "AmplifyBindings",
301 );
302 const rule = rules.find(
303 (r): boolean =>
304 typeof r.EventPattern === "string" &&
305 r.EventPattern.includes('"aws.amplify"'),
306 );
307 expect(rule).toBeDefined();
308 expect(rule!.EventPattern).toContain(
309 "Amplify Deployment Status Change",
310 );
311 }),
312 { tags: ["provider:aws:eventbridge"], timeout: 60_000 },
313 );
314
315 test(
316 "generateAccessLogs returns a pre-signed log URL for the default domain",
317 Effect.gen(function* () {
318 const meta = (yield* getJson("/meta")) as { defaultDomain: string };
319 const result = (yield* postJson("/access-logs", {
320 domainName: meta.defaultDomain,
321 })) as {
322 ok: boolean;
323 logUrl?: string;
324 errorTag?: string;
325 message?: string;
326 cause?: string;
327 };
328 if (result.ok) {
329 expect(result.logUrl).toContain("https://");
330 } else if (result.errorTag === "UnexpectedCause") {
331 return yield* Effect.fail(
332 new Error(result.cause ?? "GenerateAccessLogs failed unexpectedly"),
333 );
334 } else {
335 // Some accounts gate access logs on the default domain — the typed
336 // tag still proves the binding + IAM wiring.
337 expect(["BadRequestException", "NotFoundException"]).toContain(
338 result.errorTag,
339 );
340 }
341 }),
342 { timeout: 60_000, retry: 0 },
343 );
344 },
345);