1import * as AWS from "@/AWS";
2import { GraphqlApi } from "@/AWS/AppSync";
3import { WebACL, WebACLAssociation } from "@/AWS/WAFv2";
4import * as Test from "@/Test/Alchemy";
5import * as sts from "@distilled.cloud/aws/sts";
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 Result from "effect/Result";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15class WebACLStillExists extends Data.TaggedError("WebACLStillExists")<{
16 readonly name: string;
17}> {}
18
19class AssociationPending extends Data.TaggedError("AssociationPending")<{
20 readonly resourceArn: string;
21 readonly expected: string | undefined;
22 readonly actual: string | undefined;
23}> {}
24
25const assertWebAclDeleted = (name: string, id: string) =>
26 wafv2.getWebACL({ Name: name, Scope: "REGIONAL", Id: id }).pipe(
27 Effect.flatMap(() => Effect.fail(new WebACLStillExists({ name }))),
28 Effect.catchTag("WAFNonexistentItemException", () => Effect.void),
29 Effect.retry({
30 while: (e) => e._tag === "WebACLStillExists",
31 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
32 }),
33 );
34
35// Association reads are eventually consistent — poll until the observed web
36// ACL matches the expectation (bounded).
37const assertAssociated = (resourceArn: string, expected: string | undefined) =>
38 wafv2.getWebACLForResource({ ResourceArn: resourceArn }).pipe(
39 Effect.flatMap((response) =>
40 response.WebACL?.ARN === expected
41 ? Effect.void
42 : Effect.fail(
43 new AssociationPending({
44 resourceArn,
45 expected,
46 actual: response.WebACL?.ARN,
47 }),
48 ),
49 ),
50 Effect.retry({
51 while: (e) => e._tag === "AssociationPending",
52 schedule: Schedule.max([
53 Schedule.fixed("2 seconds"),
54 Schedule.recurs(15),
55 ]),
56 }),
57 );
58
59// Ungated probe: associateWebACL with well-formed but nonexistent ARNs
60// returns the typed WAFNonexistentItemException — proves the distilled
61// wiring and the typed error union at near-zero cost, without creating
62// any resources.
63test.provider(
64 "associateWebACL surfaces typed WAFNonexistentItemException",
65 () =>
66 Effect.gen(function* () {
67 const identity = yield* sts.getCallerIdentity({});
68 const account = identity.Account;
69 const result = yield* Effect.result(
70 wafv2.associateWebACL({
71 WebACLArn: `arn:aws:wafv2:us-west-2:${account}:regional/webacl/alchemy-probe-nonexistent/00000000-0000-0000-0000-000000000000`,
72 ResourceArn: `arn:aws:appsync:us-west-2:${account}:apis/alchemyprobenonexistent`,
73 }),
74 );
75 expect(Result.isFailure(result)).toBe(true);
76 if (Result.isFailure(result)) {
77 expect(result.failure._tag).toBe("WAFNonexistentItemException");
78 }
79 }),
80 { tags: ["provider:aws", "provider:aws:wafv2", "live"] },
81);
82
83// Live lifecycle is gated: associating a FRESHLY CREATED web ACL (or
84// protected resource) intermittently fails with
85// WAFUnavailableEntityException: AWS WAF couldn’t retrieve the resource
86// that you requested. Retry your request.
87// for well over 150s (~50% of back-to-back runs; observed with both
88// Cognito user pool and AppSync API targets; terraform-provider-aws
89// retries this for 5 minutes — see hashicorp/terraform-provider-aws#24386).
90// The provider retries the tag on a ~150s budget which covers routine
91// propagation; the deterministic suite can't absorb the multi-minute tail.
92test.provider.skipIf(!process.env.AWS_TEST_WAF_ASSOCIATION)(
93 "associate a web ACL with an AppSync API, re-point, disassociate",
94 (stack) =>
95 Effect.gen(function* () {
96 // reconcile away any prior partial/crashed deployment
97 yield* stack.destroy();
98
99 // create API + ACL + association in one deploy
100 const outputs = yield* stack.deploy(
101 Effect.gen(function* () {
102 const api = yield* GraphqlApi("WafProtectedApi", {});
103 const acl = yield* WebACL("PoolAclA", {});
104 yield* WebACLAssociation("PoolAssociation", {
105 webAclArn: acl.webAclArn,
106 resourceArn: api.apiArn,
107 });
108 return {
109 apiArn: api.apiArn,
110 aclName: acl.webAclName,
111 aclId: acl.webAclId,
112 aclArn: acl.webAclArn,
113 };
114 }),
115 );
116
117 // out-of-band verification via getWebACLForResource
118 yield* assertAssociated(outputs.apiArn, outputs.aclArn);
119
120 // re-point the association at a second web ACL in place (keep the
121 // first ACL deployed — never drop a dependency in the same step)
122 const repointed = yield* stack.deploy(
123 Effect.gen(function* () {
124 const api = yield* GraphqlApi("WafProtectedApi", {});
125 yield* WebACL("PoolAclA", {});
126 const aclB = yield* WebACL("PoolAclB", {});
127 yield* WebACLAssociation("PoolAssociation", {
128 webAclArn: aclB.webAclArn,
129 resourceArn: api.apiArn,
130 });
131 return {
132 aclBName: aclB.webAclName,
133 aclBId: aclB.webAclId,
134 aclBArn: aclB.webAclArn,
135 };
136 }),
137 );
138
139 expect(repointed.aclBArn).not.toBe(outputs.aclArn);
140 yield* assertAssociated(outputs.apiArn, repointed.aclBArn);
141
142 // destroy everything; deletion order proves the association was
143 // removed before its web ACL (deleteWebACL fails while associated)
144 yield* stack.destroy();
145 yield* assertWebAclDeleted(outputs.aclName, outputs.aclId);
146 yield* assertWebAclDeleted(repointed.aclBName, repointed.aclBId);
147 }),
148 // First associate can wait up to ~150s for the fresh user pool to
149 // propagate to WAF (retryUnavailableEntityLong).
150 {
151 tags: [
152 "provider:aws",
153 "provider:aws:appsync",
154 "provider:aws:wafv2",
155 "live",
156 ],
157 timeout: 240_000,
158 },
159);