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 contentScanning from "@distilled.cloud/cloudflare/content-scanning";
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
21// WAF Content Scanning is an Enterprise paid add-on. On the testing
22// account's zone, reading the status works (it reports "disabled"), but
23// enabling fails with "not entitled to use the phase
24// http_request_firewall_scan_file", surfaced as the typed
25// `ContentScanningNotEntitled` error. The enable lifecycle test below is
26// gated behind an entitled zone id supplied via env.
27const entitledZoneId = process.env.CLOUDFLARE_TEST_CONTENT_SCANNING_ZONE_ID;
28
29const resolveZoneId = Effect.gen(function* () {
30 const { accountId } = yield* yield* CloudflareEnvironment;
31 const zone = yield* findZoneByName({ accountId, name: zoneName });
32 if (!zone) {
33 return yield* Effect.die(
34 new Error(`zone "${zoneName}" not found in account`),
35 );
36 }
37 return zone.id;
38});
39
40// The scoped API token the test harness mints propagates eventually-
41// consistently across Cloudflare's edge — ride out 403 blips on the test's
42// own out-of-band calls by retrying the typed `Forbidden` error.
43const forbiddenRetrySchedule = Schedule.exponential("500 millis");
44
45const getStatus = (zoneId: string) =>
46 contentScanning.getContentScanning({ zoneId }).pipe(
47 Effect.retry({
48 while: (e) => e._tag === "Forbidden",
49 schedule: forbiddenRetrySchedule,
50 times: 8,
51 }),
52 );
53
54const setBaseline = (zoneId: string, value: "enabled" | "disabled") =>
55 contentScanning.putContentScanning({ zoneId, value }).pipe(
56 Effect.retry({
57 while: (e) => e._tag === "Forbidden",
58 schedule: forbiddenRetrySchedule,
59 times: 8,
60 }),
61 );
62
63// Both cases mutate the same zone-level content-scanning enablement singleton; run them serially so they don't corrupt each other's captured `initialValue` under the global concurrent test config.
64describe.sequential(
65 "ContentScanning",
66 {
67 tags: [
68 "provider:cloudflare",
69 "provider:cloudflare:contentscanning",
70 "live",
71 ],
72 },
73 () => {
74 test.provider(
75 "surfaces the typed ContentScanningNotEntitled error on unentitled zones",
76 (stack) =>
77 Effect.gen(function* () {
78 const zoneId = yield* resolveZoneId;
79
80 yield* stack.destroy();
81
82 // The standard testing zone lacks the Content Scanning add-on — the
83 // distilled enable call must fail with the typed entitlement tag.
84 const error = yield* contentScanning
85 .putContentScanning({ zoneId, value: "enabled" })
86 .pipe(
87 Effect.retry({
88 while: (e) => e._tag === "Forbidden",
89 schedule: forbiddenRetrySchedule,
90 times: 8,
91 }),
92 Effect.flip,
93 );
94 expect(error._tag).toEqual("ContentScanningNotEntitled");
95
96 yield* stack.destroy();
97 }).pipe(logLevel),
98 { tags: ["provider:cloudflare:zone"] },
99 );
100
101 test.provider(
102 "pins Content Scanning off on an unentitled zone and destroys cleanly",
103 (stack) =>
104 Effect.gen(function* () {
105 const zoneId = yield* resolveZoneId;
106
107 yield* stack.destroy();
108 // Known baseline: an unentitled zone reports "disabled" (the PUT to
109 // "disabled" succeeds even without the add-on).
110 yield* setBaseline(zoneId, "disabled");
111
112 const scanning = yield* stack.deploy(
113 Effect.gen(function* () {
114 return yield* Cloudflare.ContentScanning.ContentScanning(
115 "UploadScanning",
116 {
117 zoneId,
118 enabled: false,
119 },
120 );
121 }),
122 );
123
124 expect(scanning.zoneId).toEqual(zoneId);
125 expect(scanning.enabled).toEqual(false);
126 // The pre-management status was captured for restore-on-destroy.
127 expect(scanning.initialValue).toEqual("disabled");
128
129 // Out-of-band verification via the distilled API.
130 const live = yield* getStatus(zoneId);
131 expect(live.value).toEqual("disabled");
132
133 // Re-deploying the same desired state is a pure no-op (no PUT) —
134 // on an unentitled zone any write of "enabled" would fail, so this
135 // also proves reconcile only calls the API on a delta.
136 const again = yield* stack.deploy(
137 Effect.gen(function* () {
138 return yield* Cloudflare.ContentScanning.ContentScanning(
139 "UploadScanning",
140 {
141 zoneId,
142 enabled: false,
143 },
144 );
145 }),
146 );
147 expect(again.enabled).toEqual(false);
148 expect(again.initialValue).toEqual("disabled");
149
150 yield* stack.destroy();
151
152 // Destroy restored (kept) the pre-management status.
153 const restored = yield* getStatus(zoneId);
154 expect(restored.value).toEqual("disabled");
155 }).pipe(logLevel),
156 { tags: ["provider:cloudflare:zone"] },
157 );
158
159 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
160 // API for this per-zone setting, so `list()` enumerates every zone via
161 // `listAllZones` and reads the singleton in each. Reading the status works
162 // on every plan (only enabling is entitlement-gated), so this stays an
163 // ungated read-only assertion. Assert the result is non-empty and contains
164 // the standing test zone.
165 test.provider(
166 "list enumerates the status across all zones",
167 (stack) =>
168 Effect.gen(function* () {
169 const zoneId = yield* resolveZoneId;
170
171 const provider = yield* Provider.findProvider(
172 Cloudflare.ContentScanning.ContentScanning,
173 );
174 // The freshly-minted scoped token propagates eventually-consistently, so
175 // the account-wide enumeration intermittently 403s (`Forbidden`) or 401s
176 // (`Unauthorized`). Both are transient here — ride out the blip like
177 // every other out-of-band call in this suite.
178 const all = yield* provider.list().pipe(
179 Effect.retry({
180 while: (e) => e._tag === "Forbidden" || e._tag === "Unauthorized",
181 schedule: forbiddenRetrySchedule,
182 times: 8,
183 }),
184 );
185
186 expect(all.length).toBeGreaterThan(0);
187 expect(all.some((s) => s.zoneId === zoneId)).toBe(true);
188
189 // `stack` is unused here (the singleton always exists on every zone),
190 // but keep the destroy bookend so the harness state stays clean.
191 yield* stack.destroy();
192 }).pipe(logLevel),
193 { tags: ["provider:cloudflare:zone"] },
194 );
195
196 test.provider.skipIf(!entitledZoneId)(
197 "enables Content Scanning and restores the original status on destroy",
198 (stack) =>
199 Effect.gen(function* () {
200 const zoneId = entitledZoneId!;
201
202 yield* stack.destroy();
203 // Known baseline: Content Scanning defaults to "disabled".
204 yield* setBaseline(zoneId, "disabled");
205
206 const scanning = yield* stack.deploy(
207 Effect.gen(function* () {
208 return yield* Cloudflare.ContentScanning.ContentScanning(
209 "UploadScanning",
210 {
211 zoneId,
212 },
213 );
214 }),
215 );
216
217 expect(scanning.zoneId).toEqual(zoneId);
218 expect(scanning.enabled).toEqual(true);
219 expect(scanning.initialValue).toEqual("disabled");
220
221 const live = yield* getStatus(zoneId);
222 expect(live.value).toEqual("enabled");
223
224 // Update in place — same singleton, initialValue survives.
225 const updated = yield* stack.deploy(
226 Effect.gen(function* () {
227 return yield* Cloudflare.ContentScanning.ContentScanning(
228 "UploadScanning",
229 {
230 zoneId,
231 enabled: false,
232 },
233 );
234 }),
235 );
236 expect(updated.enabled).toEqual(false);
237 expect(updated.initialValue).toEqual("disabled");
238
239 yield* stack.destroy();
240
241 // Destroy restored the status the zone had before we managed it.
242 const restored = yield* getStatus(zoneId);
243 expect(restored.value).toEqual("disabled");
244 }).pipe(logLevel),
245 { timeout: 120_000 },
246 );
247 },
248);