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 User-Agent strings. A UA rule's User-Agent value
25// is its identity within a zone (duplicates are rejected), so each test
26// owns disjoint values and the same strings are reused on every run
27// (never Date.now()/random).
28const UA_LIFECYCLE_V1 = "AlchemyTestBot/1.0 (+https://alchemy.run)";
29const UA_LIFECYCLE_V2 = "AlchemyTestBot/2.0 (+https://alchemy.run)";
30const UA_LIST = "AlchemyListBot/1.0 (+https://alchemy.run)";
31
32const resolveZoneId = Effect.gen(function* () {
33 const { accountId } = yield* yield* CloudflareEnvironment;
34 const zone = yield* findZoneByName({ accountId, name: zoneName });
35 if (!zone) {
36 return yield* Effect.die(
37 new Error(`zone "${zoneName}" not found in account`),
38 );
39 }
40 return zone.id;
41});
42
43// The scoped API token the test harness mints propagates eventually-
44// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
45// declared in the distilled error union via patches) on the test's own
46// out-of-band verification calls.
47const forbiddenRetry = {
48 while: (e: { _tag: string }) => e._tag === "Forbidden",
49 schedule: Schedule.exponential("500 millis"),
50 times: 8,
51} as const;
52
53const getUaRule = (zoneId: string, uaRuleId: string) =>
54 firewall.getUaRule({ zoneId, uaRuleId }).pipe(Effect.retry(forbiddenRetry));
55
56// List every rule matching one of the given User-Agent strings — used to
57// purge leftovers from interrupted runs so each test starts from a clean
58// slate (a leaked rule would surface as Unowned/duplicate because the
59// User-Agent value is identity).
60const listByUserAgents = (zoneId: string, userAgents: string[]) =>
61 firewall.listUaRules.items({ zoneId }).pipe(
62 Stream.filter((r) => userAgents.includes(r.configuration?.value ?? "")),
63 Stream.runCollect,
64 Effect.map((chunk) => Array.from(chunk)),
65 Effect.retry(forbiddenRetry),
66 );
67
68const purgeUaRules = (zoneId: string, userAgents: string[]) =>
69 listByUserAgents(zoneId, userAgents).pipe(
70 Effect.flatMap(
71 Effect.forEach((r) =>
72 firewall.deleteUaRule({ zoneId, uaRuleId: r.id ?? "" }).pipe(
73 Effect.retry(forbiddenRetry),
74 Effect.catchTag("UaRuleNotFound", () => Effect.void),
75 ),
76 ),
77 ),
78 );
79
80// Poll until the rule is gone — a missing rule surfaces as the typed
81// `UaRuleNotFound` (Cloudflare code 10001, firewalluablock.api.not_found).
82const expectUaRuleGone = (zoneId: string, uaRuleId: string) =>
83 getUaRule(zoneId, uaRuleId).pipe(
84 Effect.flatMap(() => Effect.fail({ _tag: "UaRuleNotDeleted" } as const)),
85 Effect.catchTag("UaRuleNotFound", () => Effect.void),
86 Effect.retry({
87 while: (e) => e._tag === "UaRuleNotDeleted",
88 schedule: Schedule.max([
89 Schedule.exponential("500 millis"),
90 Schedule.recurs(10),
91 ]),
92 }),
93 );
94
95test.provider(
96 "create, update mode/paused/description/userAgent in place, destroy",
97 (stack) =>
98 Effect.gen(function* () {
99 const zoneId = yield* resolveZoneId;
100
101 yield* stack.destroy();
102 yield* purgeUaRules(zoneId, [UA_LIFECYCLE_V1, UA_LIFECYCLE_V2]);
103
104 const initial = yield* stack.deploy(
105 Effect.gen(function* () {
106 return yield* Cloudflare.Firewall.UaRule("LifecycleUaRule", {
107 zoneId,
108 userAgent: UA_LIFECYCLE_V1,
109 mode: "block",
110 description: "alchemy ua rule test (v1)",
111 }).pipe(adopt(true));
112 }),
113 );
114
115 expect(initial.uaRuleId).toBeDefined();
116 expect(initial.zoneId).toEqual(zoneId);
117 expect(initial.userAgent).toEqual(UA_LIFECYCLE_V1);
118 expect(initial.mode).toEqual("block");
119 expect(initial.description).toEqual("alchemy ua rule test (v1)");
120 expect(initial.paused).toEqual(false);
121
122 const live = yield* getUaRule(zoneId, initial.uaRuleId);
123 expect(live.id).toEqual(initial.uaRuleId);
124 expect(live.configuration?.value).toEqual(UA_LIFECYCLE_V1);
125 expect(live.mode).toEqual("block");
126
127 // Update every mutable aspect in one PUT: new User-Agent value, a
128 // softer mode, a new description, and pause the rule — all in place.
129 const updated = yield* stack.deploy(
130 Effect.gen(function* () {
131 return yield* Cloudflare.Firewall.UaRule("LifecycleUaRule", {
132 zoneId,
133 userAgent: UA_LIFECYCLE_V2,
134 mode: "managed_challenge",
135 description: "alchemy ua rule test (v2)",
136 paused: true,
137 }).pipe(adopt(true));
138 }),
139 );
140
141 // Same rule updated in place — not a replacement.
142 expect(updated.uaRuleId).toEqual(initial.uaRuleId);
143 expect(updated.userAgent).toEqual(UA_LIFECYCLE_V2);
144 expect(updated.mode).toEqual("managed_challenge");
145 expect(updated.description).toEqual("alchemy ua rule test (v2)");
146 expect(updated.paused).toEqual(true);
147
148 const liveUpdated = yield* getUaRule(zoneId, updated.uaRuleId);
149 expect(liveUpdated.configuration?.value).toEqual(UA_LIFECYCLE_V2);
150 expect(liveUpdated.mode).toEqual("managed_challenge");
151 expect(liveUpdated.description).toEqual("alchemy ua rule test (v2)");
152 expect(liveUpdated.paused).toEqual(true);
153
154 // Redeploying identical props is a no-op (still the same rule).
155 const noop = yield* stack.deploy(
156 Effect.gen(function* () {
157 return yield* Cloudflare.Firewall.UaRule("LifecycleUaRule", {
158 zoneId,
159 userAgent: UA_LIFECYCLE_V2,
160 mode: "managed_challenge",
161 description: "alchemy ua rule test (v2)",
162 paused: true,
163 }).pipe(adopt(true));
164 }),
165 );
166 expect(noop.uaRuleId).toEqual(initial.uaRuleId);
167
168 yield* stack.destroy();
169
170 yield* expectUaRuleGone(zoneId, initial.uaRuleId);
171 }).pipe(logLevel),
172 {
173 tags: [
174 "provider:cloudflare",
175 "provider:cloudflare:firewall",
176 "provider:cloudflare:zone",
177 "live",
178 ],
179 },
180);
181
182// Canonical `list()` test (zone-scoped collection): UA rules live inside a
183// zone with no account-wide list, so `list()` enumerates every zone via
184// `listAllZones` and exhaustively paginates each. Deploy one rule and assert
185// it appears in the result, hydrated into the full `read` Attributes shape.
186test.provider(
187 "list enumerates UA rules across all zones",
188 (stack) =>
189 Effect.gen(function* () {
190 const zoneId = yield* resolveZoneId;
191
192 yield* stack.destroy();
193 yield* purgeUaRules(zoneId, [UA_LIST]);
194
195 const deployed = yield* stack.deploy(
196 Effect.gen(function* () {
197 return yield* Cloudflare.Firewall.UaRule("ListUaRule", {
198 zoneId,
199 userAgent: UA_LIST,
200 mode: "block",
201 description: "alchemy ua rule list test",
202 }).pipe(adopt(true));
203 }),
204 );
205
206 const provider = yield* Provider.findProvider(Cloudflare.Firewall.UaRule);
207 const all = yield* provider.list();
208
209 const found = all.find((r) => r.uaRuleId === deployed.uaRuleId);
210 expect(found).toBeDefined();
211 expect(found?.zoneId).toEqual(zoneId);
212 expect(found?.userAgent).toEqual(UA_LIST);
213 expect(found?.mode).toEqual("block");
214
215 yield* stack.destroy();
216 yield* purgeUaRules(zoneId, [UA_LIST]);
217 }).pipe(logLevel),
218 {
219 tags: [
220 "provider:cloudflare",
221 "provider:cloudflare:firewall",
222 "provider:cloudflare:zone",
223 "live",
224 ],
225 },
226);