1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as botManagement from "@distilled.cloud/cloudflare/bot-management";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const zoneName =
19 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
20
21const resolveZoneId = Effect.gen(function* () {
22 const { accountId } = yield* yield* CloudflareEnvironment;
23 const zone = yield* findZoneByName({ accountId, name: zoneName });
24 if (!zone) {
25 return yield* Effect.die(
26 new Error(`zone "${zoneName}" not found in account`),
27 );
28 }
29 return zone.id;
30});
31
32// The scoped API token the test harness mints propagates eventually-
33// consistently across Cloudflare's edge — a fresh token intermittently 403s.
34// Ride out the blips on the test's own out-of-band verification calls by
35// retrying the typed `Forbidden` error (patched into the bot-management
36// operations' error unions).
37const forbiddenRetrySchedule = Schedule.exponential("500 millis");
38
39/**
40 * The distilled response is a 4-way union of plan shapes; widen to a flat
41 * bag of optional fields for assertions.
42 */
43interface ObservedConfig {
44 readonly enableJs?: boolean | null;
45 readonly fightMode?: boolean | null;
46 readonly aiBotsProtection?: string | null;
47 readonly sbfmDefinitelyAutomated?: string | null;
48 readonly sbfmVerifiedBots?: string | null;
49 readonly sbfmStaticResourceProtection?: boolean | null;
50 readonly optimizeWordpress?: boolean | null;
51}
52
53const getConfig = (zoneId: string) =>
54 botManagement.getBotManagement({ zoneId }).pipe(
55 Effect.map((config): ObservedConfig => config as ObservedConfig),
56 Effect.retry({
57 while: (e) => e._tag === "Forbidden",
58 schedule: forbiddenRetrySchedule,
59 times: 8,
60 }),
61 );
62
63const restoreSbfm = (zoneId: string, original: ObservedConfig) =>
64 Effect.gen(function* () {
65 const body: Record<string, unknown> = {};
66 if (original.sbfmDefinitelyAutomated != null) {
67 body.sbfmDefinitelyAutomated = original.sbfmDefinitelyAutomated;
68 }
69 if (original.sbfmVerifiedBots != null) {
70 body.sbfmVerifiedBots = original.sbfmVerifiedBots;
71 }
72 if (original.sbfmStaticResourceProtection != null) {
73 body.sbfmStaticResourceProtection = original.sbfmStaticResourceProtection;
74 }
75 if (Object.keys(body).length === 0) return;
76 yield* botManagement.putBotManagement({ zoneId, ...body });
77 }).pipe(Effect.ignore);
78
79// Setting `sbfm_definitely_automated` to anything but "allow" makes the
80// standing zone answer every automated request (curl, fetch, vitest HTTP
81// assertions) with a managed challenge — breaking every live suite that
82// drives the zone over HTTP, and an interrupted run leaves the setting
83// behind. The mutating lifecycle tests are therefore opt-in; the adopt and
84// list tests below always run.
85const destructive = !!process.env.CLOUDFLARE_TEST_BOT_MANAGEMENT;
86
87describe.sequential(
88 "BotManagement",
89 {
90 tags: [
91 "provider:cloudflare",
92 "provider:cloudflare:botmanagement",
93 "provider:cloudflare:zone",
94 "live",
95 ],
96 },
97 () => {
98 test.provider.skipIf(!destructive)(
99 "manages SBFM settings on the zone singleton and restores them on destroy",
100 (stack) =>
101 Effect.gen(function* () {
102 const zoneId = yield* resolveZoneId;
103
104 yield* stack.destroy();
105
106 const original = yield* getConfig(zoneId);
107 // Pick deterministic targets that differ from the live config so the
108 // first reconcile demonstrably writes.
109 const target =
110 original.sbfmDefinitelyAutomated === "managed_challenge"
111 ? "block"
112 : ("managed_challenge" as const);
113 const target2 = target === "block" ? "managed_challenge" : "block";
114
115 yield* Effect.gen(function* () {
116 // 1. Create (adopt the singleton) with one SBFM field set.
117 const created = yield* stack.deploy(
118 Effect.gen(function* () {
119 return yield* Cloudflare.BotManagement.BotManagement("Bots", {
120 zoneId,
121 sbfmDefinitelyAutomated: target,
122 });
123 }),
124 );
125 expect(created.zoneId).toEqual(zoneId);
126 expect(created.sbfmDefinitelyAutomated).toEqual(target);
127 // Snapshot captured the pre-management value.
128 expect(
129 created.initialSettings.sbfmDefinitelyAutomated ?? null,
130 ).toEqual(original.sbfmDefinitelyAutomated ?? null);
131
132 const live1 = yield* getConfig(zoneId);
133 expect(live1.sbfmDefinitelyAutomated).toEqual(target);
134
135 // 2. Update the mutable field — same singleton (same zoneId), and
136 // the initial snapshot must remain sticky across updates.
137 const updated = yield* stack.deploy(
138 Effect.gen(function* () {
139 return yield* Cloudflare.BotManagement.BotManagement("Bots", {
140 zoneId,
141 sbfmDefinitelyAutomated: target2,
142 });
143 }),
144 );
145 expect(updated.zoneId).toEqual(zoneId);
146 expect(updated.sbfmDefinitelyAutomated).toEqual(target2);
147 expect(
148 updated.initialSettings.sbfmDefinitelyAutomated ?? null,
149 ).toEqual(original.sbfmDefinitelyAutomated ?? null);
150
151 const live2 = yield* getConfig(zoneId);
152 expect(live2.sbfmDefinitelyAutomated).toEqual(target2);
153
154 // 3. Destroy — the managed field is restored to the snapshot value
155 // (when the snapshot had one; the test zone always does once the
156 // field has ever been set, which step 1 guarantees for reruns).
157 yield* stack.destroy();
158
159 const after = yield* getConfig(zoneId);
160 if (original.sbfmDefinitelyAutomated != null) {
161 expect(after.sbfmDefinitelyAutomated).toEqual(
162 original.sbfmDefinitelyAutomated,
163 );
164 }
165 }).pipe(Effect.ensuring(restoreSbfm(zoneId, original)));
166
167 yield* stack.destroy();
168 }).pipe(logLevel),
169 { timeout: 240_000 },
170 );
171
172 test.provider(
173 "deploy with no settings set adopts the singleton without writing",
174 (stack) =>
175 Effect.gen(function* () {
176 const zoneId = yield* resolveZoneId;
177
178 yield* stack.destroy();
179
180 const before = yield* getConfig(zoneId);
181
182 const adopted = yield* stack.deploy(
183 Effect.gen(function* () {
184 return yield* Cloudflare.BotManagement.BotManagement("Bots", {
185 zoneId,
186 });
187 }),
188 );
189 expect(adopted.zoneId).toEqual(zoneId);
190
191 // Nothing was set => no PUT => live config unchanged.
192 const afterDeploy = yield* getConfig(zoneId);
193 expect(afterDeploy.sbfmDefinitelyAutomated ?? null).toEqual(
194 before.sbfmDefinitelyAutomated ?? null,
195 );
196 expect(afterDeploy.sbfmVerifiedBots ?? null).toEqual(
197 before.sbfmVerifiedBots ?? null,
198 );
199 expect(afterDeploy.sbfmStaticResourceProtection ?? null).toEqual(
200 before.sbfmStaticResourceProtection ?? null,
201 );
202 expect(afterDeploy.enableJs ?? null).toEqual(before.enableJs ?? null);
203
204 // Destroy of an untouched singleton restores nothing and never errors.
205 yield* stack.destroy();
206
207 const afterDestroy = yield* getConfig(zoneId);
208 expect(afterDestroy.sbfmDefinitelyAutomated ?? null).toEqual(
209 before.sbfmDefinitelyAutomated ?? null,
210 );
211 expect(afterDestroy.enableJs ?? null).toEqual(
212 before.enableJs ?? null,
213 );
214 }).pipe(logLevel),
215 { timeout: 240_000 },
216 );
217
218 test.provider.skipIf(!destructive)(
219 "toggles a boolean SBFM field and restores it on destroy",
220 (stack) =>
221 Effect.gen(function* () {
222 const zoneId = yield* resolveZoneId;
223
224 yield* stack.destroy();
225
226 const original = yield* getConfig(zoneId);
227 const toggled = !(original.sbfmStaticResourceProtection ?? false);
228
229 yield* Effect.gen(function* () {
230 const created = yield* stack.deploy(
231 Effect.gen(function* () {
232 return yield* Cloudflare.BotManagement.BotManagement("Bots", {
233 zoneId,
234 sbfmStaticResourceProtection: toggled,
235 });
236 }),
237 );
238 expect(created.sbfmStaticResourceProtection).toEqual(toggled);
239
240 const live = yield* getConfig(zoneId);
241 expect(live.sbfmStaticResourceProtection).toEqual(toggled);
242
243 yield* stack.destroy();
244
245 const after = yield* getConfig(zoneId);
246 if (original.sbfmStaticResourceProtection != null) {
247 expect(after.sbfmStaticResourceProtection).toEqual(
248 original.sbfmStaticResourceProtection,
249 );
250 }
251 }).pipe(Effect.ensuring(restoreSbfm(zoneId, original)));
252
253 yield* stack.destroy();
254 }).pipe(logLevel),
255 { timeout: 240_000 },
256 );
257
258 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
259 // API for this per-zone config, so `list()` enumerates every zone via
260 // `listAllZones` and reads the bot-management singleton in each. Assert the
261 // result is non-empty and contains the standing test zone. This is a
262 // read-only assertion (no mutation), so it runs regardless of whether the
263 // zone has the paid Bot Management add-on.
264 test.provider(
265 "list enumerates bot management across all zones",
266 (stack) =>
267 Effect.gen(function* () {
268 const zoneId = yield* resolveZoneId;
269
270 const provider = yield* Provider.findProvider(
271 Cloudflare.BotManagement.BotManagement,
272 );
273 const all = yield* provider.list();
274
275 expect(all.length).toBeGreaterThan(0);
276 expect(all.some((s) => s.zoneId === zoneId)).toBe(true);
277
278 // `stack` is unused (the singleton always exists on every zone), but
279 // keep the destroy bookend so the harness state stays clean.
280 yield* stack.destroy();
281 }).pipe(logLevel),
282 { timeout: 240_000 },
283 );
284 },
285);