| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as amplify from "@distilled.cloud/aws/amplify"; |
| 5 | import * as eventbridge from "@distilled.cloud/aws/eventbridge"; |
| 6 | import { describe, expect } from "alchemy-test"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 12 | import AmplifyTestFunctionLive, { |
| 13 | AmplifyTestFunction, |
| 14 | } from "./fixtures/handler"; |
| 15 | import { makeAmplifyTestLease } from "./TestLease.ts"; |
| 16 | |
| 17 | const testOptions = { providers: AWS.providers() }; |
| 18 | const { test, beforeAll, afterAll } = Test.make(testOptions); |
| 19 | const sharedStack = Core.scratchStack(testOptions, "AmplifyBindings"); |
| 20 | const testLease = makeAmplifyTestLease(); |
| 21 | |
| 22 | beforeAll(testLease.acquire, { timeout: 240_000 }); |
| 23 | afterAll(testLease.release); |
| 24 | |
| 25 | const 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. |
| 30 | const SITE_ZIP_BASE64 = |
| 31 | "UEsDBBQAAAAAAAAAIVwZ1jYHJwAAACcAAAAKAAAAaW5kZXguaHRtbDxodG1sPjxib2R5PmhlbGxvIGFtcGxpZnk8L2JvZHk+PC9odG1sPlBLAQIUAxQAAAAAAAAAIVwZ1jYHJwAAACcAAAAKAAAAAAAAAAAAAACAAQAAAABpbmRleC5odG1sUEsFBgAAAAABAAEAOAAAAE8AAAAAAA=="; |
| 32 | |
| 33 | const readinessPolicy = Schedule.max([ |
| 34 | Schedule.fixed("2 seconds"), |
| 35 | Schedule.recurs(10), |
| 36 | ]); |
| 37 | |
| 38 | let baseUrl: string; |
| 39 | let appId: string; |
| 40 | |
| 41 | class 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. |
| 49 | const 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 | |
| 75 | const 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 | |
| 82 | const getJson = (path: string) => |
| 83 | send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe( |
| 84 | Effect.flatMap((response) => response.json), |
| 85 | ); |
| 86 | |
| 87 | const 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 | |
| 130 | describe.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 | ); |