| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as fraud from "@distilled.cloud/cloudflare/fraud"; |
| 7 | import { describe, expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 12 | |
| 13 | const logLevel = Effect.provideService( |
| 14 | MinimumLogLevel, |
| 15 | process.env.DEBUG ? "Debug" : "Info", |
| 16 | ); |
| 17 | |
| 18 | const 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. |
| 27 | const entitled = !!process.env.CLOUDFLARE_TEST_FRAUD_DETECTION; |
| 28 | |
| 29 | const 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. |
| 43 | const 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. |
| 53 | describe.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 | ); |