1import * as AWS from "@/AWS";
2import { WebACL } from "@/AWS/WAFv2";
3import * as Test from "@/Test/Alchemy";
4import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
5import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
6import * as wafv2 from "@distilled.cloud/aws/wafv2";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14class WebACLStillExists extends Data.TaggedError("WebACLStillExists")<{
15 readonly name: string;
16}> {}
17
18const assertWebAclDeleted = (
19 name: string,
20 id: string,
21 scope: "REGIONAL" | "CLOUDFRONT",
22) =>
23 wafv2.getWebACL({ Name: name, Scope: scope, Id: id }).pipe(
24 Effect.flatMap(() => Effect.fail(new WebACLStillExists({ name }))),
25 Effect.catchTag("WAFNonexistentItemException", () => Effect.void),
26 Effect.retry({
27 while: (e) => e._tag === "WebACLStillExists",
28 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
29 }),
30 );
31
32const visibility = (metricName: string): WAFV2.VisibilityConfig => ({
33 SampledRequestsEnabled: true,
34 CloudWatchMetricsEnabled: true,
35 MetricName: metricName,
36});
37
38const BLOCKED_PATH = new TextEncoder().encode("/blocked");
39
40// Flagship rule set: a managed rule group, a rate-based rule and a custom
41// byte-match rule.
42const flagshipRules: WAFV2.Rule[] = [
43 {
44 Name: "managed-common",
45 Priority: 0,
46 Statement: {
47 ManagedRuleGroupStatement: {
48 VendorName: "AWS",
49 Name: "AWSManagedRulesCommonRuleSet",
50 },
51 },
52 OverrideAction: { None: {} },
53 VisibilityConfig: visibility("managed-common"),
54 },
55 {
56 Name: "rate-limit",
57 Priority: 1,
58 Statement: {
59 RateBasedStatement: { Limit: 100, AggregateKeyType: "IP" },
60 },
61 Action: { Block: {} },
62 VisibilityConfig: visibility("rate-limit"),
63 },
64 {
65 Name: "block-path",
66 Priority: 2,
67 Statement: {
68 ByteMatchStatement: {
69 SearchString: BLOCKED_PATH,
70 FieldToMatch: { UriPath: {} },
71 TextTransformations: [{ Priority: 0, Type: "LOWERCASE" }],
72 PositionalConstraint: "STARTS_WITH",
73 },
74 },
75 Action: { Block: {} },
76 VisibilityConfig: visibility("block-path"),
77 },
78];
79
80test.provider(
81 "create with managed + rate-based + byte-match rules, update, delete",
82 (stack) =>
83 Effect.gen(function* () {
84 // reconcile away any prior partial/crashed deployment
85 yield* stack.destroy();
86
87 const acl = yield* stack.deploy(
88 Effect.gen(function* () {
89 return yield* WebACL("LifecycleAcl", {
90 rules: flagshipRules,
91 tags: { Environment: "test" },
92 });
93 }),
94 );
95
96 expect(acl.scope).toBe("REGIONAL");
97 expect(acl.webAclArn).toContain(":wafv2:");
98 expect(acl.webAclArn).toContain("/webacl/");
99
100 // out-of-band verification via distilled
101 const created = yield* wafv2.getWebACL({
102 Name: acl.webAclName,
103 Scope: "REGIONAL",
104 Id: acl.webAclId,
105 });
106 expect(created.WebACL?.DefaultAction?.Allow).toBeDefined();
107 const ruleNames = (created.WebACL?.Rules ?? []).map((r) => r.Name);
108 expect(ruleNames).toEqual(["managed-common", "rate-limit", "block-path"]);
109 const managed = created.WebACL?.Rules?.find(
110 (r) => r.Name === "managed-common",
111 );
112 expect(managed?.Statement?.ManagedRuleGroupStatement?.Name).toBe(
113 "AWSManagedRulesCommonRuleSet",
114 );
115 const rate = created.WebACL?.Rules?.find((r) => r.Name === "rate-limit");
116 expect(rate?.Statement?.RateBasedStatement?.Limit).toBe(100);
117
118 const tags = yield* wafv2.listTagsForResource({
119 ResourceARN: acl.webAclArn,
120 });
121 const tagRecord = Object.fromEntries(
122 (tags.TagInfoForResource?.TagList ?? []).map((t) => [t.Key, t.Value]),
123 );
124 expect(tagRecord.Environment).toBe("test");
125 expect(tagRecord["alchemy::id"]).toBe("LifecycleAcl");
126
127 // update in place: block by default, drop to a single rule, retag
128 const updated = yield* stack.deploy(
129 Effect.gen(function* () {
130 return yield* WebACL("LifecycleAcl", {
131 defaultAction: { Block: {} },
132 description: "block by default",
133 rules: [flagshipRules[1]],
134 tags: { Environment: "test", Extra: "1" },
135 });
136 }),
137 );
138 expect(updated.webAclId).toBe(acl.webAclId);
139
140 const afterUpdate = yield* wafv2.getWebACL({
141 Name: acl.webAclName,
142 Scope: "REGIONAL",
143 Id: acl.webAclId,
144 });
145 expect(afterUpdate.WebACL?.DefaultAction?.Block).toBeDefined();
146 expect(afterUpdate.WebACL?.Description).toBe("block by default");
147 expect(afterUpdate.WebACL?.Rules?.length).toBe(1);
148 expect(afterUpdate.WebACL?.Rules?.[0]?.Name).toBe("rate-limit");
149
150 const updatedTags = yield* wafv2.listTagsForResource({
151 ResourceARN: acl.webAclArn,
152 });
153 const updatedRecord = Object.fromEntries(
154 (updatedTags.TagInfoForResource?.TagList ?? []).map((t) => [
155 t.Key,
156 t.Value,
157 ]),
158 );
159 expect(updatedRecord.Extra).toBe("1");
160
161 yield* stack.destroy();
162 yield* assertWebAclDeleted(acl.webAclName, acl.webAclId, "REGIONAL");
163 }),
164 { tags: ["provider:aws", "provider:aws:wafv2", "live"], timeout: 120_000 },
165);
166
167test.provider(
168 "explicit name change replaces the web ACL",
169 (stack) =>
170 Effect.gen(function* () {
171 // reconcile away any prior partial/crashed deployment
172 yield* stack.destroy();
173
174 const first = yield* stack.deploy(
175 Effect.gen(function* () {
176 return yield* WebACL("RenamedAcl", {
177 webAclName: "alchemy-test-wafv2-rename-a",
178 });
179 }),
180 );
181 expect(first.webAclName).toBe("alchemy-test-wafv2-rename-a");
182
183 const second = yield* stack.deploy(
184 Effect.gen(function* () {
185 return yield* WebACL("RenamedAcl", {
186 webAclName: "alchemy-test-wafv2-rename-b",
187 });
188 }),
189 );
190 expect(second.webAclName).toBe("alchemy-test-wafv2-rename-b");
191 expect(second.webAclId).not.toBe(first.webAclId);
192
193 // the old web ACL is gone after replacement
194 yield* assertWebAclDeleted(first.webAclName, first.webAclId, "REGIONAL");
195
196 yield* stack.destroy();
197 yield* assertWebAclDeleted(
198 second.webAclName,
199 second.webAclId,
200 "REGIONAL",
201 );
202 }),
203 { tags: ["provider:aws", "provider:aws:wafv2", "live"], timeout: 120_000 },
204);
205
206// CLOUDFRONT-scoped web ACLs are pinned to us-east-1 by the provider. The
207// lifecycle is identical to REGIONAL; gate the live run to keep the default
208// suite regional-only.
209test.provider.skipIf(!process.env.AWS_TEST_WAF_CLOUDFRONT)(
210 "CLOUDFRONT scope provisions in us-east-1",
211 (stack) =>
212 Effect.gen(function* () {
213 // reconcile away any prior partial/crashed deployment
214 yield* stack.destroy();
215
216 const acl = yield* stack.deploy(
217 Effect.gen(function* () {
218 return yield* WebACL("EdgeAcl", { scope: "CLOUDFRONT" });
219 }),
220 );
221 expect(acl.scope).toBe("CLOUDFRONT");
222 expect(acl.webAclArn).toContain(":us-east-1:");
223 expect(acl.webAclArn).toContain("global/webacl/");
224
225 // out-of-band verification pinned to us-east-1
226 const created = yield* wafv2
227 .getWebACL({
228 Name: acl.webAclName,
229 Scope: "CLOUDFRONT",
230 Id: acl.webAclId,
231 })
232 .pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
233 expect(created.WebACL?.ARN).toBe(acl.webAclArn);
234
235 yield* stack.destroy();
236 yield* assertWebAclDeleted(
237 acl.webAclName,
238 acl.webAclId,
239 "CLOUDFRONT",
240 ).pipe(Effect.provideService(AwsRegion, Effect.succeed("us-east-1")));
241 }),
242 { tags: ["provider:aws", "provider:aws:wafv2", "live"], timeout: 120_000 },
243);