1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import Wafv2BindingsFunctionLive, { Wafv2BindingsFunction } from "./handler";
11
12const testOptions = { providers: AWS.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "Wafv2Bindings");
15
16// Lambda function URL cold-start (DNS, IAM propagation, init) can take well
17// over 60s on a fresh deploy.
18const readinessPolicy = Schedule.max([
19 Schedule.fixed("2 seconds"),
20 Schedule.recurs(75),
21]);
22
23let baseUrl: string;
24
25class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
26 readonly status: number;
27 readonly body: string;
28}> {}
29
30// The shared Lambda fixture occasionally answers a transient 5xx under load
31// (cold re-init, IAM propagation on the freshly attached policy). Retry only
32// 5xx; a genuine 4xx/assertion failure surfaces immediately.
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e) => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("500 millis"),
50 Schedule.recurs(6),
51 ]),
52 }),
53 );
54
55const getJson = (path: string) =>
56 send(HttpClientRequest.get(`${baseUrl}${path}`)).pipe(
57 Effect.flatMap((r) => r.json),
58 );
59
60const postJson = (path: string, body: object) =>
61 send(
62 HttpClientRequest.post(`${baseUrl}${path}`).pipe(
63 HttpClientRequest.bodyJsonUnsafe(body),
64 ),
65 ).pipe(Effect.flatMap((r) => r.json));
66
67describe.sequential(
68 "WAFv2 Bindings",
69 {
70 tags: ["provider:aws", "provider:aws:lambda", "provider:aws:wafv2", "live"],
71 },
72 () => {
73 beforeAll(
74 Effect.gen(function* () {
75 yield* Effect.logInfo(
76 "WAFv2 test setup: destroying previous resources",
77 );
78 yield* sharedStack.destroy();
79
80 yield* Effect.logInfo("WAFv2 test setup: deploying fixture");
81 const attrs = yield* sharedStack.deploy(
82 Effect.gen(function* () {
83 return yield* Wafv2BindingsFunction;
84 }).pipe(Effect.provide(Wafv2BindingsFunctionLive)),
85 );
86
87 expect(attrs.functionUrl).toBeTruthy();
88 baseUrl = attrs.functionUrl!.replace(/\/+$/, "");
89
90 const readinessUrl = `${baseUrl}/bindings`;
91 yield* Effect.logInfo(
92 `WAFv2 test setup: probing readiness at ${readinessUrl}`,
93 );
94 yield* HttpClient.get(readinessUrl).pipe(
95 Effect.flatMap((response) =>
96 response.status === 200
97 ? Effect.succeed(response)
98 : Effect.fail(
99 new Error(`Function not ready: ${response.status}`),
100 ),
101 ),
102 Effect.tapError((error) =>
103 Effect.logWarning(
104 `WAFv2 test setup: fixture not ready yet (${String(error)})`,
105 ),
106 ),
107 Effect.retry({ schedule: readinessPolicy }),
108 );
109 }),
110 { timeout: 600_000 },
111 );
112
113 afterAll(process.env.NO_DESTROY ? Effect.void : sharedStack.destroy(), {
114 timeout: 600_000,
115 });
116
117 describe("binding registration", () => {
118 test.provider("all 20 capabilities initialize in the runtime", (_stack) =>
119 Effect.gen(function* () {
120 const response = (yield* getJson("/bindings")) as { bound: string[] };
121 expect(response.bound).toHaveLength(20);
122 expect(response.bound).toContain("getIPSet");
123 expect(response.bound).toContain("updateIPSet");
124 expect(response.bound).toContain("checkCapacity");
125 expect(response.bound).toContain("getWebACLForResource");
126 }),
127 );
128 });
129
130 describe("GetIPSet / UpdateIPSet", () => {
131 test.provider(
132 "reads and replaces the dynamic block list (LockToken handled)",
133 (_stack) =>
134 Effect.gen(function* () {
135 const initial = (yield* getJson("/ip-set")) as {
136 addresses: string[];
137 description: string;
138 };
139 expect(initial.addresses).toEqual(["192.0.2.44/32"]);
140 expect(initial.description).toBe("bindings fixture");
141
142 const updated = (yield* postJson("/ip-set", {
143 addresses: ["192.0.2.44/32", "198.51.100.7/32"],
144 })) as { addresses: string[] };
145 expect(updated.addresses).toEqual([
146 "192.0.2.44/32",
147 "198.51.100.7/32",
148 ]);
149
150 // restore for idempotent re-runs
151 const restored = (yield* postJson("/ip-set", {
152 addresses: ["192.0.2.44/32"],
153 })) as { addresses: string[] };
154 expect(restored.addresses).toEqual(["192.0.2.44/32"]);
155 }),
156 { timeout: 120_000 },
157 );
158 });
159
160 describe("GetSampledRequests", () => {
161 test.provider("reads the (empty) sample for the rate rule", (_stack) =>
162 Effect.gen(function* () {
163 const response = (yield* getJson("/sampled")) as {
164 sampled: number;
165 populationSize: number;
166 };
167 expect(response.sampled).toBe(0);
168 }),
169 );
170 });
171
172 describe("GetRateBasedStatementManagedKeys", () => {
173 test.provider("reads the currently rate-limited addresses", (_stack) =>
174 Effect.gen(function* () {
175 const response = (yield* getJson("/rate-keys")) as {
176 v4: number;
177 v4Version: string | null;
178 };
179 expect(response.v4).toBe(0);
180 expect(response.v4Version).toBe("IPV4");
181 }),
182 );
183 });
184
185 describe("ListResourcesForWebACL", () => {
186 test.provider("lists no associated resources", (_stack) =>
187 Effect.gen(function* () {
188 const response = (yield* getJson("/resources")) as {
189 resources: string[];
190 };
191 expect(response.resources).toEqual([]);
192 }),
193 );
194 });
195
196 describe("GetTopPathStatisticsByTraffic", () => {
197 test.provider("returns data or the typed pricing-plan gate", (_stack) =>
198 Effect.gen(function* () {
199 const response = (yield* getJson("/top-paths")) as {
200 ok: boolean;
201 tag: string | null;
202 };
203 if (!response.ok) {
204 // account without the bot-statistics pricing plan — the typed
205 // tag proves the grant + wiring reached WAF
206 expect([
207 "WAFFeatureNotIncludedInPricingPlanException",
208 "WAFInvalidOperationException",
209 "WAFNonexistentItemException",
210 ]).toContain(response.tag);
211 }
212 }),
213 );
214 });
215
216 describe("PutPermissionPolicy / GetPermissionPolicy / DeletePermissionPolicy", () => {
217 test.provider("shares and unshares the rule group", (_stack) =>
218 Effect.gen(function* () {
219 const response = (yield* getJson("/permission-policy")) as {
220 beforePolicy: string | null;
221 hasPolicy: boolean;
222 deleted: boolean;
223 };
224 expect(response.beforePolicy).toBeNull();
225 expect(response.hasPolicy).toBe(true);
226 expect(response.deleted).toBe(true);
227 }),
228 );
229 });
230
231 describe("CheckCapacity", () => {
232 test.provider("computes the WCU cost of a geo-match rule", (_stack) =>
233 Effect.gen(function* () {
234 const response = (yield* getJson("/capacity")) as {
235 capacity: number;
236 };
237 expect(response.capacity).toBeGreaterThan(0);
238 }),
239 );
240 });
241
242 describe("CAPTCHA API keys", () => {
243 test.provider(
244 "mints, lists, decrypts, and deletes an API key",
245 (_stack) =>
246 Effect.gen(function* () {
247 const response = (yield* getJson("/api-keys")) as {
248 created: boolean;
249 listed: number;
250 domains: string[];
251 deleted: boolean;
252 };
253 expect(response.created).toBe(true);
254 expect(response.listed).toBeGreaterThan(0);
255 expect(response.domains).toContain("example.com");
256 expect(response.deleted).toBe(true);
257 }),
258 );
259 });
260
261 describe("managed rule group catalog", () => {
262 test.provider(
263 "lists groups and describes the common rule set",
264 (_stack) =>
265 Effect.gen(function* () {
266 const response = (yield* getJson("/managed")) as {
267 groups: number;
268 capacity: number;
269 currentDefaultVersion: string | null;
270 };
271 expect(response.groups).toBeGreaterThan(0);
272 expect(response.capacity).toBeGreaterThan(0);
273 expect(response.currentDefaultVersion).toBeTruthy();
274 }),
275 );
276
277 test.provider("reads the managed products catalog", (_stack) =>
278 Effect.gen(function* () {
279 const response = (yield* getJson("/products")) as {
280 all: number;
281 aws: number;
282 };
283 expect(response.all).toBeGreaterThan(0);
284 expect(response.aws).toBeGreaterThan(0);
285 }),
286 );
287 });
288
289 describe("GetWebACLForResource", () => {
290 test.provider(
291 "returns not-found for an unassociated resource (typed)",
292 (_stack) =>
293 Effect.gen(function* () {
294 const response = (yield* getJson("/waf-for-resource")) as {
295 found: boolean;
296 };
297 expect(response.found).toBe(false);
298 }),
299 );
300 });
301 },
302);