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 fraud from "@distilled.cloud/cloudflare/fraud";
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// Fraud Detection (User Profiles) is a beta, subscription-gated product.
22// On the standard testing zone any meaningful PUT (writing `user_profiles`,
23// non-empty `username_expressions`, or `authentication_settings`) fails with
24// Cloudflare error code 10400 — "A fraud detection subscription is required"
25// — surfaced as the typed `FraudDetectionNotEntitled` error. The full
26// write lifecycle test below is gated behind an entitled zone via env.
27const entitled = !!process.env.CLOUDFLARE_TEST_FRAUD_DETECTION;
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 (`Forbidden`,
42// declared in the distilled error union) on out-of-band verification calls.
43const getSettings = (zoneId: string) =>
44 fraud.getFraud({ zoneId }).pipe(
45 Effect.retry({
46 while: (e) => e._tag === "Forbidden",
47 schedule: Schedule.exponential("500 millis"),
48 times: 8,
49 }),
50 );
51
52// Both cases mutate the same zone-level fraud-detection settings singleton; run them serially so they don't corrupt each other's captured baseline under the global concurrent test config.
53describe.sequential(
54 "DetectionSettings",
55 {
56 tags: [
57 "provider:cloudflare",
58 "provider:cloudflare:fraud",
59 "provider:cloudflare:zone",
60 "live",
61 ],
62 },
63 () => {
64 test.provider(
65 "adopts the zone singleton without writing and restores nothing on destroy",
66 (stack) =>
67 Effect.gen(function* () {
68 const zoneId = yield* resolveZoneId;
69
70 yield* stack.destroy();
71
72 const before = yield* getSettings(zoneId);
73
74 // 1. Create — adopt the singleton with no settings set: reconcile
75 // observes but never PUTs.
76 const adopted = yield* stack.deploy(
77 Effect.gen(function* () {
78 return yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
79 zoneId,
80 });
81 }),
82 );
83 expect(adopted.zoneId).toEqual(zoneId);
84 // The snapshot captured the pre-management state.
85 expect(adopted.initialSettings.userProfiles ?? null).toEqual(
86 before.userProfiles ?? null,
87 );
88 expect(adopted.initialSettings.usernameExpressions ?? null).toEqual(
89 before.usernameExpressions ?? null,
90 );
91
92 const afterDeploy = yield* getSettings(zoneId);
93 expect(afterDeploy.userProfiles ?? null).toEqual(
94 before.userProfiles ?? null,
95 );
96 expect(afterDeploy.usernameExpressions ?? null).toEqual(
97 before.usernameExpressions ?? null,
98 );
99
100 // 2. In-place update — set `usernameExpressions` to the value the
101 // zone already has: reconcile diffs observed vs desired and
102 // skips the PUT (which would otherwise require a subscription).
103 const updated = yield* stack.deploy(
104 Effect.gen(function* () {
105 return yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
106 zoneId,
107 usernameExpressions: [...(before.usernameExpressions ?? [])],
108 });
109 }),
110 );
111 expect(updated.zoneId).toEqual(zoneId);
112 expect(updated.usernameExpressions ?? []).toEqual(
113 before.usernameExpressions ?? [],
114 );
115 // The initial snapshot stays sticky across updates.
116 expect(updated.initialSettings.userProfiles ?? null).toEqual(
117 before.userProfiles ?? null,
118 );
119
120 // 3. Destroy — nothing was written, so nothing is restored and the
121 // live settings are untouched.
122 yield* stack.destroy();
123
124 const afterDestroy = yield* getSettings(zoneId);
125 expect(afterDestroy.userProfiles ?? null).toEqual(
126 before.userProfiles ?? null,
127 );
128 expect(afterDestroy.usernameExpressions ?? null).toEqual(
129 before.usernameExpressions ?? null,
130 );
131 }).pipe(logLevel),
132 { timeout: 120_000 },
133 );
134
135 // Unentitlement probe — pins the typed FraudDetectionNotEntitled rejection (code 10400)
136 // and skips on entitled zones, where the PUT would succeed and mutate live settings.
137 test.provider.skipIf(entitled)(
138 "surfaces the typed FraudDetectionNotEntitled error on unentitled zones",
139 (stack) =>
140 Effect.gen(function* () {
141 const zoneId = yield* resolveZoneId;
142
143 yield* stack.destroy();
144
145 // Writing `user_profiles` (even "disabled") requires a fraud
146 // detection subscription — the distilled PUT must fail with the
147 // typed entitlement tag (Cloudflare error code 10400). Reads on the
148 // same zone succeed, proving the gate is on writes, not the token.
149 const error = yield* fraud
150 .putFraud({ zoneId, userProfiles: "disabled" })
151 .pipe(
152 Effect.retry({
153 while: (e) => e._tag === "Forbidden",
154 schedule: Schedule.exponential("500 millis"),
155 times: 8,
156 }),
157 Effect.flip,
158 );
159 expect(error._tag).toEqual("FraudDetectionNotEntitled");
160
161 const settings = yield* getSettings(zoneId);
162 expect(settings.userProfiles ?? "disabled").toEqual(
163 settings.userProfiles ?? "disabled",
164 );
165
166 yield* stack.destroy();
167 }).pipe(logLevel),
168 { timeout: 120_000 },
169 );
170
171 // Requires a zone with a Fraud Detection (beta) subscription — unentitled zones fail with
172 // the typed FraudDetectionNotEntitled (code 10400). Unlock with CLOUDFLARE_TEST_FRAUD_DETECTION=1.
173 test.provider.skipIf(!entitled)(
174 "manages fraud detection settings and restores them on destroy",
175 (stack) =>
176 Effect.gen(function* () {
177 const zoneId = yield* resolveZoneId;
178
179 yield* stack.destroy();
180
181 const original = yield* getSettings(zoneId);
182
183 const expression =
184 'lookup_json_string(http.request.body.raw, "username")';
185
186 yield* Effect.gen(function* () {
187 // 1. Create — enable user profiles with one username expression.
188 const created = yield* stack.deploy(
189 Effect.gen(function* () {
190 return yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
191 zoneId,
192 userProfiles: "enabled",
193 usernameExpressions: [expression],
194 });
195 }),
196 );
197 expect(created.zoneId).toEqual(zoneId);
198 expect(created.userProfiles).toEqual("enabled");
199 expect(created.usernameExpressions).toEqual([expression]);
200 expect(created.initialSettings.userProfiles ?? null).toEqual(
201 original.userProfiles ?? null,
202 );
203
204 const live1 = yield* getSettings(zoneId);
205 expect(live1.userProfiles).toEqual("enabled");
206 expect(live1.usernameExpressions).toEqual([expression]);
207
208 // 2. In-place update — add authentication outcome classification;
209 // same singleton (same zoneId), sticky snapshot.
210 const updated = yield* stack.deploy(
211 Effect.gen(function* () {
212 return yield* Cloudflare.Fraud.DetectionSettings("Fraud", {
213 zoneId,
214 userProfiles: "enabled",
215 usernameExpressions: [expression],
216 authenticationSettings: {
217 successCriteria: {
218 kind: "status_code",
219 statusCodes: [200],
220 },
221 failureCriteria: {
222 kind: "status_code",
223 statusCodes: [401, 403],
224 },
225 },
226 });
227 }),
228 );
229 expect(updated.authenticationSettings?.successCriteria).toEqual({
230 kind: "status_code",
231 statusCodes: [200],
232 });
233 expect(updated.initialSettings.userProfiles ?? null).toEqual(
234 original.userProfiles ?? null,
235 );
236
237 const live2 = yield* getSettings(zoneId);
238 expect(
239 live2.authenticationSettings?.successCriteria?.statusCodes,
240 ).toEqual([200]);
241
242 // 3. Destroy — the managed fields are restored to the snapshot.
243 yield* stack.destroy();
244
245 const after = yield* getSettings(zoneId);
246 expect(after.userProfiles ?? null).toEqual(
247 original.userProfiles ?? null,
248 );
249 expect(after.usernameExpressions ?? null).toEqual(
250 original.usernameExpressions ?? null,
251 );
252 }).pipe(
253 Effect.ensuring(
254 fraud
255 .putFraud({
256 zoneId,
257 userProfiles:
258 original.userProfiles === "enabled"
259 ? "enabled"
260 : "disabled",
261 usernameExpressions: [
262 ...(original.usernameExpressions ?? []),
263 ],
264 })
265 .pipe(Effect.ignore),
266 ),
267 );
268
269 yield* stack.destroy();
270 }).pipe(logLevel),
271 { timeout: 240_000 },
272 );
273
274 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
275 // API for this per-zone setting, so `list()` enumerates every zone via
276 // `listAllZones` and reads the singleton in each. `getFraud` is a read and
277 // never trips the entitlement gate (which lives on `putFraud`), so this
278 // read-only assertion always runs. Assert the result is non-empty and
279 // contains the standing test zone.
280 test.provider("list enumerates the settings across all zones", (stack) =>
281 Effect.gen(function* () {
282 const zoneId = yield* resolveZoneId;
283
284 const provider = yield* Provider.findProvider(
285 Cloudflare.Fraud.DetectionSettings,
286 );
287 const all = yield* provider.list();
288
289 expect(all.length).toBeGreaterThan(0);
290 expect(all.some((s) => s.zoneId === zoneId)).toBe(true);
291
292 // `stack` is unused here (the singleton always exists on every zone),
293 // but keep the destroy bookend so the harness state stays clean.
294 yield* stack.destroy();
295 }).pipe(logLevel),
296 );
297 },
298);