1import { mergeWithObservedConfig } from "@/AWS/CloudFront/Distribution";
2import type * as cloudfront from "@distilled.cloud/aws/cloudfront";
3import { describe, expect, test } from "alchemy-test";
4
5/**
6 * A live distribution config as returned by `GetDistributionConfig` for a
7 * distribution CloudFront has normalized — every member present, including
8 * the ones `DistributionProps` cannot express (per-origin `CustomHeaders`,
9 * per-behavior `TrustedSigners`, `GrpcConfig`, …). `UpdateDistribution`
10 * rejects requests missing any of these with `IllegalUpdate`.
11 */
12const observed: cloudfront.DistributionConfig = {
13 CallerReference: "caller-ref",
14 Aliases: { Quantity: 0 },
15 DefaultRootObject: "",
16 Origins: {
17 Quantity: 1,
18 Items: [
19 {
20 Id: "s3",
21 DomainName: "bucket.s3.us-east-1.amazonaws.com",
22 OriginPath: "",
23 CustomHeaders: { Quantity: 0 },
24 S3OriginConfig: { OriginAccessIdentity: "", OriginReadTimeout: 30 },
25 ConnectionAttempts: 3,
26 ConnectionTimeout: 10,
27 OriginShield: { Enabled: false },
28 OriginAccessControlId: "OAC_OBSERVED",
29 },
30 ],
31 },
32 OriginGroups: { Quantity: 0 },
33 DefaultCacheBehavior: {
34 TargetOriginId: "s3",
35 TrustedSigners: { Enabled: false, Quantity: 0 },
36 TrustedKeyGroups: { Enabled: false, Quantity: 0 },
37 ViewerProtocolPolicy: "redirect-to-https",
38 AllowedMethods: {
39 Quantity: 2,
40 Items: ["HEAD", "GET"],
41 CachedMethods: { Quantity: 2, Items: ["HEAD", "GET"] },
42 },
43 SmoothStreaming: false,
44 Compress: true,
45 LambdaFunctionAssociations: { Quantity: 0 },
46 FunctionAssociations: { Quantity: 0 },
47 FieldLevelEncryptionId: "",
48 CachePolicyId: "cache-policy-id",
49 GrpcConfig: { Enabled: false },
50 },
51 CacheBehaviors: {
52 Quantity: 1,
53 Items: [
54 {
55 PathPattern: "*.webp",
56 TargetOriginId: "s3",
57 TrustedSigners: { Enabled: false, Quantity: 0 },
58 TrustedKeyGroups: { Enabled: false, Quantity: 0 },
59 ViewerProtocolPolicy: "redirect-to-https",
60 AllowedMethods: {
61 Quantity: 2,
62 Items: ["HEAD", "GET"],
63 CachedMethods: { Quantity: 2, Items: ["HEAD", "GET"] },
64 },
65 SmoothStreaming: false,
66 Compress: true,
67 LambdaFunctionAssociations: { Quantity: 0 },
68 FunctionAssociations: { Quantity: 0 },
69 FieldLevelEncryptionId: "",
70 CachePolicyId: "cache-policy-id",
71 GrpcConfig: { Enabled: false },
72 },
73 ],
74 },
75 CustomErrorResponses: { Quantity: 0 },
76 Comment: "",
77 Logging: { Enabled: false, IncludeCookies: false, Bucket: "", Prefix: "" },
78 PriceClass: "PriceClass_100",
79 Enabled: true,
80 ViewerCertificate: {
81 CloudFrontDefaultCertificate: true,
82 SSLSupportMethod: "vip",
83 MinimumProtocolVersion: "TLSv1",
84 },
85 Restrictions: { GeoRestriction: { RestrictionType: "none", Quantity: 0 } },
86 WebACLId: "",
87 HttpVersion: "http2",
88 IsIPV6Enabled: false,
89 ContinuousDeploymentPolicyId: "",
90 Staging: false,
91};
92
93/**
94 * The sparse config `toConfig` builds from props — only what the props
95 * express, everything else `undefined` (i.e. omitted from the request).
96 */
97const desired: cloudfront.DistributionConfig = {
98 CallerReference: "caller-ref",
99 Aliases: { Quantity: 0, Items: [] },
100 Origins: {
101 Quantity: 1,
102 Items: [
103 {
104 Id: "s3",
105 DomainName: "bucket.s3.us-east-1.amazonaws.com",
106 OriginAccessControlId: "OAC_DESIRED",
107 S3OriginConfig: { OriginAccessIdentity: "" },
108 },
109 ],
110 },
111 DefaultCacheBehavior: {
112 TargetOriginId: "s3",
113 ViewerProtocolPolicy: "redirect-to-https",
114 AllowedMethods: {
115 Quantity: 2,
116 Items: ["GET", "HEAD"],
117 CachedMethods: { Quantity: 2, Items: ["GET", "HEAD"] },
118 },
119 Compress: true,
120 CachePolicyId: "cache-policy-id",
121 FunctionAssociations: {
122 Quantity: 1,
123 Items: [
124 {
125 FunctionARN: "arn:aws:cloudfront::123:function/deny-all",
126 EventType: "viewer-request",
127 },
128 ],
129 },
130 },
131 CacheBehaviors: {
132 Quantity: 1,
133 Items: [
134 {
135 PathPattern: "*.webp",
136 TargetOriginId: "s3",
137 ViewerProtocolPolicy: "redirect-to-https",
138 AllowedMethods: {
139 Quantity: 2,
140 Items: ["GET", "HEAD"],
141 CachedMethods: { Quantity: 2, Items: ["GET", "HEAD"] },
142 },
143 Compress: true,
144 CachePolicyId: "cache-policy-id",
145 },
146 ],
147 },
148 Comment: "",
149 Enabled: false,
150 Restrictions: { GeoRestriction: { RestrictionType: "none", Quantity: 0 } },
151 IsIPV6Enabled: true,
152} as cloudfront.DistributionConfig;
153
154describe(
155 "mergeWithObservedConfig",
156 { tags: ["unit", "provider:aws", "provider:aws:cloudfront", "local"] },
157 () => {
158 const merged = mergeWithObservedConfig(desired, observed);
159
160 test("desired values win over observed ones", () => {
161 expect(merged.Enabled).toBe(false);
162 expect(merged.IsIPV6Enabled).toBe(true);
163 expect(merged.Origins?.Items?.[0]?.OriginAccessControlId).toBe(
164 "OAC_DESIRED",
165 );
166 expect(merged.DefaultCacheBehavior?.FunctionAssociations?.Quantity).toBe(
167 1,
168 );
169 });
170
171 test("top-level members the desired config omits are filled from observed", () => {
172 expect(merged.DefaultRootObject).toBe("");
173 expect(merged.WebACLId).toBe("");
174 expect(merged.ContinuousDeploymentPolicyId).toBe("");
175 expect(merged.Staging).toBe(false);
176 expect(merged.PriceClass).toBe("PriceClass_100");
177 expect(merged.Logging).toEqual(observed.Logging);
178 expect(merged.OriginGroups).toEqual({ Quantity: 0 });
179 expect(merged.CustomErrorResponses).toEqual({ Quantity: 0 });
180 });
181
182 test("nested members inside origin items are filled by Id", () => {
183 const origin = merged.Origins?.Items?.[0];
184 expect(origin?.CustomHeaders).toEqual({ Quantity: 0 });
185 expect(origin?.OriginPath).toBe("");
186 expect(origin?.ConnectionAttempts).toBe(3);
187 expect(origin?.ConnectionTimeout).toBe(10);
188 expect(origin?.OriginShield).toEqual({ Enabled: false });
189 expect(origin?.S3OriginConfig?.OriginReadTimeout).toBe(30);
190 });
191
192 test("nested members inside behaviors are filled (default + by PathPattern)", () => {
193 for (const behavior of [
194 merged.DefaultCacheBehavior,
195 merged.CacheBehaviors?.Items?.[0],
196 ]) {
197 expect(behavior?.TrustedSigners).toEqual({
198 Enabled: false,
199 Quantity: 0,
200 });
201 expect(behavior?.TrustedKeyGroups).toEqual({
202 Enabled: false,
203 Quantity: 0,
204 });
205 expect(behavior?.SmoothStreaming).toBe(false);
206 expect(behavior?.FieldLevelEncryptionId).toBe("");
207 expect(behavior?.GrpcConfig).toEqual({ Enabled: false });
208 expect(behavior?.LambdaFunctionAssociations).toEqual({ Quantity: 0 });
209 }
210 });
211
212 test("partial nested objects are completed member-wise, not replaced", () => {
213 // Desired sets CloudFrontDefaultCertificate only; the observed
214 // SSLSupportMethod / MinimumProtocolVersion carry over.
215 const mergedWithCert = mergeWithObservedConfig(
216 {
217 ...desired,
218 ViewerCertificate: { CloudFrontDefaultCertificate: true },
219 },
220 observed,
221 );
222 expect(mergedWithCert.ViewerCertificate).toEqual({
223 CloudFrontDefaultCertificate: true,
224 SSLSupportMethod: "vip",
225 MinimumProtocolVersion: "TLSv1",
226 });
227 });
228
229 test("desired arrays are never extended by observed items", () => {
230 // Observed has one origin; a desired config that removes it must win.
231 const withoutOrigins = mergeWithObservedConfig(
232 {
233 ...desired,
234 Origins: { Quantity: 0, Items: [] },
235 },
236 observed,
237 );
238 expect(withoutOrigins.Origins).toEqual({ Quantity: 0, Items: [] });
239 });
240
241 test("a Quantity-only desired list never resurrects observed Items", () => {
242 // Dropping a geo whitelist produces `{ RestrictionType: "none",
243 // Quantity: 0 }` with no `Items`; filling `Items` from the observed
244 // whitelist would desynchronize Quantity/Items and CloudFront rejects
245 // the update with `InconsistentQuantities`.
246 const withWhitelist = {
247 ...observed,
248 Restrictions: {
249 GeoRestriction: {
250 RestrictionType: "whitelist",
251 Quantity: 2,
252 Items: ["US", "GB"],
253 },
254 },
255 } as cloudfront.DistributionConfig;
256 const dropped = mergeWithObservedConfig(
257 {
258 ...desired,
259 Restrictions: {
260 GeoRestriction: { RestrictionType: "none", Quantity: 0 },
261 },
262 } as cloudfront.DistributionConfig,
263 withWhitelist,
264 );
265 expect(dropped.Restrictions).toEqual({
266 GeoRestriction: { RestrictionType: "none", Quantity: 0 },
267 });
268 });
269
270 test("an origin absent from observed is passed through unchanged", () => {
271 const withNewOrigin = mergeWithObservedConfig(
272 {
273 ...desired,
274 Origins: {
275 Quantity: 1,
276 Items: [
277 {
278 Id: "brand-new",
279 DomainName: "new.example.com",
280 } as cloudfront.Origin,
281 ],
282 },
283 },
284 observed,
285 );
286 expect(withNewOrigin.Origins?.Items?.[0]).toEqual({
287 Id: "brand-new",
288 DomainName: "new.example.com",
289 });
290 });
291 },
292);