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 getTopics = (zoneId: string) =>
45 aiSecurity.getCustomTopic({ zoneId }).pipe(
46 Effect.retry({
47 while: (e) => e._tag === "Forbidden",
48 schedule: forbiddenRetrySchedule,
49 times: 8,
50 }),
51 );
52
53// Normalize the list to a known baseline so each run starts from the same
54// cloud state regardless of what a previous run left behind.
55const setBaseline = (
56 zoneId: string,
57 topics: { label: string; topic: string }[],
58) =>
59 aiSecurity.putCustomTopic({ zoneId, topics }).pipe(
60 Effect.retry({
61 while: (e) => e._tag === "Forbidden",
62 schedule: forbiddenRetrySchedule,
63 times: 8,
64 }),
65 );
66
67test.provider(
68 "surfaces the typed AiSecurityNotEntitled error on unentitled accounts",
69 (stack) =>
70 Effect.gen(function* () {
71 const zoneId = yield* resolveZoneId;
72
73 yield* stack.destroy();
74
75 // The testing account lacks the AI Security entitlement — both the
76 // read and the write must fail with the typed entitlement tag.
77 const readError = yield* getTopics(zoneId).pipe(Effect.flip);
78 expect(readError._tag).toEqual("AiSecurityNotEntitled");
79
80 const writeError = yield* setBaseline(zoneId, []).pipe(Effect.flip);
81 expect(writeError._tag).toEqual("AiSecurityNotEntitled");
82
83 yield* stack.destroy();
84 }).pipe(logLevel),
85 { tags: ["provider:cloudflare", "provider:cloudflare:zone", "live"] },
86);
87
88// Canonical `list()` test (zone-scoped singleton): there is no account-wide
89// API for this per-zone list, so `list()` enumerates every zone via
90// `listAllZones` and reads the singleton in each, skipping zones that reject
91// with the typed `AiSecurityNotEntitled` / `ZoneNotAuthorized` / `Forbidden`
92// tags. On the unentitled testing account every zone is skipped, so the
93// result is an empty array — the assertion is that `list()` resolves to an
94// array (proving the typed skip path) rather than throwing. Presence of the
95// standing test zone is asserted only on an entitled account (env-gated).
96test.provider(
97 "list enumerates the custom topics across all zones",
98 (stack) =>
99 Effect.gen(function* () {
100 const provider = yield* Provider.findProvider(Cloudflare.AI.CustomTopics);
101 const all = yield* provider.list();
102
103 expect(Array.isArray(all)).toBe(true);
104
105 if (entitledZoneId) {
106 expect(all.some((t) => t.zoneId === entitledZoneId)).toBe(true);
107 }
108
109 // `stack` is unused (the singleton always exists on every entitled zone),
110 // but keep the destroy bookends so the harness state stays clean.
111 yield* stack.destroy();
112 }).pipe(logLevel),
113 { tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"] },
114);
115
116test.provider.skipIf(!entitledZoneId)(
117 "sets topics, updates the list in place, and restores on destroy",
118 (stack) =>
119 Effect.gen(function* () {
120 const zoneId = entitledZoneId!;
121
122 yield* stack.destroy();
123 // Known baseline: no custom topics.
124 yield* setBaseline(zoneId, []);
125
126 const created = yield* stack.deploy(
127 Effect.gen(function* () {
128 return yield* Cloudflare.AI.CustomTopics("Topics", {
129 zoneId,
130 topics: [
131 {
132 label: "billing",
133 topic: "Questions about invoices and payments",
134 },
135 { label: "abuse", topic: "Harassment or abusive language" },
136 ],
137 });
138 }),
139 );
140
141 expect(created.zoneId).toEqual(zoneId);
142 expect(created.topics).toHaveLength(2);
143 // The pre-management list was captured for restore-on-destroy.
144 expect(created.initialTopics).toEqual([]);
145
146 // Out-of-band verification via the distilled API.
147 const live = yield* getTopics(zoneId);
148 expect(live.topics ?? []).toHaveLength(2);
149
150 // Update in place — the PUT replaces the whole list.
151 const updated = yield* stack.deploy(
152 Effect.gen(function* () {
153 return yield* Cloudflare.AI.CustomTopics("Topics", {
154 zoneId,
155 topics: [
156 { label: "support", topic: "Questions about product support" },
157 ],
158 });
159 }),
160 );
161 expect(updated.topics).toHaveLength(1);
162 expect(updated.topics[0]?.label).toEqual("support");
163 expect(updated.initialTopics).toEqual([]);
164
165 const replacedList = yield* getTopics(zoneId);
166 expect(replacedList.topics ?? []).toHaveLength(1);
167
168 yield* stack.destroy();
169
170 // Destroy restored the (empty) list the zone had before we managed it.
171 const restored = yield* getTopics(zoneId);
172 expect(restored.topics ?? []).toEqual([]);
173 }).pipe(logLevel),
174 {
175 tags: ["provider:cloudflare", "provider:cloudflare:ai", "live"],
176 timeout: 120_000,
177 },
178);