1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as firewall from "@distilled.cloud/cloudflare/firewall";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const zoneName =
22 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
23
24// Deterministic per-test configuration values (TEST-NET-2). A rule's
25// configuration is its identity within a scope, so each test owns a disjoint
26// IP and the same value is reused on every run (never Date.now()/random).
27const IP_DEFAULT = "198.51.100.101";
28const IP_UPDATE = "198.51.100.102";
29const IP_REPLACE_OLD = "198.51.100.103";
30const IP_REPLACE_NEW = "198.51.100.104";
31const IP_ACCOUNT = "198.51.100.105";
32const IP_LIST = "198.51.100.106";
33
34const resolveZoneId = Effect.gen(function* () {
35 const { accountId } = yield* yield* CloudflareEnvironment;
36 const zone = yield* findZoneByName({ accountId, name: zoneName });
37 if (!zone) {
38 return yield* Effect.die(
39 new Error(`zone "${zoneName}" not found in account`),
40 );
41 }
42 return zone.id;
43});
44
45// The scoped API token the test harness mints propagates eventually-
46// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
47// declared in the distilled error union via patches) on the test's own
48// out-of-band verification calls.
49const forbiddenRetry = {
50 while: (e: { _tag: string }) => e._tag === "Forbidden",
51 schedule: Schedule.exponential("500 millis"),
52 times: 8,
53} as const;
54
55const getZoneRule = (zoneId: string, ruleId: string) =>
56 firewall
57 .getAccessRuleForZone({ zoneId, ruleId })
58 .pipe(Effect.retry(forbiddenRetry));
59
60const getAccountRule = (accountId: string, ruleId: string) =>
61 firewall
62 .getAccessRuleForAccount({ accountId, ruleId })
63 .pipe(Effect.retry(forbiddenRetry));
64
65const listByIp = (
66 scope: { zoneId: string } | { accountId: string },
67 ip: string,
68) =>
69 ("zoneId" in scope
70 ? firewall.listAccessRulesForZone.items({ zoneId: scope.zoneId })
71 : firewall.listAccessRulesForAccount.items({ accountId: scope.accountId })
72 ).pipe(
73 Stream.filter(
74 (r) => r.configuration.target === "ip" && r.configuration.value === ip,
75 ),
76 Stream.runCollect,
77 Effect.map((chunk) => Array.from(chunk)),
78 Effect.retry(forbiddenRetry),
79 );
80
81// Delete every rule matching the ip — used to purge leftovers from
82// interrupted runs so each test starts from a clean slate (a leaked rule
83// would surface as Unowned/duplicate because configuration is identity).
84const purgeRules = (
85 scope: { zoneId: string } | { accountId: string },
86 ip: string,
87) =>
88 listByIp(scope, ip).pipe(
89 Effect.flatMap(
90 Effect.forEach((r) =>
91 ("zoneId" in scope
92 ? firewall.deleteAccessRuleForZone({
93 zoneId: scope.zoneId,
94 ruleId: r.id,
95 })
96 : firewall.deleteAccessRuleForAccount({
97 accountId: scope.accountId,
98 ruleId: r.id,
99 })
100 ).pipe(
101 Effect.retry(forbiddenRetry),
102 Effect.catchTag("AccessRuleNotFound", () => Effect.void),
103 ),
104 ),
105 ),
106 );
107
108// Poll until the rule is gone — a missing rule surfaces as the typed
109// `AccessRuleNotFound` (Cloudflare code 10001, firewallaccessrules.api.not_found).
110const expectZoneRuleGone = (zoneId: string, ruleId: string) =>
111 getZoneRule(zoneId, ruleId).pipe(
112 Effect.flatMap(() => Effect.fail({ _tag: "RuleNotDeleted" } as const)),
113 Effect.catchTag("AccessRuleNotFound", () => Effect.void),
114 Effect.retry({
115 while: (e) => e._tag === "RuleNotDeleted",
116 schedule: Schedule.max([
117 Schedule.exponential("500 millis"),
118 Schedule.recurs(10),
119 ]),
120 }),
121 );
122
123const expectAccountRuleGone = (accountId: string, ruleId: string) =>
124 getAccountRule(accountId, ruleId).pipe(
125 Effect.flatMap(() => Effect.fail({ _tag: "RuleNotDeleted" } as const)),
126 Effect.catchTag("AccessRuleNotFound", () => Effect.void),
127 Effect.retry({
128 while: (e) => e._tag === "RuleNotDeleted",
129 schedule: Schedule.max([
130 Schedule.exponential("500 millis"),
131 Schedule.recurs(10),
132 ]),
133 }),
134 );
135
136test.provider(
137 "create and delete a zone-scoped ip rule",
138 (stack) =>
139 Effect.gen(function* () {
140 const { accountId } = yield* yield* CloudflareEnvironment;
141 const zoneId = yield* resolveZoneId;
142
143 yield* stack.destroy();
144 yield* purgeRules({ zoneId }, IP_DEFAULT);
145
146 const rule = yield* stack.deploy(
147 Effect.gen(function* () {
148 return yield* Cloudflare.Firewall.AccessRule("DefaultRule", {
149 zoneId,
150 configuration: { target: "ip", value: IP_DEFAULT },
151 mode: "challenge",
152 notes: "alchemy firewall test (default)",
153 }).pipe(adopt(true));
154 }),
155 );
156
157 expect(rule.ruleId).toBeDefined();
158 expect(rule.zoneId).toEqual(zoneId);
159 expect(rule.accountId).toEqual(accountId);
160 expect(rule.configuration).toEqual({ target: "ip", value: IP_DEFAULT });
161 expect(rule.mode).toEqual("challenge");
162 expect(rule.notes).toEqual("alchemy firewall test (default)");
163 expect(rule.allowedModes).toContain("block");
164
165 const live = yield* getZoneRule(zoneId, rule.ruleId);
166 expect(live.id).toEqual(rule.ruleId);
167 expect(live.configuration.value).toEqual(IP_DEFAULT);
168 expect(live.mode).toEqual("challenge");
169
170 yield* stack.destroy();
171
172 yield* expectZoneRuleGone(zoneId, rule.ruleId);
173 }).pipe(logLevel),
174 {
175 tags: [
176 "provider:cloudflare",
177 "provider:cloudflare:firewall",
178 "provider:cloudflare:zone",
179 "live",
180 ],
181 },
182);
183
184test.provider(
185 "update mode and notes in place (same ruleId)",
186 (stack) =>
187 Effect.gen(function* () {
188 const zoneId = yield* resolveZoneId;
189
190 yield* stack.destroy();
191 yield* purgeRules({ zoneId }, IP_UPDATE);
192
193 const initial = yield* stack.deploy(
194 Effect.gen(function* () {
195 return yield* Cloudflare.Firewall.AccessRule("UpdateRule", {
196 zoneId,
197 configuration: { target: "ip", value: IP_UPDATE },
198 mode: "challenge",
199 notes: "v1",
200 }).pipe(adopt(true));
201 }),
202 );
203
204 expect(initial.mode).toEqual("challenge");
205 expect(initial.notes).toEqual("v1");
206
207 const updated = yield* stack.deploy(
208 Effect.gen(function* () {
209 return yield* Cloudflare.Firewall.AccessRule("UpdateRule", {
210 zoneId,
211 configuration: { target: "ip", value: IP_UPDATE },
212 mode: "managed_challenge",
213 notes: "v2",
214 }).pipe(adopt(true));
215 }),
216 );
217
218 // Same rule patched in place — not a replacement.
219 expect(updated.ruleId).toEqual(initial.ruleId);
220 expect(updated.mode).toEqual("managed_challenge");
221 expect(updated.notes).toEqual("v2");
222
223 const live = yield* getZoneRule(zoneId, updated.ruleId);
224 expect(live.mode).toEqual("managed_challenge");
225 expect(live.notes).toEqual("v2");
226
227 // Redeploying identical props is a no-op (still the same rule).
228 const noop = yield* stack.deploy(
229 Effect.gen(function* () {
230 return yield* Cloudflare.Firewall.AccessRule("UpdateRule", {
231 zoneId,
232 configuration: { target: "ip", value: IP_UPDATE },
233 mode: "managed_challenge",
234 notes: "v2",
235 }).pipe(adopt(true));
236 }),
237 );
238 expect(noop.ruleId).toEqual(initial.ruleId);
239
240 yield* stack.destroy();
241
242 yield* expectZoneRuleGone(zoneId, initial.ruleId);
243 }).pipe(logLevel),
244 {
245 tags: [
246 "provider:cloudflare",
247 "provider:cloudflare:firewall",
248 "provider:cloudflare:zone",
249 "live",
250 ],
251 },
252);
253
254test.provider(
255 "changing the configuration triggers replacement",
256 (stack) =>
257 Effect.gen(function* () {
258 const zoneId = yield* resolveZoneId;
259
260 yield* stack.destroy();
261 yield* purgeRules({ zoneId }, IP_REPLACE_OLD);
262 yield* purgeRules({ zoneId }, IP_REPLACE_NEW);
263
264 const initial = yield* stack.deploy(
265 Effect.gen(function* () {
266 return yield* Cloudflare.Firewall.AccessRule("ReplaceRule", {
267 zoneId,
268 configuration: { target: "ip", value: IP_REPLACE_OLD },
269 mode: "challenge",
270 }).pipe(adopt(true));
271 }),
272 );
273
274 expect(initial.configuration.value).toEqual(IP_REPLACE_OLD);
275
276 const replaced = yield* stack.deploy(
277 Effect.gen(function* () {
278 return yield* Cloudflare.Firewall.AccessRule("ReplaceRule", {
279 zoneId,
280 configuration: { target: "ip", value: IP_REPLACE_NEW },
281 mode: "challenge",
282 }).pipe(adopt(true));
283 }),
284 );
285
286 // The configuration is the rule's identity — a new physical rule exists.
287 expect(replaced.ruleId).not.toEqual(initial.ruleId);
288 expect(replaced.configuration.value).toEqual(IP_REPLACE_NEW);
289
290 // The old rule was deleted as part of the replacement.
291 yield* expectZoneRuleGone(zoneId, initial.ruleId);
292
293 const live = yield* getZoneRule(zoneId, replaced.ruleId);
294 expect(live.configuration.value).toEqual(IP_REPLACE_NEW);
295
296 yield* stack.destroy();
297
298 yield* expectZoneRuleGone(zoneId, replaced.ruleId);
299 }).pipe(logLevel),
300 {
301 tags: [
302 "provider:cloudflare",
303 "provider:cloudflare:firewall",
304 "provider:cloudflare:zone",
305 "live",
306 ],
307 },
308);
309
310test.provider(
311 "list enumerates the deployed zone-scoped rule",
312 (stack) =>
313 Effect.gen(function* () {
314 const zoneId = yield* resolveZoneId;
315
316 yield* stack.destroy();
317 yield* purgeRules({ zoneId }, IP_LIST);
318
319 const rule = yield* stack.deploy(
320 Effect.gen(function* () {
321 return yield* Cloudflare.Firewall.AccessRule("ListRule", {
322 zoneId,
323 configuration: { target: "ip", value: IP_LIST },
324 mode: "challenge",
325 notes: "alchemy firewall test (list)",
326 }).pipe(adopt(true));
327 }),
328 );
329
330 const provider = yield* Provider.findProvider(
331 Cloudflare.Firewall.AccessRule,
332 );
333 const all = yield* provider.list();
334
335 const found = all.find((r) => r.ruleId === rule.ruleId);
336 expect(found).toBeDefined();
337 expect(found?.zoneId).toEqual(zoneId);
338 expect(found?.configuration).toEqual({ target: "ip", value: IP_LIST });
339 expect(found?.mode).toEqual("challenge");
340
341 yield* stack.destroy();
342
343 yield* expectZoneRuleGone(zoneId, rule.ruleId);
344 }).pipe(logLevel),
345 {
346 tags: [
347 "provider:cloudflare",
348 "provider:cloudflare:firewall",
349 "provider:cloudflare:zone",
350 "live",
351 ],
352 },
353);
354
355test.provider(
356 "account-scoped rule (no zoneId) create, update, delete",
357 (stack) =>
358 Effect.gen(function* () {
359 const { accountId } = yield* yield* CloudflareEnvironment;
360
361 yield* stack.destroy();
362 yield* purgeRules({ accountId }, IP_ACCOUNT);
363
364 const rule = yield* stack.deploy(
365 Effect.gen(function* () {
366 return yield* Cloudflare.Firewall.AccessRule("AccountRule", {
367 configuration: { target: "ip", value: IP_ACCOUNT },
368 mode: "challenge",
369 notes: "alchemy firewall test (account)",
370 }).pipe(adopt(true));
371 }),
372 );
373
374 expect(rule.ruleId).toBeDefined();
375 expect(rule.zoneId).toBeUndefined();
376 expect(rule.accountId).toEqual(accountId);
377 expect(rule.configuration).toEqual({ target: "ip", value: IP_ACCOUNT });
378 expect(rule.mode).toEqual("challenge");
379
380 const live = yield* getAccountRule(accountId, rule.ruleId);
381 expect(live.id).toEqual(rule.ruleId);
382 expect(live.configuration.value).toEqual(IP_ACCOUNT);
383
384 const updated = yield* stack.deploy(
385 Effect.gen(function* () {
386 return yield* Cloudflare.Firewall.AccessRule("AccountRule", {
387 configuration: { target: "ip", value: IP_ACCOUNT },
388 mode: "managed_challenge",
389 notes: "alchemy firewall test (account, v2)",
390 }).pipe(adopt(true));
391 }),
392 );
393
394 expect(updated.ruleId).toEqual(rule.ruleId);
395 expect(updated.mode).toEqual("managed_challenge");
396
397 const liveUpdated = yield* getAccountRule(accountId, rule.ruleId);
398 expect(liveUpdated.mode).toEqual("managed_challenge");
399
400 yield* stack.destroy();
401
402 yield* expectAccountRuleGone(accountId, rule.ruleId);
403 }).pipe(logLevel),
404 { tags: ["provider:cloudflare", "provider:cloudflare:firewall", "live"] },
405);