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 loadBalancers from "@distilled.cloud/cloudflare/load-balancers";
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// Load Balancing is a paid add-on subscription. The testing zone does not
23// have it: load balancer creation is rejected with "load balancing not
24// enabled for zone" (Cloudflare code 1002), surfaced as the typed
25// `LoadBalancingNotEnabledForZone` error. Set CLOUDFLARE_LB_ENABLED=1 once
26// the subscription is provisioned to run the full lifecycle test.
27const lbEnabled = !!process.env.CLOUDFLARE_LB_ENABLED;
28
29// Deterministic per-test hostnames — reused on every run.
30const NAME_ENTITLEMENT = `alchemy-lb-entitlement-probe.${zoneName}`;
31const NAME_LIFECYCLE = `alchemy-lb-lifecycle.${zoneName}`;
32const NAME_POOL = "alchemy-lb-lifecycle-pool";
33
34const resolveZoneId = Effect.gen(function* () {
35 const { accountId } = yield* yield* CloudflareEnvironment;
36 const zone = yield* findZoneByName({ accountId, name: zoneName });
37 if (!zone) {
38 return yield* Effect.die(
39 new Error(`zone "${zoneName}" not found in account`),
40 );
41 }
42 return zone.id;
43});
44
45// Freshly minted scoped tokens propagate eventually-consistently across
46// Cloudflare's edge — retry the typed `Forbidden` blips on out-of-band calls.
47const forbiddenRetrySchedule = Schedule.exponential("500 millis");
48
49const getLoadBalancer = (zoneId: string, loadBalancerId: string) =>
50 loadBalancers.getLoadBalancer({ zoneId, loadBalancerId }).pipe(
51 Effect.retry({
52 while: (e) => e._tag === "Forbidden",
53 schedule: forbiddenRetrySchedule,
54 times: 8,
55 }),
56 );
57
58const expectGone = (zoneId: string, loadBalancerId: string) =>
59 getLoadBalancer(zoneId, loadBalancerId).pipe(
60 Effect.flatMap(() =>
61 Effect.fail({ _tag: "LoadBalancerNotDeleted" } as const),
62 ),
63 // A missing load balancer surfaces as the typed `LoadBalancerNotFound`
64 // (Cloudflare error code 1001) — that's the success condition here.
65 Effect.catchTag("LoadBalancerNotFound", () => Effect.void),
66 Effect.retry({
67 while: (e) => e._tag === "LoadBalancerNotDeleted",
68 schedule: Schedule.max([
69 Schedule.exponential("500 millis"),
70 Schedule.recurs(10),
71 ]),
72 }),
73 );
74
75// Unentitlement probe: pins the typed plan-gate rejection, so it must skip
76// on entitled accounts — there the create would succeed instead of failing.
77test.provider.skipIf(lbEnabled)(
78 "surfaces the typed LoadBalancingNotEnabledForZone error without the LB subscription",
79 (stack) =>
80 Effect.gen(function* () {
81 const zoneId = yield* resolveZoneId;
82
83 yield* stack.destroy();
84
85 // The zone entitlement gate fires before pool validation, so
86 // placeholder pool ids are fine here.
87 const error = yield* loadBalancers
88 .createLoadBalancer({
89 zoneId,
90 name: NAME_ENTITLEMENT,
91 defaultPools: ["17b5962d775c646f3f9725cbc7a53df4"],
92 fallbackPool: "17b5962d775c646f3f9725cbc7a53df4",
93 })
94 .pipe(
95 Effect.retry({
96 while: (e) => e._tag === "Forbidden",
97 schedule: forbiddenRetrySchedule,
98 times: 8,
99 }),
100 Effect.flip,
101 );
102 expect(error._tag).toEqual("LoadBalancingNotEnabledForZone");
103
104 yield* stack.destroy();
105 }).pipe(logLevel),
106 {
107 tags: [
108 "provider:cloudflare",
109 "provider:cloudflare:loadbalancer",
110 "provider:cloudflare:zone",
111 "live",
112 ],
113 },
114);
115
116test.provider.skipIf(!lbEnabled)(
117 "create, update in place, and destroy a load balancer",
118 (stack) =>
119 Effect.gen(function* () {
120 const zoneId = yield* resolveZoneId;
121
122 yield* stack.destroy();
123
124 const initial = yield* stack.deploy(
125 Effect.gen(function* () {
126 const pool = yield* Cloudflare.LoadBalancer.Pool("Pool", {
127 name: NAME_POOL,
128 origins: [{ name: "origin-1", address: "203.0.113.10" }],
129 });
130 const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("Lb", {
131 zoneId,
132 name: NAME_LIFECYCLE,
133 defaultPools: [pool.poolId],
134 fallbackPool: pool.poolId,
135 proxied: false,
136 ttl: 30,
137 });
138 return { pool, lb };
139 }),
140 );
141
142 expect(initial.lb.loadBalancerId).toBeTruthy();
143 expect(initial.lb.zoneId).toEqual(zoneId);
144 expect(initial.lb.name).toEqual(NAME_LIFECYCLE);
145 expect(initial.lb.proxied).toEqual(false);
146 expect(initial.lb.defaultPools).toEqual([initial.pool.poolId]);
147 expect(initial.lb.fallbackPool).toEqual(initial.pool.poolId);
148
149 const live = yield* getLoadBalancer(zoneId, initial.lb.loadBalancerId);
150 expect(live.name).toEqual(NAME_LIFECYCLE);
151 expect(live.defaultPools).toEqual([initial.pool.poolId]);
152
153 // Mutable props (steering, affinity, proxied) update in place — same
154 // loadBalancerId. Keep the pool deployed across every step so the
155 // engine never has to replace and drop a dependency in one deploy.
156 const updated = yield* stack.deploy(
157 Effect.gen(function* () {
158 const pool = yield* Cloudflare.LoadBalancer.Pool("Pool", {
159 name: NAME_POOL,
160 origins: [{ name: "origin-1", address: "203.0.113.10" }],
161 });
162 const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("Lb", {
163 zoneId,
164 name: NAME_LIFECYCLE,
165 defaultPools: [pool.poolId],
166 fallbackPool: pool.poolId,
167 proxied: true,
168 steeringPolicy: "random",
169 sessionAffinity: "cookie",
170 });
171 return { pool, lb };
172 }),
173 );
174 expect(updated.lb.loadBalancerId).toEqual(initial.lb.loadBalancerId);
175 expect(updated.lb.proxied).toEqual(true);
176 expect(updated.lb.steeringPolicy).toEqual("random");
177
178 const synced = yield* getLoadBalancer(zoneId, updated.lb.loadBalancerId);
179 expect(synced.proxied).toEqual(true);
180 expect(synced.steeringPolicy).toEqual("random");
181 expect(synced.sessionAffinity).toEqual("cookie");
182
183 yield* stack.destroy();
184
185 yield* expectGone(zoneId, initial.lb.loadBalancerId);
186 }).pipe(logLevel),
187 {
188 tags: [
189 "provider:cloudflare",
190 "provider:cloudflare:loadbalancer",
191 "provider:cloudflare:zone",
192 "live",
193 ],
194 timeout: 120_000,
195 },
196);
197
198// Canonical `list()` test, ungated read-only half: load balancers are
199// zone-scoped, so `list()` fans out over every zone and exhaustively
200// paginates each zone's load balancers. The testing account's zones lack
201// the Load Balancing subscription, so the per-zone list yields no rows
202// (and any `Forbidden` zone is skipped) — assert a well-typed array whose
203// elements (if any) match the `read` Attributes shape.
204test.provider(
205 "list returns a well-typed array of load balancers",
206 (stack) =>
207 Effect.gen(function* () {
208 yield* stack.destroy();
209
210 const provider = yield* Provider.findProvider(
211 Cloudflare.LoadBalancer.LoadBalancer,
212 );
213 const all = yield* provider.list();
214
215 expect(Array.isArray(all)).toBe(true);
216 for (const lb of all) {
217 expect(typeof lb.loadBalancerId).toBe("string");
218 expect(typeof lb.zoneId).toBe("string");
219 expect(typeof lb.name).toBe("string");
220 expect(typeof lb.enabled).toBe("boolean");
221 expect(typeof lb.proxied).toBe("boolean");
222 expect(typeof lb.steeringPolicy).toBe("string");
223 expect(Array.isArray(lb.defaultPools)).toBe(true);
224 expect(typeof lb.fallbackPool).toBe("string");
225 }
226
227 yield* stack.destroy();
228 }).pipe(logLevel),
229 {
230 tags: ["provider:cloudflare", "provider:cloudflare:loadbalancer", "live"],
231 timeout: 120_000,
232 },
233);
234
235// Entitlement-gated half: with the Load Balancing subscription enabled,
236// deploy a real pool + load balancer and assert the deployed item appears
237// in the exhaustively-paginated, fanned-out `list()` result.
238test.provider.skipIf(!lbEnabled)(
239 "list enumerates the deployed load balancer",
240 (stack) =>
241 Effect.gen(function* () {
242 const zoneId = yield* resolveZoneId;
243
244 yield* stack.destroy();
245
246 const deployed = yield* stack.deploy(
247 Effect.gen(function* () {
248 const pool = yield* Cloudflare.LoadBalancer.Pool("Pool", {
249 name: NAME_POOL,
250 origins: [{ name: "origin-1", address: "203.0.113.10" }],
251 });
252 const lb = yield* Cloudflare.LoadBalancer.LoadBalancer("Lb", {
253 zoneId,
254 name: NAME_LIFECYCLE,
255 defaultPools: [pool.poolId],
256 fallbackPool: pool.poolId,
257 proxied: false,
258 ttl: 30,
259 });
260 return { pool, lb };
261 }),
262 );
263
264 const provider = yield* Provider.findProvider(
265 Cloudflare.LoadBalancer.LoadBalancer,
266 );
267 const all = yield* provider.list();
268
269 const row = all.find(
270 (lb) => lb.loadBalancerId === deployed.lb.loadBalancerId,
271 );
272 expect(row).toBeDefined();
273 expect(row!.zoneId).toEqual(zoneId);
274 expect(row!.name).toEqual(NAME_LIFECYCLE);
275
276 yield* stack.destroy();
277
278 yield* expectGone(zoneId, deployed.lb.loadBalancerId);
279 }).pipe(logLevel),
280 {
281 tags: [
282 "provider:cloudflare",
283 "provider:cloudflare:loadbalancer",
284 "provider:cloudflare:zone",
285 "live",
286 ],
287 timeout: 120_000,
288 },
289);