| 1 | import * as AWS from "@/AWS"; |
| 2 | import { WebACL } from "@/AWS/WAFv2"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import { Region as AwsRegion } from "@distilled.cloud/aws/Region"; |
| 5 | import type * as WAFV2 from "@distilled.cloud/aws/wafv2"; |
| 6 | import * as wafv2 from "@distilled.cloud/aws/wafv2"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Data from "effect/Data"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: AWS.providers() }); |
| 13 | |
| 14 | class WebACLStillExists extends Data.TaggedError("WebACLStillExists")<{ |
| 15 | readonly name: string; |
| 16 | }> {} |
| 17 | |
| 18 | const 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 | |
| 32 | const visibility = (metricName: string): WAFV2.VisibilityConfig => ({ |
| 33 | SampledRequestsEnabled: true, |
| 34 | CloudWatchMetricsEnabled: true, |
| 35 | MetricName: metricName, |
| 36 | }); |
| 37 | |
| 38 | const 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. |
| 42 | const 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 | |
| 80 | test.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 | |
| 167 | test.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. |
| 209 | test.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 | ); |