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// Custom scan expressions require WAF Content Scanning (an Enterprise paid
22// add-on) to be enabled on the zone. On the testing account's zone every
23// payload call fails with "File Upload Scan not enabled", surfaced as the
24// typed `ContentScanningNotEnabled` error. The full lifecycle test below is
25// gated behind an entitled zone id supplied via env.
26const entitledZoneId = process.env.CLOUDFLARE_TEST_CONTENT_SCANNING_ZONE_ID;
27
28const resolveZoneId = Effect.gen(function* () {
29 const { accountId } = yield* yield* CloudflareEnvironment;
30 const zone = yield* findZoneByName({ accountId, name: zoneName });
31 if (!zone) {
32 return yield* Effect.die(
33 new Error(`zone "${zoneName}" not found in account`),
34 );
35 }
36 return zone.id;
37});
38
39// Ride out eventual-consistency 403 blips on the test's own out-of-band
40// calls by retrying the typed `Forbidden` error.
41const forbiddenRetrySchedule = Schedule.exponential("500 millis");
42
43const listExpressions = (zoneId: string) =>
44 contentScanning.listPayloads({ zoneId }).pipe(
45 Effect.retry({
46 while: (e) => e._tag === "Forbidden",
47 schedule: forbiddenRetrySchedule,
48 times: 8,
49 }),
50 Effect.map((r) => r.result),
51 );
52
53// Both cases enable the same zone-level content-scanning singleton as a prerequisite; run them serially so they don't fight over that singleton under the global concurrent test config.
54describe.sequential(
55 "Expression",
56 {
57 tags: [
58 "provider:cloudflare",
59 "provider:cloudflare:contentscanning",
60 "live",
61 ],
62 },
63 () => {
64 test.provider(
65 "surfaces the typed ContentScanningNotEnabled error when scanning is disabled",
66 (stack) =>
67 Effect.gen(function* () {
68 const zoneId = yield* resolveZoneId;
69
70 yield* stack.destroy();
71
72 // The standard testing zone has Content Scanning disabled (and lacks
73 // the add-on entirely) — payload calls must fail with the typed tag.
74 const error = yield* contentScanning.listPayloads({ zoneId }).pipe(
75 Effect.retry({
76 while: (e) => e._tag === "Forbidden",
77 schedule: forbiddenRetrySchedule,
78 times: 8,
79 }),
80 Effect.flip,
81 );
82 expect(error._tag).toEqual("ContentScanningNotEnabled");
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 { tags: ["provider:cloudflare:zone"] },
87 );
88
89 // Requires WAF Content Scanning (Enterprise paid add-on) on the zone — without it payload
90 // calls fail with the typed ContentScanningNotEnabled. Unlock with CLOUDFLARE_TEST_CONTENT_SCANNING_ZONE_ID=<zone id>.
91 test.provider.skipIf(!entitledZoneId)(
92 "creates a custom expression, replaces on payload change, destroys cleanly",
93 (stack) =>
94 Effect.gen(function* () {
95 const zoneId = entitledZoneId!;
96
97 yield* stack.destroy();
98
99 const firstPayload =
100 'lookup_json_string(http.request.body.raw, "file")';
101 const secondPayload =
102 'lookup_json_string(http.request.body.raw, "document")';
103
104 // Scanning must be enabled for payload calls; the expression depends
105 // on the singleton through its zoneId output.
106 const first = yield* stack.deploy(
107 Effect.gen(function* () {
108 const scanning =
109 yield* Cloudflare.ContentScanning.ContentScanning(
110 "UploadScanning",
111 {
112 zoneId,
113 },
114 );
115 return yield* Cloudflare.ContentScanning.Expression("ScanField", {
116 zoneId: scanning.zoneId,
117 payload: firstPayload,
118 });
119 }),
120 );
121
122 expect(first.zoneId).toEqual(zoneId);
123 expect(first.payload).toEqual(firstPayload);
124 expect(first.expressionId).not.toEqual("");
125
126 // Out-of-band verification via the distilled API.
127 const live = yield* listExpressions(zoneId);
128 expect(live.some((e) => e.id === first.expressionId)).toBe(true);
129
130 // Changing the payload is a replacement — there is no update
131 // endpoint, so a new expression is created and the old one deleted.
132 const replaced = yield* stack.deploy(
133 Effect.gen(function* () {
134 const scanning =
135 yield* Cloudflare.ContentScanning.ContentScanning(
136 "UploadScanning",
137 {
138 zoneId,
139 },
140 );
141 return yield* Cloudflare.ContentScanning.Expression("ScanField", {
142 zoneId: scanning.zoneId,
143 payload: secondPayload,
144 });
145 }),
146 );
147
148 expect(replaced.payload).toEqual(secondPayload);
149 expect(replaced.expressionId).not.toEqual(first.expressionId);
150
151 const afterReplace = yield* listExpressions(zoneId);
152 expect(afterReplace.some((e) => e.id === replaced.expressionId)).toBe(
153 true,
154 );
155 expect(afterReplace.some((e) => e.id === first.expressionId)).toBe(
156 false,
157 );
158
159 yield* stack.destroy();
160
161 // The expression is gone; the singleton was restored to its
162 // pre-management status by its own destroy.
163 const status = yield* contentScanning.getContentScanning({ zoneId });
164 if (status.value === "enabled") {
165 // Zone was already enabled before the test — expressions must
166 // still be cleaned up.
167 const remaining = yield* listExpressions(zoneId);
168 expect(remaining.some((e) => e.id === replaced.expressionId)).toBe(
169 false,
170 );
171 }
172 }).pipe(logLevel),
173 { timeout: 120_000 },
174 );
175
176 // list() fans out over every zone and skips zones without Content Scanning
177 // (typed ContentScanningNotEnabled/Forbidden) or deleted zones (InvalidRoute).
178 // The standing test zone has scanning disabled, so this returns a well-typed
179 // array (empty for un-entitled zones) without throwing — proving the
180 // pagination + typed-skip wiring end-to-end.
181 test.provider(
182 "list returns a well-typed array across all zones",
183 (stack) =>
184 Effect.gen(function* () {
185 yield* stack.destroy();
186
187 const provider = yield* Provider.findProvider(
188 Cloudflare.ContentScanning.Expression,
189 );
190 const all = yield* provider.list();
191
192 expect(Array.isArray(all)).toBe(true);
193 for (const item of all) {
194 expect(typeof item.zoneId).toBe("string");
195 expect(typeof item.payload).toBe("string");
196 expect(typeof item.expressionId).toBe("string");
197 }
198
199 yield* stack.destroy();
200 }).pipe(logLevel),
201 { timeout: 120_000 },
202 );
203
204 // Requires WAF Content Scanning (Enterprise paid add-on) on the zone — without
205 // it payload calls fail with the typed ContentScanningNotEnabled. Unlock with
206 // CLOUDFLARE_TEST_CONTENT_SCANNING_ZONE_ID=<zone id>.
207 test.provider.skipIf(!entitledZoneId)(
208 "list enumerates the deployed expression",
209 (stack) =>
210 Effect.gen(function* () {
211 const zoneId = entitledZoneId!;
212
213 yield* stack.destroy();
214
215 const payload = 'lookup_json_string(http.request.body.raw, "file")';
216
217 const deployed = yield* stack.deploy(
218 Effect.gen(function* () {
219 const scanning =
220 yield* Cloudflare.ContentScanning.ContentScanning(
221 "UploadScanning",
222 { zoneId },
223 );
224 return yield* Cloudflare.ContentScanning.Expression("ListField", {
225 zoneId: scanning.zoneId,
226 payload,
227 });
228 }),
229 );
230
231 const provider = yield* Provider.findProvider(
232 Cloudflare.ContentScanning.Expression,
233 );
234 const all = yield* provider.list();
235
236 const found = all.find(
237 (e) => e.expressionId === deployed.expressionId,
238 );
239 expect(found).toBeDefined();
240 expect(found?.zoneId).toEqual(zoneId);
241 expect(found?.payload).toEqual(payload);
242
243 yield* stack.destroy();
244 }).pipe(logLevel),
245 { timeout: 120_000 },
246 );
247 },
248);