1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as customHostnames from "@distilled.cloud/cloudflare/custom-hostnames";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16// Cloudflare for SaaS (SSL for SaaS) is NOT provisioned on the test account:
17// every custom-hostnames API call fails with `SaasQuotaNotAllocated`
18// (code 1404): "No quota has been allocated for this zone or for this
19// account. If you're already a paid SSL for SaaS customer, please contact
20// your Customer Success Manager for additional provisioning. ..."
21// Enabling Cloudflare for SaaS is a one-time dashboard action (SSL/TLS →
22// Custom Hostnames → Enable) with no public API — verified directly against
23// the API: POST /custom_hostnames and PUT /custom_hostnames/fallback_origin
24// are both rejected with the same entitlement errors. Set
25// CLOUDFLARE_SAAS_ENABLED=1 once the zone is provisioned to run these tests.
26const saasEnabled = !!process.env.CLOUDFLARE_SAAS_ENABLED;
27const testSaas = test.provider.skipIf(!saasEnabled);
28
29const logLevel = Effect.provideService(
30 MinimumLogLevel,
31 process.env.DEBUG ? "Debug" : "Info",
32);
33
34const zoneName =
35 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
36
37// Deterministic per-test hostnames on a domain we do not control — the
38// hostnames stay `pending` forever, which is fine: CRUD is still fully
39// exercisable. Each test owns a disjoint hostname so reruns and parallel
40// runs never collide (never derive names from Date.now()/random).
41const HOST_DEFAULT = "alchemy-ch-default.alchemy-saas-example.com";
42const HOST_UPDATE = "alchemy-ch-update.alchemy-saas-example.com";
43const HOST_REPLACE_A = "alchemy-ch-replace-a.alchemy-saas-example.com";
44const HOST_REPLACE_B = "alchemy-ch-replace-b.alchemy-saas-example.com";
45
46const resolveZoneId = Effect.gen(function* () {
47 const { accountId } = yield* yield* CloudflareEnvironment;
48 const zone = yield* findZoneByName({ accountId, name: zoneName });
49 if (!zone) {
50 return yield* Effect.die(
51 new Error(`zone "${zoneName}" not found in account`),
52 );
53 }
54 return zone.id;
55});
56
57// The scoped API token the test harness mints propagates eventually-
58// consistently across Cloudflare's edge — a fresh token intermittently 403s
59// (typed as `Forbidden` in the distilled unions). Ride out the blips on the
60// test's own out-of-band verification calls. Inlined at each call site so the
61// retry predicate is checked against the operation's inferred error union.
62const forbiddenBlips = Schedule.exponential("500 millis");
63
64const findByHostname = (zoneId: string, hostname: string) =>
65 customHostnames.listCustomHostnames
66 .items({ zoneId, hostname: { contain: hostname } })
67 .pipe(
68 Stream.filter((h) => h.hostname === hostname),
69 Stream.runCollect,
70 Effect.map((chunk) => Array.from(chunk)[0]),
71 Effect.retry({
72 while: (e) => e._tag === "Forbidden",
73 schedule: forbiddenBlips,
74 times: 8,
75 }),
76 );
77
78const getHostname = (zoneId: string, customHostnameId: string) =>
79 customHostnames.getCustomHostname({ zoneId, customHostnameId }).pipe(
80 Effect.retry({
81 while: (e) => e._tag === "Forbidden",
82 schedule: forbiddenBlips,
83 times: 8,
84 }),
85 );
86
87// Typed entitlement probe: every custom-hostnames call on an unprovisioned
88// zone is rejected with `SaasQuotaNotAllocated` (code 1404). When the suite
89// is enabled but the zone still lacks the entitlement, fail fast with a
90// clear message instead of surfacing raw entitlement errors mid-deploy.
91const probeSaasEntitlement = (zoneId: string) =>
92 customHostnames.listCustomHostnames.items({ zoneId }).pipe(
93 Stream.runDrain,
94 Effect.retry({
95 while: (e) => e._tag === "Forbidden",
96 schedule: forbiddenBlips,
97 times: 8,
98 }),
99 Effect.catchTag("SaasQuotaNotAllocated", (e) =>
100 Effect.die(
101 new Error(
102 `Cloudflare for SaaS is not provisioned on zone "${zoneName}" — ` +
103 `unset CLOUDFLARE_SAAS_ENABLED or enable SSL for SaaS first: ${e.message}`,
104 ),
105 ),
106 ),
107 );
108
109testSaas(
110 "create and delete a custom hostname with default ssl",
111 (stack) =>
112 Effect.gen(function* () {
113 const zoneId = yield* resolveZoneId;
114 yield* probeSaasEntitlement(zoneId);
115
116 yield* stack.destroy();
117
118 const hostname = yield* stack.deploy(
119 Effect.gen(function* () {
120 return yield* Cloudflare.CustomHostname.CustomHostname("Default", {
121 zoneId,
122 hostname: HOST_DEFAULT,
123 }).pipe(adopt(true));
124 }),
125 );
126
127 expect(hostname.customHostnameId).toBeDefined();
128 expect(hostname.zoneId).toEqual(zoneId);
129 expect(hostname.hostname).toEqual(HOST_DEFAULT);
130 // We don't control the domain, so activation never completes.
131 expect(hostname.status).toBeDefined();
132
133 const live = yield* getHostname(zoneId, hostname.customHostnameId);
134 expect(live.id).toEqual(hostname.customHostnameId);
135 expect(live.hostname).toEqual(HOST_DEFAULT);
136 expect(live.ssl?.method).toEqual("txt");
137 expect(live.ssl?.type).toEqual("dv");
138
139 yield* stack.destroy();
140
141 const gone = yield* findByHostname(zoneId, HOST_DEFAULT);
142 expect(gone).toBeUndefined();
143 }).pipe(logLevel),
144 {
145 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
146 timeout: 120_000,
147 },
148);
149
150testSaas(
151 "updating ssl method patches in place",
152 (stack) =>
153 Effect.gen(function* () {
154 const zoneId = yield* resolveZoneId;
155 yield* probeSaasEntitlement(zoneId);
156
157 yield* stack.destroy();
158
159 const initial = yield* stack.deploy(
160 Effect.gen(function* () {
161 return yield* Cloudflare.CustomHostname.CustomHostname("Update", {
162 zoneId,
163 hostname: HOST_UPDATE,
164 ssl: { method: "txt", type: "dv" },
165 }).pipe(adopt(true));
166 }),
167 );
168
169 expect(initial.customHostnameId).toBeDefined();
170
171 const updated = yield* stack.deploy(
172 Effect.gen(function* () {
173 return yield* Cloudflare.CustomHostname.CustomHostname("Update", {
174 zoneId,
175 hostname: HOST_UPDATE,
176 ssl: { method: "http", type: "dv" },
177 }).pipe(adopt(true));
178 }),
179 );
180
181 // Same custom hostname patched in place — not a replacement.
182 expect(updated.customHostnameId).toEqual(initial.customHostnameId);
183
184 const live = yield* getHostname(zoneId, updated.customHostnameId);
185 expect(live.ssl?.method).toEqual("http");
186
187 // Re-deploying the same desired state is a no-op (does not error or
188 // trigger reissuance).
189 const again = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* Cloudflare.CustomHostname.CustomHostname("Update", {
192 zoneId,
193 hostname: HOST_UPDATE,
194 ssl: { method: "http", type: "dv" },
195 }).pipe(adopt(true));
196 }),
197 );
198 expect(again.customHostnameId).toEqual(initial.customHostnameId);
199
200 yield* stack.destroy();
201 }).pipe(logLevel),
202 {
203 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
204 timeout: 120_000,
205 },
206);
207
208// `list()` fans out over every zone in the account and skips zones without
209// the SaaS entitlement (typed `SaasQuotaNotAllocated`/`Forbidden`). Without
210// the entitlement on any zone the result is a well-typed empty array — this
211// read-only assertion runs ungated and proves the per-zone skip path.
212test.provider(
213 "list returns a well-typed array of custom hostnames",
214 (stack) =>
215 Effect.gen(function* () {
216 yield* stack.destroy();
217
218 const provider = yield* Provider.findProvider(
219 Cloudflare.CustomHostname.CustomHostname,
220 );
221 const all = yield* provider.list();
222
223 expect(Array.isArray(all)).toBe(true);
224 for (const item of all) {
225 expect(typeof item.customHostnameId).toBe("string");
226 expect(typeof item.zoneId).toBe("string");
227 expect(typeof item.hostname).toBe("string");
228 }
229
230 yield* stack.destroy();
231 }).pipe(logLevel),
232 {
233 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
234 },
235);
236
237// Entitlement-gated: deploy a custom hostname and assert `list()` enumerates
238// it. Skipped unless CLOUDFLARE_SAAS_ENABLED=1 because Cloudflare for SaaS is
239// not provisioned on the test account (every create is rejected with
240// `SaasQuotaNotAllocated`, code 1404).
241testSaas(
242 "list enumerates the deployed custom hostname",
243 (stack) =>
244 Effect.gen(function* () {
245 const zoneId = yield* resolveZoneId;
246 yield* probeSaasEntitlement(zoneId);
247
248 yield* stack.destroy();
249
250 const deployed = yield* stack.deploy(
251 Effect.gen(function* () {
252 return yield* Cloudflare.CustomHostname.CustomHostname(
253 "ListResource",
254 {
255 zoneId,
256 hostname: HOST_DEFAULT,
257 },
258 ).pipe(adopt(true));
259 }),
260 );
261
262 const provider = yield* Provider.findProvider(
263 Cloudflare.CustomHostname.CustomHostname,
264 );
265 const all = yield* provider.list();
266
267 expect(
268 all.some((h) => h.customHostnameId === deployed.customHostnameId),
269 ).toBe(true);
270
271 yield* stack.destroy();
272 }).pipe(logLevel),
273 {
274 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
275 timeout: 120_000,
276 },
277);
278
279testSaas(
280 "changing the hostname triggers replacement",
281 (stack) =>
282 Effect.gen(function* () {
283 const zoneId = yield* resolveZoneId;
284 yield* probeSaasEntitlement(zoneId);
285
286 yield* stack.destroy();
287
288 const initial = yield* stack.deploy(
289 Effect.gen(function* () {
290 return yield* Cloudflare.CustomHostname.CustomHostname("Replace", {
291 zoneId,
292 hostname: HOST_REPLACE_A,
293 }).pipe(adopt(true));
294 }),
295 );
296
297 expect(initial.hostname).toEqual(HOST_REPLACE_A);
298
299 const replaced = yield* stack.deploy(
300 Effect.gen(function* () {
301 return yield* Cloudflare.CustomHostname.CustomHostname("Replace", {
302 zoneId,
303 hostname: HOST_REPLACE_B,
304 }).pipe(adopt(true));
305 }),
306 );
307
308 // hostname is the resource's identity — a new physical hostname exists.
309 expect(replaced.customHostnameId).not.toEqual(initial.customHostnameId);
310 expect(replaced.hostname).toEqual(HOST_REPLACE_B);
311
312 // The old hostname was deleted as part of the replacement.
313 const oldHostname = yield* findByHostname(zoneId, HOST_REPLACE_A);
314 expect(oldHostname).toBeUndefined();
315
316 const live = yield* getHostname(zoneId, replaced.customHostnameId);
317 expect(live.hostname).toEqual(HOST_REPLACE_B);
318
319 yield* stack.destroy();
320
321 const gone = yield* findByHostname(zoneId, HOST_REPLACE_B);
322 expect(gone).toBeUndefined();
323 }).pipe(logLevel),
324 {
325 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
326 timeout: 180_000,
327 },
328);