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 aiSecurity from "@distilled.cloud/cloudflare/ai-security";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const zoneName =
20 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
21
22// AI Security for Apps (Firewall for AI) is entitlement-gated — on the
23// standard testing account every call fails with "not entitled to access
24// this resource" (code 13101), surfaced as the typed `AiSecurityNotEntitled`
25// error. The full lifecycle test below is gated behind an entitled zone id
26// supplied via env.
27const entitledZoneId = process.env.CLOUDFLARE_TEST_AI_SECURITY_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// A freshly minted scoped token propagates eventually-consistently across
41// Cloudflare's edge — retry the typed `Forbidden` blips on out-of-band calls.
42const forbiddenRetrySchedule = Schedule.exponential("500 millis");
43
44const getSettings = (zoneId: string) =>
45 aiSecurity.getAiSecurity({ zoneId }).pipe(
46 Effect.retry({
47 while: (e) => e._tag === "Forbidden",
48 schedule: forbiddenRetrySchedule,
49 times: 8,
50 }),
51 );
52
53// Normalize the setting to a known baseline so each run starts from the
54// same cloud state regardless of what a previous run left behind.
55const setBaseline = (zoneId: string, enabled: boolean) =>
56 aiSecurity.putAiSecurity({ zoneId, enabled }).pipe(
57 Effect.retry({
58 while: (e) => e._tag === "Forbidden",
59 schedule: forbiddenRetrySchedule,
60 times: 8,
61 }),
62 );
63
64test.provider(
65 "surfaces the typed AiSecurityNotEntitled error on unentitled accounts",
66 (stack) =>
67 Effect.gen(function* () {
68 const zoneId = yield* resolveZoneId;
69
70 yield* stack.destroy();
71
72 // Settings may be entitled independently of Custom Topics. The
73 // unentitled path still has to surface the typed tag (never the
74 // catch-all); an entitled zone returns the singleton instead.
75 const settings = yield* getSettings(zoneId).pipe(
76 Effect.catchTag("AiSecurityNotEntitled", () =>
77 Effect.succeed(undefined),
78 ),
79 );
80 if (settings !== undefined) {
81 expect(typeof (settings.enabled ?? false)).toBe("boolean");
82 }
83
84 const topicsError = yield* aiSecurity.getCustomTopic({ zoneId }).pipe(
85 Effect.retry({
86 while: (e) => e._tag === "Forbidden",
87 schedule: forbiddenRetrySchedule,
88 times: 8,
89 }),
90 Effect.flip,
91 );
92 expect(topicsError._tag).toEqual("AiSecurityNotEntitled");
93
94 yield* stack.destroy();
95 }).pipe(logLevel),
96 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
97);
98
99// Canonical `list()` test (zone-scoped singleton): there is no account-wide
100// API for this per-zone setting, so `list()` enumerates every zone via
101// `listAllZones` and reads the singleton in each, skipping zones that reject
102// the route (AI Security is entitlement-gated). It always returns a well-typed
103// `Attributes[]` — empty on the unentitled testing account, non-empty and
104// containing the entitled zone when one is supplied via env.
105test.provider(
106 "list enumerates the setting across all entitled zones",
107 (stack) =>
108 Effect.gen(function* () {
109 const provider = yield* Provider.findProvider(
110 Cloudflare.AI.SecuritySettings,
111 );
112 const all = yield* provider.list();
113
114 expect(Array.isArray(all)).toBe(true);
115 // Every element is the full Attributes shape `read` produces.
116 for (const settings of all) {
117 expect(typeof settings.zoneId).toBe("string");
118 expect(typeof settings.enabled).toBe("boolean");
119 expect(typeof settings.initialEnabled).toBe("boolean");
120 }
121 // On an entitled account, the supplied zone must appear in the result.
122 if (entitledZoneId) {
123 expect(all.some((s) => s.zoneId === entitledZoneId)).toBe(true);
124 }
125
126 yield* stack.destroy();
127 }).pipe(logLevel),
128 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
129);
130
131test.provider.skipIf(!entitledZoneId)(
132 "pins enabled, updates in place, and restores the original on destroy",
133 (stack) =>
134 Effect.gen(function* () {
135 const zoneId = entitledZoneId!;
136
137 yield* stack.destroy();
138 // Known baseline: AI Security disabled.
139 yield* setBaseline(zoneId, false);
140
141 const created = yield* stack.deploy(
142 Effect.gen(function* () {
143 return yield* Cloudflare.AI.SecuritySettings("AiSecurity", {
144 zoneId,
145 enabled: true,
146 });
147 }),
148 );
149
150 expect(created.zoneId).toEqual(zoneId);
151 expect(created.enabled).toEqual(true);
152 // The pre-management value was captured for restore-on-destroy.
153 expect(created.initialEnabled).toEqual(false);
154
155 // Out-of-band verification via the distilled API.
156 const live = yield* getSettings(zoneId);
157 expect(live.enabled ?? false).toEqual(true);
158
159 // Update in place — same singleton, initial value survives.
160 const updated = yield* stack.deploy(
161 Effect.gen(function* () {
162 return yield* Cloudflare.AI.SecuritySettings("AiSecurity", {
163 zoneId,
164 enabled: false,
165 });
166 }),
167 );
168 expect(updated.enabled).toEqual(false);
169 expect(updated.initialEnabled).toEqual(false);
170
171 const toggled = yield* getSettings(zoneId);
172 expect(toggled.enabled ?? false).toEqual(false);
173
174 yield* stack.destroy();
175
176 // Destroy restored the value the zone had before we managed it.
177 const restored = yield* getSettings(zoneId);
178 expect(restored.enabled ?? false).toEqual(false);
179 }).pipe(logLevel),
180 {
181 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
182 timeout: 120_000,
183 },
184);