1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Test from "@/Test/Alchemy";
4import * as rules from "@distilled.cloud/cloudflare/rules";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// The scoped API token the test harness mints propagates eventually-
20// consistently across Cloudflare's edge — ride out 403 blips
21// (`Forbidden`, declared in the distilled error union) on the test's
22// own out-of-band verification calls.
23const getList = (accountId: string, listId: string) =>
24 rules.getList({ accountId, listId }).pipe(
25 Effect.retry({
26 while: (e) => e._tag === "Forbidden",
27 schedule: Schedule.exponential("500 millis"),
28 times: 8,
29 }),
30 );
31
32const getItems = (accountId: string, listId: string) =>
33 rules.listListItems.items({ accountId, listId }).pipe(
34 Stream.runCollect,
35 Effect.map((chunk) => Array.from(chunk)),
36 Effect.retry({
37 while: (e) => e._tag === "Forbidden",
38 schedule: Schedule.exponential("500 millis"),
39 times: 8,
40 }),
41 );
42
43const expectGone = (accountId: string, listId: string) =>
44 getList(accountId, listId).pipe(
45 Effect.flatMap(() => Effect.fail({ _tag: "ListNotDeleted" } as const)),
46 // A missing list surfaces as `ListNotFound` (Cloudflare error code
47 // 10001) — that's the success condition here.
48 Effect.catchTag("ListNotFound", () => Effect.void),
49 Effect.retry({
50 while: (e) => e._tag === "ListNotDeleted",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(10),
54 ]),
55 }),
56 );
57
58test.provider(
59 "create and delete an ip list with default name",
60 (stack) =>
61 Effect.gen(function* () {
62 const { accountId } = yield* yield* CloudflareEnvironment;
63
64 yield* stack.destroy();
65
66 const list = yield* stack.deploy(
67 Cloudflare.Rules.List("DefaultList", {
68 kind: "ip",
69 description: "alchemy rules list create test",
70 items: [
71 { ip: "203.0.113.7", comment: "scanner" },
72 { ip: "198.51.100.0/24" },
73 ],
74 }),
75 );
76
77 expect(list.listId).toBeDefined();
78 expect(list.accountId).toEqual(accountId);
79 expect(list.kind).toEqual("ip");
80 expect(list.description).toEqual("alchemy rules list create test");
81 // List names only allow letters, numbers, and underscores.
82 expect(list.name).toMatch(/^[a-zA-Z0-9_]+$/);
83 expect(list.numItems).toEqual(2);
84
85 const live = yield* getList(accountId, list.listId);
86 expect(live.name).toEqual(list.name);
87 expect(live.kind).toEqual("ip");
88
89 const items = yield* getItems(accountId, list.listId);
90 const ips = items
91 .map((item) => ("ip" in item ? item.ip : undefined))
92 .sort();
93 expect(ips).toEqual(["198.51.100.0/24", "203.0.113.7"]);
94
95 yield* stack.destroy();
96
97 yield* expectGone(accountId, list.listId);
98 }).pipe(logLevel),
99 { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] },
100);
101
102test.provider(
103 "update description and items in place (same listId)",
104 (stack) =>
105 Effect.gen(function* () {
106 const { accountId } = yield* yield* CloudflareEnvironment;
107
108 yield* stack.destroy();
109
110 const initial = yield* stack.deploy(
111 Cloudflare.Rules.List("UpdateList", {
112 name: "alchemy_rules_list_update",
113 kind: "ip",
114 description: "v1",
115 items: [{ ip: "203.0.113.1" }, { ip: "203.0.113.2" }],
116 }),
117 );
118
119 expect(initial.name).toEqual("alchemy_rules_list_update");
120 expect(initial.description).toEqual("v1");
121 expect(initial.numItems).toEqual(2);
122
123 const updated = yield* stack.deploy(
124 Cloudflare.Rules.List("UpdateList", {
125 name: "alchemy_rules_list_update",
126 kind: "ip",
127 description: "v2",
128 items: [
129 { ip: "203.0.113.2", comment: "kept" },
130 { ip: "192.0.2.0/24" },
131 ],
132 }),
133 );
134
135 // Same list mutated in place — not a replacement.
136 expect(updated.listId).toEqual(initial.listId);
137 expect(updated.description).toEqual("v2");
138 expect(updated.numItems).toEqual(2);
139
140 const items = yield* getItems(accountId, updated.listId);
141 const ips = items
142 .map((item) => ("ip" in item ? item.ip : undefined))
143 .sort();
144 expect(ips).toEqual(["192.0.2.0/24", "203.0.113.2"]);
145 const kept = items.find(
146 (item) => "ip" in item && item.ip === "203.0.113.2",
147 );
148 expect(kept && "comment" in kept ? kept.comment : undefined).toEqual(
149 "kept",
150 );
151
152 // Redeploying identical props is a no-op (still the same list).
153 const noop = yield* stack.deploy(
154 Cloudflare.Rules.List("UpdateList", {
155 name: "alchemy_rules_list_update",
156 kind: "ip",
157 description: "v2",
158 items: [
159 { ip: "203.0.113.2", comment: "kept" },
160 { ip: "192.0.2.0/24" },
161 ],
162 }),
163 );
164 expect(noop.listId).toEqual(initial.listId);
165
166 yield* stack.destroy();
167
168 yield* expectGone(accountId, initial.listId);
169 }).pipe(logLevel),
170 { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] },
171);
172
173test.provider(
174 "changing kind replaces the list",
175 (stack) =>
176 Effect.gen(function* () {
177 const { accountId } = yield* yield* CloudflareEnvironment;
178
179 yield* stack.destroy();
180
181 const ipList = yield* stack.deploy(
182 Cloudflare.Rules.List("ReplaceList", {
183 name: "alchemy_rules_list_replace",
184 kind: "ip",
185 items: [{ ip: "203.0.113.9" }],
186 }),
187 );
188 expect(ipList.kind).toEqual("ip");
189
190 const hostnameList = yield* stack.deploy(
191 Cloudflare.Rules.List("ReplaceList", {
192 name: "alchemy_rules_list_replace",
193 kind: "hostname",
194 items: [{ hostname: { urlHostname: "example.com" } }],
195 }),
196 );
197
198 // Kind is immutable — the engine must replace the list.
199 expect(hostnameList.listId).not.toEqual(ipList.listId);
200 expect(hostnameList.kind).toEqual("hostname");
201
202 // The old list is gone, the new one is live.
203 yield* expectGone(accountId, ipList.listId);
204 const live = yield* getList(accountId, hostnameList.listId);
205 expect(live.kind).toEqual("hostname");
206
207 yield* stack.destroy();
208
209 yield* expectGone(accountId, hostnameList.listId);
210 }).pipe(logLevel),
211 { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] },
212);
213
214test.provider(
215 "recreates after out-of-band delete",
216 (stack) =>
217 Effect.gen(function* () {
218 const { accountId } = yield* yield* CloudflareEnvironment;
219
220 yield* stack.destroy();
221
222 const list = yield* stack.deploy(
223 Cloudflare.Rules.List("HealList", {
224 name: "alchemy_rules_list_heal",
225 kind: "ip",
226 items: [{ ip: "203.0.113.11" }],
227 }),
228 );
229
230 // Delete the list out-of-band. A redeploy with identical props is a
231 // planner no-op, so change a prop to force reconcile — it must observe
232 // the list as missing and recreate it instead of failing on a 404.
233 yield* rules.deleteList({ accountId, listId: list.listId }).pipe(
234 Effect.retry({
235 while: (e) => e._tag === "Forbidden",
236 schedule: Schedule.exponential("500 millis"),
237 times: 8,
238 }),
239 );
240
241 const healed = yield* stack.deploy(
242 Cloudflare.Rules.List("HealList", {
243 name: "alchemy_rules_list_heal",
244 kind: "ip",
245 description: "healed",
246 items: [{ ip: "203.0.113.11" }],
247 }),
248 );
249
250 expect(healed.listId).not.toEqual(list.listId);
251 expect(healed.description).toEqual("healed");
252 const live = yield* getList(accountId, healed.listId);
253 expect(live.name).toEqual("alchemy_rules_list_heal");
254
255 yield* stack.destroy();
256
257 yield* expectGone(accountId, healed.listId);
258 }).pipe(logLevel),
259 { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] },
260);
261
262test.provider(
263 "adopts an existing list with the same name",
264 (stack) =>
265 Effect.gen(function* () {
266 const { accountId } = yield* yield* CloudflareEnvironment;
267
268 yield* stack.destroy();
269
270 // Create a list out-of-band with the exact name the resource will use —
271 // the create conflict (`ListAlreadyExists`, code 10021) must resolve by
272 // adopting the existing list.
273 const preexisting = yield* rules
274 .createList({
275 accountId,
276 name: "alchemy_rules_list_adopt",
277 kind: "ip",
278 })
279 .pipe(
280 Effect.retry({
281 while: (e) => e._tag === "Forbidden",
282 schedule: Schedule.exponential("500 millis"),
283 times: 8,
284 }),
285 Effect.catchTag("ListAlreadyExists", () =>
286 rules.listLists.items({ accountId }).pipe(
287 Stream.filter((l) => l.name === "alchemy_rules_list_adopt"),
288 Stream.runHead,
289 Effect.map(Option.getOrThrow),
290 ),
291 ),
292 );
293
294 const adopted = yield* stack.deploy(
295 Cloudflare.Rules.List("AdoptList", {
296 name: "alchemy_rules_list_adopt",
297 kind: "ip",
298 description: "adopted",
299 items: [{ ip: "203.0.113.21" }],
300 }),
301 );
302
303 expect(adopted.listId).toEqual(preexisting.id);
304 expect(adopted.description).toEqual("adopted");
305 expect(adopted.numItems).toEqual(1);
306
307 yield* stack.destroy();
308
309 yield* expectGone(accountId, preexisting.id);
310 }).pipe(logLevel),
311 { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] },
312);