1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as cloudfront from "@distilled.cloud/aws/cloudfront";
5import * as kvs from "@distilled.cloud/aws/cloudfront-keyvaluestore";
6import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientRequest from "effect/http/HttpClientRequest";
13import CloudFrontTestFunctionLive, {
14 CloudFrontTestFunction,
15} from "./handler.ts";
16import CloudFrontKvsTestFunctionLive, {
17 CloudFrontKvsTestFunction,
18} from "./kvs-handler.ts";
19
20// The fixture deploys a CloudFront Distribution, which takes 3-10 minutes to
21// reach `Deployed` — gate the live run per the catalog's slow-test guidance.
22const runLive = !process.env.FAST;
23
24const testOptions = { providers: AWS.providers() };
25const { test, beforeAll, afterAll } = Test.make(testOptions);
26const sharedStack = Core.scratchStack(testOptions, "CloudFrontBindings");
27
28let baseUrl: string;
29
30// Resolve the fixture's distribution out-of-band by its deterministic
31// comment (runtime Output resolution inside the Lambda is not available for
32// resources declared in the fixture, so the test looks the id up itself).
33// Runs inside the test body — `beforeAll` has no distilled credentials
34// context. `listDistributions` is eventually consistent for a
35// freshly-created distribution, so retry the lookup for up to ~60s.
36const findFixtureDistributionId = Effect.gen(function* () {
37 const response = yield* cloudfront.listDistributions({});
38 const match = (response.DistributionList?.Items ?? []).find(
39 // Comment is a sensitive field — distilled decodes it as Redacted.
40 (item) =>
41 (typeof item.Comment === "string"
42 ? item.Comment
43 : Redacted.value(item.Comment)) === "alchemy-cf-bindings-fixture",
44 );
45 if (!match) {
46 return yield* Effect.fail(
47 new Error("fixture distribution not found by comment"),
48 );
49 }
50 return match.Id;
51}).pipe(
52 Effect.retry({
53 while: (e) => e instanceof Error,
54 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(30)]),
55 }),
56);
57
58describe(
59 "CloudFront Bindings",
60 {
61 tags: [
62 "provider:aws",
63 "provider:aws:cloudfront",
64 "provider:aws:lambda",
65 "provider:aws:s3",
66 "live",
67 ],
68 },
69 () => {
70 beforeAll(
71 runLive
72 ? Effect.gen(function* () {
73 yield* Effect.logInfo("CloudFront bindings: destroying previous");
74 yield* sharedStack.destroy();
75
76 yield* Effect.logInfo(
77 "CloudFront bindings: deploying fixture (distribution deploy is slow)",
78 );
79 const { functionUrl } = yield* sharedStack.deploy(
80 Effect.gen(function* () {
81 return yield* CloudFrontTestFunction;
82 }).pipe(Effect.provide(CloudFrontTestFunctionLive)),
83 );
84
85 expect(functionUrl).toBeTruthy();
86 baseUrl = functionUrl!.replace(/\/+$/, "");
87
88 // Fresh function URLs take a few seconds to start serving.
89 yield* HttpClient.get(`${baseUrl}/health`).pipe(
90 Effect.flatMap((response) =>
91 response.status === 200
92 ? Effect.void
93 : Effect.fail(
94 new Error(`Function not ready: ${response.status}`),
95 ),
96 ),
97 Effect.retry({
98 while: (e) => e instanceof Error,
99 schedule: Schedule.max([
100 Schedule.fixed("2 seconds"),
101 Schedule.recurs(60),
102 ]),
103 }),
104 );
105 })
106 : Effect.void,
107 { timeout: 900_000 },
108 );
109 afterAll(runLive ? sharedStack.destroy() : Effect.void, {
110 timeout: 900_000,
111 });
112
113 describe("CreateInvalidation", () => {
114 test.provider.skipIf(!runLive)(
115 "creates an invalidation for the bound distribution at runtime",
116 (_stack) =>
117 Effect.gen(function* () {
118 const response = yield* HttpClient.execute(
119 HttpClientRequest.post(`${baseUrl}/invalidate`).pipe(
120 HttpClientRequest.bodyJsonUnsafe({
121 callerReference: "alchemy-test-invalidation-1",
122 paths: ["/index.html"],
123 }),
124 ),
125 );
126 expect(response.status).toBe(200);
127 const body = (yield* response.json) as {
128 invalidationId: string;
129 status: string;
130 };
131 expect(body.invalidationId).toBeTruthy();
132 expect(["InProgress", "Completed"]).toContain(body.status);
133
134 // Out-of-band: the invalidation exists on the distribution.
135 const distributionId = yield* findFixtureDistributionId;
136 const observed = yield* cloudfront.getInvalidation({
137 DistributionId: distributionId,
138 Id: body.invalidationId,
139 });
140 expect(observed.Invalidation?.Id).toBe(body.invalidationId);
141 }),
142 { timeout: 120_000 },
143 );
144 });
145
146 describe("GetInvalidation + ListInvalidations", () => {
147 test.provider.skipIf(!runLive)(
148 "reads and lists invalidations for the bound distribution at runtime",
149 (_stack) =>
150 Effect.gen(function* () {
151 // Create one via the runtime binding first.
152 const created = yield* HttpClient.execute(
153 HttpClientRequest.post(`${baseUrl}/invalidate`).pipe(
154 HttpClientRequest.bodyJsonUnsafe({
155 callerReference: "alchemy-test-invalidation-2",
156 paths: ["/about.html"],
157 }),
158 ),
159 );
160 expect(created.status).toBe(200);
161 const { invalidationId } = (yield* created.json) as {
162 invalidationId: string;
163 };
164
165 // GetInvalidation reads its status through the binding.
166 const got = yield* HttpClient.get(
167 `${baseUrl}/invalidation?id=${invalidationId}`,
168 );
169 expect(got.status).toBe(200);
170 const gotBody = (yield* got.json) as {
171 invalidationId: string;
172 status: string;
173 };
174 expect(gotBody.invalidationId).toBe(invalidationId);
175 expect(["InProgress", "Completed"]).toContain(gotBody.status);
176
177 // ListInvalidations includes it.
178 const listed = yield* HttpClient.get(`${baseUrl}/invalidations`);
179 expect(listed.status).toBe(200);
180 const listedBody = (yield* listed.json) as {
181 invalidationIds: string[];
182 };
183 expect(listedBody.invalidationIds).toContain(invalidationId);
184 }),
185 { timeout: 120_000 },
186 );
187 });
188 },
189);
190
191// The KVS fixture only needs a KeyValueStore + Lambda (no distribution), so
192// it deploys in well under a minute and runs ungated.
193const kvsStack = Core.scratchStack(testOptions, "CloudFrontKvsBindings");
194
195let kvsBaseUrl: string;
196
197// Resolve the fixture's store ARN through the deployed function's
198// DescribeKeyValueStore binding (the response carries `KvsARN`), avoiding a
199// paginated account-wide list lookup.
200const findFixtureStoreArn = Effect.suspend(() =>
201 // `kvsBaseUrl` is assigned in `beforeAll`, so build the request lazily.
202 HttpClient.get(`${kvsBaseUrl}/describe`),
203).pipe(
204 Effect.flatMap((response) => response.json),
205 Effect.map((body) => (body as { kvsArn: string }).kvsArn),
206);
207
208describe(
209 "CloudFront KeyValueStore Bindings",
210 {
211 tags: [
212 "provider:aws",
213 "provider:aws:cloudfront",
214 "provider:aws:lambda",
215 "live",
216 ],
217 },
218 () => {
219 beforeAll(
220 Effect.gen(function* () {
221 yield* kvsStack.destroy();
222
223 const { functionUrl } = yield* kvsStack.deploy(
224 Effect.gen(function* () {
225 return yield* CloudFrontKvsTestFunction;
226 }).pipe(Effect.provide(CloudFrontKvsTestFunctionLive)),
227 );
228
229 expect(functionUrl).toBeTruthy();
230 kvsBaseUrl = functionUrl!.replace(/\/+$/, "");
231
232 // Fresh function URLs take a few seconds to start serving, and the
233 // store's data plane can briefly 500 while the store finishes
234 // provisioning + the role propagates.
235 yield* HttpClient.get(`${kvsBaseUrl}/describe`).pipe(
236 Effect.flatMap((response) =>
237 response.status === 200
238 ? Effect.void
239 : response.text.pipe(
240 Effect.flatMap((body) =>
241 Effect.fail(
242 new Error(`KVS not ready: ${response.status}: ${body}`),
243 ),
244 ),
245 ),
246 ),
247 Effect.retry({
248 while: (e): boolean => e instanceof Error,
249 schedule: Schedule.max([
250 Schedule.fixed("2 seconds"),
251 Schedule.recurs(60),
252 ]),
253 }),
254 );
255 }),
256 { timeout: 300_000 },
257 );
258 afterAll(kvsStack.destroy(), { timeout: 300_000 });
259
260 describe("DescribeKeyValueStore", () => {
261 test.provider(
262 "reads the store's data-plane metadata at runtime",
263 (_stack) =>
264 Effect.gen(function* () {
265 const response = yield* HttpClient.get(`${kvsBaseUrl}/describe`);
266 expect(response.status).toBe(200);
267 const body = (yield* response.json) as {
268 etag: string;
269 itemCount: number;
270 };
271 expect(body.etag).toBeTruthy();
272 expect(body.itemCount).toBeGreaterThanOrEqual(0);
273 }),
274 { timeout: 60_000 },
275 );
276 });
277
278 describe("PutKey + GetKey + ListKeys", () => {
279 test.provider(
280 "puts, gets, and lists a key at runtime",
281 (_stack) =>
282 Effect.gen(function* () {
283 const put = yield* HttpClient.execute(
284 HttpClientRequest.post(`${kvsBaseUrl}/put`).pipe(
285 HttpClientRequest.bodyJsonUnsafe({
286 key: "routes:/about",
287 value: "/about.html",
288 }),
289 ),
290 );
291 expect(put.status).toBe(200);
292 const putBody = (yield* put.json) as { etag: string };
293 expect(putBody.etag).toBeTruthy();
294
295 const got = yield* HttpClient.get(
296 `${kvsBaseUrl}/key?key=${encodeURIComponent("routes:/about")}`,
297 );
298 expect(got.status).toBe(200);
299 const gotBody = (yield* got.json) as { key: string; value: string };
300 expect(gotBody.key).toBe("routes:/about");
301 expect(gotBody.value).toBe("/about.html");
302
303 const listed = yield* HttpClient.get(`${kvsBaseUrl}/keys`);
304 expect(listed.status).toBe(200);
305 const listedBody = (yield* listed.json) as {
306 keys: { key: string; value: string }[];
307 };
308 expect(listedBody.keys.map((k) => k.key)).toContain(
309 "routes:/about",
310 );
311
312 // Out-of-band: the key is visible through distilled directly.
313 // KVS data-plane calls only accept us-east-1 signatures (see
314 // `common.ts`), so pin the Region like the resource providers do.
315 const storeArn = yield* findFixtureStoreArn;
316 const observed = yield* kvs
317 .getKey({
318 KvsARN: storeArn,
319 Key: "routes:/about",
320 })
321 .pipe(
322 Effect.provideService(AwsRegion, Effect.succeed("us-east-1")),
323 );
324 expect(
325 typeof observed.Value === "string"
326 ? observed.Value
327 : Redacted.value(observed.Value),
328 ).toBe("/about.html");
329 }),
330 { timeout: 60_000 },
331 );
332 });
333
334 describe("UpdateKeys + DeleteKey", () => {
335 test.provider(
336 "batch-updates and deletes keys at runtime",
337 (_stack) =>
338 Effect.gen(function* () {
339 const updated = yield* HttpClient.execute(
340 HttpClientRequest.post(`${kvsBaseUrl}/update`).pipe(
341 HttpClientRequest.bodyJsonUnsafe({
342 puts: [
343 { key: "routes:/", value: "/index.html" },
344 { key: "routes:/tmp", value: "/tmp.html" },
345 ],
346 }),
347 ),
348 );
349 expect(updated.status).toBe(200);
350
351 const deleted = yield* HttpClient.execute(
352 HttpClientRequest.post(`${kvsBaseUrl}/delete`).pipe(
353 HttpClientRequest.bodyJsonUnsafe({ key: "routes:/tmp" }),
354 ),
355 );
356 expect(deleted.status).toBe(200);
357
358 const listed = yield* HttpClient.get(`${kvsBaseUrl}/keys`);
359 expect(listed.status).toBe(200);
360 const listedBody = (yield* listed.json) as {
361 keys: { key: string; value: string }[];
362 };
363 const keys = listedBody.keys.map((k) => k.key);
364 expect(keys).toContain("routes:/");
365 expect(keys).not.toContain("routes:/tmp");
366 }),
367 { timeout: 60_000 },
368 );
369 });
370 },
371);