1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Test from "@/Test/Alchemy";
4import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Cloudflare.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// Ride out 403 blips (`Forbidden`) while the harness-minted token
18// propagates across Cloudflare's edge.
19const getList = (accountId: string, listId: string) =>
20 zeroTrust.getGatewayList({ accountId, listId }).pipe(
21 Effect.retry({
22 while: (e) => e._tag === "Forbidden",
23 schedule: Schedule.exponential("500 millis"),
24 times: 8,
25 }),
26 );
27
28// A deleted list surfaces as `GatewayListNotFound` (Cloudflare code 2218).
29const expectGone = (accountId: string, listId: string) =>
30 getList(accountId, listId).pipe(
31 Effect.flatMap(() => Effect.fail({ _tag: "ListNotDeleted" } as const)),
32 Effect.catchTag("GatewayListNotFound", () => Effect.void),
33 Effect.retry({
34 while: (e) => e._tag === "ListNotDeleted",
35 schedule: Schedule.max([
36 Schedule.exponential("500 millis"),
37 Schedule.recurs(10),
38 ]),
39 }),
40 );
41
42const itemValues = (items: ReadonlyArray<{ value: string }>) =>
43 items.map((i) => i.value).sort();
44
45test.provider(
46 "create, verify, and destroy a DOMAIN list",
47 (stack) =>
48 Effect.gen(function* () {
49 const { accountId } = yield* yield* CloudflareEnvironment;
50
51 yield* stack.destroy();
52
53 const list = yield* stack.deploy(
54 Cloudflare.Gateway.List("BasicList", {
55 name: "alchemy-zt-list-basic",
56 type: "DOMAIN",
57 description: "alchemy test list",
58 items: [
59 { value: "a.alchemy-test.example" },
60 { value: "b.alchemy-test.example" },
61 ],
62 }),
63 );
64
65 expect(list.listId).toBeTruthy();
66 expect(list.accountId).toEqual(accountId);
67 expect(list.type).toEqual("DOMAIN");
68 expect(itemValues(list.items)).toEqual([
69 "a.alchemy-test.example",
70 "b.alchemy-test.example",
71 ]);
72
73 const live = yield* getList(accountId, list.listId);
74 expect(live.name).toEqual("alchemy-zt-list-basic");
75 expect(live.description).toEqual("alchemy test list");
76 expect(itemValues((live.items ?? []) as { value: string }[])).toEqual([
77 "a.alchemy-test.example",
78 "b.alchemy-test.example",
79 ]);
80
81 yield* stack.destroy();
82 yield* expectGone(accountId, list.listId);
83 }).pipe(logLevel),
84 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
85);
86
87test.provider(
88 "update items, description, and name in place",
89 (stack) =>
90 Effect.gen(function* () {
91 const { accountId } = yield* yield* CloudflareEnvironment;
92
93 yield* stack.destroy();
94
95 const initial = yield* stack.deploy(
96 Cloudflare.Gateway.List("UpdateList", {
97 name: "alchemy-zt-list-update",
98 type: "DOMAIN",
99 items: [
100 { value: "keep.alchemy-test.example" },
101 { value: "remove.alchemy-test.example" },
102 ],
103 }),
104 );
105
106 const updated = yield* stack.deploy(
107 Cloudflare.Gateway.List("UpdateList", {
108 name: "alchemy-zt-list-update-v2",
109 type: "DOMAIN",
110 description: "now with a description",
111 items: [
112 { value: "keep.alchemy-test.example" },
113 { value: "add.alchemy-test.example" },
114 ],
115 }),
116 );
117
118 // Same list mutated in place — not a replacement.
119 expect(updated.listId).toEqual(initial.listId);
120 expect(updated.name).toEqual("alchemy-zt-list-update-v2");
121 expect(itemValues(updated.items)).toEqual([
122 "add.alchemy-test.example",
123 "keep.alchemy-test.example",
124 ]);
125
126 const live = yield* getList(accountId, updated.listId);
127 expect(live.name).toEqual("alchemy-zt-list-update-v2");
128 expect(live.description).toEqual("now with a description");
129 expect(itemValues((live.items ?? []) as { value: string }[])).toEqual([
130 "add.alchemy-test.example",
131 "keep.alchemy-test.example",
132 ]);
133
134 // Redeploying identical props is a no-op (still the same list).
135 const noop = yield* stack.deploy(
136 Cloudflare.Gateway.List("UpdateList", {
137 name: "alchemy-zt-list-update-v2",
138 type: "DOMAIN",
139 description: "now with a description",
140 items: [
141 { value: "keep.alchemy-test.example" },
142 { value: "add.alchemy-test.example" },
143 ],
144 }),
145 );
146 expect(noop.listId).toEqual(initial.listId);
147
148 yield* stack.destroy();
149 yield* expectGone(accountId, initial.listId);
150 }).pipe(logLevel),
151 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
152);
153
154test.provider(
155 "changing the type replaces the list",
156 (stack) =>
157 Effect.gen(function* () {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159
160 yield* stack.destroy();
161
162 const domainList = yield* stack.deploy(
163 Cloudflare.Gateway.List("ReplaceList", {
164 name: "alchemy-zt-list-replace",
165 type: "DOMAIN",
166 items: [{ value: "x.alchemy-test.example" }],
167 }),
168 );
169
170 // The name is the resource's cold-read identity, so a replacement
171 // (type change) pairs with a rename — keeping the old name would make
172 // the engine find the doomed sibling and refuse to adopt it.
173 const ipList = yield* stack.deploy(
174 Cloudflare.Gateway.List("ReplaceList", {
175 name: "alchemy-zt-list-replace-ip",
176 type: "IP",
177 items: [{ value: "203.0.113.1" }],
178 }),
179 );
180
181 // Type is immutable — the engine must have created a new list.
182 expect(ipList.listId).not.toEqual(domainList.listId);
183 expect(ipList.type).toEqual("IP");
184
185 const live = yield* getList(accountId, ipList.listId);
186 expect(live.type).toEqual("IP");
187 // The old list was deleted by the replacement.
188 yield* expectGone(accountId, domainList.listId);
189
190 yield* stack.destroy();
191 yield* expectGone(accountId, ipList.listId);
192 }).pipe(logLevel),
193 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
194);