1import * as AWS from "@/AWS";
2import { Distribution, OriginAccessControl } from "@/AWS/CloudFront";
3import type { PolicyStatement } from "@/AWS/IAM/Policy";
4import { Bucket } from "@/AWS/S3";
5import * as Output from "@/Output";
6import * as Provider from "@/Provider";
7import * as Test from "@/Test/Alchemy";
8import * as cloudfront from "@distilled.cloud/aws/cloudfront";
9import * as S3 from "@distilled.cloud/aws/s3";
10import { describe, expect } from "alchemy-test";
11import type * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Redacted from "effect/Redacted";
14import * as Schedule from "effect/Schedule";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18const runLive = !process.env.FAST;
19
20describe(
21 "AWS.CloudFront.Distribution",
22 { tags: ["provider:aws", "provider:aws:cloudfront", "live"] },
23 () => {
24 test.provider.skipIf(!runLive)(
25 "create and delete distribution for a private S3 origin",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29
30 const deployed = yield* stack.deploy(
31 Effect.gen(function* () {
32 const bucket = yield* Bucket("WebsiteBucket", {
33 forceDestroy: true,
34 });
35 const oac = yield* OriginAccessControl("WebsiteOac", {
36 originType: "s3",
37 });
38 const distribution = yield* Distribution("WebsiteDistribution", {
39 origins: [
40 {
41 id: "site",
42 domainName: bucket.bucketRegionalDomainName,
43 s3Origin: true,
44 originAccessControlId: oac.originAccessControlId,
45 // Duration.Input → wire whole-seconds conversion under test.
46 connectionTimeout: "5 seconds",
47 },
48 ],
49 defaultRootObject: "index.html",
50 defaultCacheBehavior: {
51 targetOriginId: "site",
52 viewerProtocolPolicy: "redirect-to-https",
53 compress: true,
54 allowedMethods: ["GET", "HEAD"],
55 cachedMethods: ["GET", "HEAD"],
56 forwardedValues: {
57 QueryString: false,
58 Cookies: {
59 Forward: "none",
60 },
61 },
62 // Duration.Input → wire whole-seconds conversion under test.
63 minTtl: 0,
64 defaultTtl: "5 minutes",
65 maxTtl: "1 hour",
66 },
67 });
68
69 const statement: PolicyStatement = {
70 Effect: "Allow",
71 Principal: {
72 Service: "cloudfront.amazonaws.com",
73 },
74 Action: ["s3:GetObject"],
75 Resource: [Output.interpolate`${bucket.bucketArn}/*` as any],
76 Condition: {
77 StringEquals: {
78 "AWS:SourceArn": distribution.distributionArn as any,
79 },
80 },
81 };
82
83 yield* bucket.bind`Allow(${distribution}, CloudFront.Read(${bucket}))`(
84 {
85 policyStatements: [statement],
86 },
87 );
88
89 return {
90 bucket,
91 oac,
92 distribution,
93 };
94 }),
95 );
96
97 const current = yield* cloudfront.getDistribution({
98 Id: deployed.distribution.distributionId,
99 });
100 expect(current.Distribution?.Status).toEqual("Deployed");
101 expect(current.Distribution?.DomainName).toEqual(
102 deployed.distribution.domainName,
103 );
104 // Duration.Input props reached the wire as whole seconds.
105 const config = current.Distribution?.DistributionConfig;
106 expect(config?.Origins?.Items?.[0]?.ConnectionTimeout).toEqual(5);
107 expect(config?.DefaultCacheBehavior?.MinTTL).toEqual(0);
108 expect(config?.DefaultCacheBehavior?.DefaultTTL).toEqual(300);
109 expect(config?.DefaultCacheBehavior?.MaxTTL).toEqual(3600);
110
111 const control = yield* cloudfront.getOriginAccessControl({
112 Id: deployed.oac.originAccessControlId,
113 });
114 expect(control.OriginAccessControl?.Id).toEqual(
115 deployed.oac.originAccessControlId,
116 );
117
118 yield* S3.putObject({
119 Bucket: deployed.bucket.bucketName,
120 Key: "index.html",
121 Body: "<html>ok</html>",
122 ContentType: "text/html; charset=utf-8",
123 });
124
125 yield* stack.destroy();
126 yield* assertDistributionDeleted(
127 deployed.distribution.distributionId,
128 );
129 }),
130 { tags: ["provider:aws:iam", "provider:aws:s3"], timeout: 600_000 },
131 );
132
133 // Fast probe (read-only): CloudFront `list()` exhaustively paginates
134 // `listDistributions` and resolves each summary to the full `read`-shaped
135 // Attributes. This runs without deploying anything (distribution create +
136 // disable-before-delete exceeds CI budget — see the gated full test below),
137 // so it verifies the live list op cheaply.
138 test.provider(
139 "list enumerates account distributions",
140 () =>
141 Effect.gen(function* () {
142 const provider = yield* Provider.findProvider(Distribution);
143 const all = yield* provider.list();
144
145 expect(Array.isArray(all)).toBe(true);
146 for (const item of all) {
147 expect(item.distributionId).toBeDefined();
148 expect(item.distributionArn).toBeDefined();
149 expect(item.domainName).toBeDefined();
150 }
151 }),
152 // Hydrating every distribution in a busy account (3 read calls each)
153 // under CloudFront's aggressive read throttle takes a while even with
154 // the provider's bounded retry — give it ample headroom.
155 { timeout: 300_000 },
156 );
157
158 // Full lifecycle: deploy a real distribution, assert it shows up in the
159 // enumerated result, then tear it down. Gated behind the same slow-test env
160 // flag as the create/delete case because distribution provisioning +
161 // disable-before-delete can take many minutes.
162 test.provider.skipIf(!runLive)(
163 "list includes a freshly deployed distribution",
164 (stack) =>
165 Effect.gen(function* () {
166 yield* stack.destroy();
167
168 const deployed = yield* stack.deploy(
169 Effect.gen(function* () {
170 const bucket = yield* Bucket("ListWebsiteBucket", {
171 forceDestroy: true,
172 });
173 const oac = yield* OriginAccessControl("ListWebsiteOac", {
174 originType: "s3",
175 });
176 return yield* Distribution("ListWebsiteDistribution", {
177 origins: [
178 {
179 id: "site",
180 domainName: bucket.bucketRegionalDomainName,
181 s3Origin: true,
182 originAccessControlId: oac.originAccessControlId,
183 },
184 ],
185 defaultCacheBehavior: {
186 targetOriginId: "site",
187 viewerProtocolPolicy: "redirect-to-https",
188 compress: true,
189 },
190 });
191 }),
192 );
193
194 const provider = yield* Provider.findProvider(Distribution);
195 const all = yield* provider.list();
196
197 expect(
198 all.some((d) => d.distributionId === deployed.distributionId),
199 ).toBe(true);
200
201 yield* stack.destroy();
202 yield* assertDistributionDeleted(deployed.distributionId);
203 }),
204 { tags: ["provider:aws:s3"], timeout: 600_000 },
205 );
206 // Exercises the newly-exposed config gaps: geo restriction + custom error
207 // responses. Creates with a whitelist + a custom 404, updates the geo
208 // restriction to `none`, and asserts both round-trip via getDistributionConfig.
209 test.provider.skipIf(!runLive)(
210 "geo restriction and custom error responses round-trip",
211 (stack) =>
212 Effect.gen(function* () {
213 yield* stack.destroy();
214
215 const deployed = yield* stack.deploy(
216 Effect.gen(function* () {
217 const bucket = yield* Bucket("GeoBucket", { forceDestroy: true });
218 const oac = yield* OriginAccessControl("GeoOac", {
219 originType: "s3",
220 });
221 const distribution = yield* Distribution("GeoDistribution", {
222 origins: [
223 {
224 id: "site",
225 domainName: bucket.bucketRegionalDomainName,
226 s3Origin: true,
227 originAccessControlId: oac.originAccessControlId,
228 },
229 ],
230 defaultCacheBehavior: {
231 targetOriginId: "site",
232 viewerProtocolPolicy: "redirect-to-https",
233 compress: true,
234 },
235 geoRestriction: {
236 restrictionType: "whitelist",
237 locations: ["US", "CA"],
238 },
239 customErrorResponses: [
240 {
241 ErrorCode: 404,
242 ResponseCode: "404",
243 ResponsePagePath: "/404.html",
244 ErrorCachingMinTTL: 10,
245 },
246 ],
247 });
248 return { distribution };
249 }),
250 );
251
252 const created = yield* cloudfront.getDistributionConfig({
253 Id: deployed.distribution.distributionId,
254 });
255 expect(
256 created.DistributionConfig?.Restrictions?.GeoRestriction
257 .RestrictionType,
258 ).toEqual("whitelist");
259 expect(
260 created.DistributionConfig?.Restrictions?.GeoRestriction.Items?.sort(),
261 ).toEqual(["CA", "US"]);
262 expect(
263 created.DistributionConfig?.CustomErrorResponses?.Items?.[0]
264 .ErrorCode,
265 ).toEqual(404);
266
267 // Update: drop the geo restriction.
268 yield* stack.deploy(
269 Effect.gen(function* () {
270 const bucket = yield* Bucket("GeoBucket", { forceDestroy: true });
271 const oac = yield* OriginAccessControl("GeoOac", {
272 originType: "s3",
273 });
274 return yield* Distribution("GeoDistribution", {
275 origins: [
276 {
277 id: "site",
278 domainName: bucket.bucketRegionalDomainName,
279 s3Origin: true,
280 originAccessControlId: oac.originAccessControlId,
281 },
282 ],
283 defaultCacheBehavior: {
284 targetOriginId: "site",
285 viewerProtocolPolicy: "redirect-to-https",
286 compress: true,
287 },
288 geoRestriction: { restrictionType: "none" },
289 });
290 }),
291 );
292
293 const updated = yield* cloudfront.getDistributionConfig({
294 Id: deployed.distribution.distributionId,
295 });
296 expect(
297 updated.DistributionConfig?.Restrictions?.GeoRestriction
298 .RestrictionType,
299 ).toEqual("none");
300
301 yield* stack.destroy();
302 yield* assertDistributionDeleted(
303 deployed.distribution.distributionId,
304 );
305 }),
306 { tags: ["provider:aws:s3"], timeout: 600_000 },
307 );
308
309 // Regression for the UpdateDistribution full-config-replacement failure:
310 // members the props don't express (DefaultRootObject, per-origin
311 // CustomHeaders) are set on the live distribution out-of-band, then an
312 // alchemy update is deployed. Before `mergeWithObservedConfig`, the update
313 // request omitted those members and CloudFront rejected it with
314 // `IllegalUpdate` ("Default root object is missing for the resource",
315 // "The 'OriginCustomHeaders' field is missing"). Now they are carried over
316 // from the observed config while the declared props still win.
317 test.provider.skipIf(!runLive)(
318 "update preserves config members set out-of-band",
319 (stack) =>
320 Effect.gen(function* () {
321 yield* stack.destroy();
322
323 // A plain custom origin keeps the fixture to a single resource — the
324 // regression under test lives entirely in the distribution's config
325 // update, so no bucket/OAC is needed.
326 const program = (defaultTtl: Duration.Input) =>
327 Effect.gen(function* () {
328 return yield* Distribution("MergeDistribution", {
329 origins: [
330 {
331 id: "site",
332 domainName: "example.com",
333 },
334 ],
335 defaultCacheBehavior: {
336 targetOriginId: "site",
337 viewerProtocolPolicy: "redirect-to-https",
338 compress: true,
339 forwardedValues: {
340 QueryString: false,
341 Cookies: {
342 Forward: "none",
343 },
344 },
345 minTtl: 0,
346 defaultTtl,
347 maxTtl: "1 hour",
348 },
349 });
350 });
351
352 const deployed = yield* stack.deploy(program("5 minutes"));
353
354 // Out-of-band read-modify-write: enrich the live config with members
355 // the props above don't express.
356 const live = yield* cloudfront.getDistributionConfig({
357 Id: deployed.distributionId,
358 });
359 const liveConfig = live.DistributionConfig!;
360 yield* cloudfront.updateDistribution({
361 Id: deployed.distributionId,
362 IfMatch: live.ETag,
363 DistributionConfig: {
364 ...liveConfig,
365 DefaultRootObject: "index.html",
366 Origins: {
367 ...liveConfig.Origins,
368 Items: liveConfig.Origins.Items.map((origin) => ({
369 ...origin,
370 CustomHeaders: {
371 Quantity: 1,
372 Items: [
373 { HeaderName: "x-out-of-band", HeaderValue: "kept" },
374 ],
375 },
376 })),
377 },
378 },
379 });
380
381 // Update via alchemy with a changed prop. The props still don't
382 // express the members set above — pre-fix this deploy failed with
383 // `IllegalUpdate`.
384 yield* stack.deploy(program("10 minutes"));
385
386 const updated = yield* cloudfront.getDistributionConfig({
387 Id: deployed.distributionId,
388 });
389 const config = updated.DistributionConfig;
390 // Desired wins: the prop change went through.
391 expect(config?.DefaultCacheBehavior?.DefaultTTL).toEqual(600);
392 // Members the props don't express carried over from the observed
393 // config instead of being dropped (or rejected) by the update.
394 expect(config?.DefaultRootObject).toEqual("index.html");
395 const headers =
396 config?.Origins?.Items?.[0]?.CustomHeaders?.Items?.map(
397 (header) => ({
398 name: header.HeaderName,
399 value:
400 typeof header.HeaderValue === "string"
401 ? header.HeaderValue
402 : Redacted.value(header.HeaderValue),
403 }),
404 );
405 expect(headers).toEqual([{ name: "x-out-of-band", value: "kept" }]);
406
407 yield* stack.destroy();
408 yield* assertDistributionDeleted(deployed.distributionId);
409 }),
410 { timeout: 900_000 },
411 );
412 },
413);
414
415const assertDistributionDeleted = (distributionId: string) =>
416 cloudfront.getDistribution({ Id: distributionId }).pipe(
417 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
418 Effect.catchTag("NoSuchDistribution", () => Effect.void),
419 Effect.retry({
420 while: (error) =>
421 error instanceof Error && error.message === "DistributionStillExists",
422 schedule: Schedule.max([
423 Schedule.fixed("10 seconds"),
424 Schedule.recurs(60),
425 ]),
426 }),
427 );