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";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15// Cloudflare for SaaS (SSL for SaaS) is NOT provisioned on the test account:
16// every fallback-origin API call fails with `SaasAccessNotGranted`
17// (code 1456): "Access to configure this resource has not been granted for
18// this zone. This feature is available with SSL for SaaS."
19// Enabling Cloudflare for SaaS is a one-time dashboard action (SSL/TLS →
20// Custom Hostnames → Enable) with no public API — verified directly against
21// the API: PUT /custom_hostnames/fallback_origin is rejected with the same
22// entitlement error. Set CLOUDFLARE_SAAS_ENABLED=1 once the zone is
23// provisioned to run these tests.
24const saasEnabled = !!process.env.CLOUDFLARE_SAAS_ENABLED;
25const testSaas = test.provider.skipIf(!saasEnabled);
26
27const logLevel = Effect.provideService(
28 MinimumLogLevel,
29 process.env.DEBUG ? "Debug" : "Info",
30);
31
32const zoneName =
33 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
34
35// Deterministic record names for the origin DNS records the fallback origin
36// points at (the API requires the origin to be a DNS record in the zone).
37const ORIGIN_A = `alchemy-fallback-origin-a.${zoneName}`;
38const ORIGIN_B = `alchemy-fallback-origin-b.${zoneName}`;
39
40const resolveZoneId = Effect.gen(function* () {
41 const { accountId } = yield* yield* CloudflareEnvironment;
42 const zone = yield* findZoneByName({ accountId, name: zoneName });
43 if (!zone) {
44 return yield* Effect.die(
45 new Error(`zone "${zoneName}" not found in account`),
46 );
47 }
48 return zone.id;
49});
50
51// The scoped API token the test harness mints propagates eventually-
52// consistently across Cloudflare's edge — a fresh token intermittently 403s
53// (typed as `Forbidden` in the distilled unions). Ride out the blips on the
54// test's own out-of-band verification calls.
55const forbiddenBlips = Schedule.exponential("500 millis");
56
57// Observe the zone's fallback origin out-of-band; `undefined` when it is not
58// configured (`FallbackOriginNotFound`) or is mid-deletion.
59const observeFallbackOrigin = (zoneId: string) =>
60 customHostnames.getFallbackOrigin({ zoneId }).pipe(
61 Effect.retry({
62 while: (e) => e._tag === "Forbidden",
63 schedule: forbiddenBlips,
64 times: 8,
65 }),
66 Effect.map((r) => ({
67 origin: r.origin ?? undefined,
68 status: r.status ?? undefined,
69 })),
70 Effect.catchTag("FallbackOriginNotFound", () => Effect.succeed(undefined)),
71 );
72
73// Typed entitlement probe: every fallback-origin call on an unprovisioned
74// zone is rejected with `SaasAccessNotGranted` (code 1456). When the suite
75// is enabled but the zone still lacks the entitlement, fail fast with a
76// clear message instead of surfacing raw entitlement errors mid-deploy.
77const probeSaasEntitlement = (zoneId: string) =>
78 customHostnames.getFallbackOrigin({ zoneId }).pipe(
79 Effect.asVoid,
80 Effect.retry({
81 while: (e) => e._tag === "Forbidden",
82 schedule: forbiddenBlips,
83 times: 8,
84 }),
85 Effect.catchTag("FallbackOriginNotFound", () => Effect.void),
86 Effect.catchTag("SaasAccessNotGranted", (e) =>
87 Effect.die(
88 new Error(
89 `Cloudflare for SaaS is not provisioned on zone "${zoneName}" — ` +
90 `unset CLOUDFLARE_SAAS_ENABLED or enable SSL for SaaS first: ${e.message}`,
91 ),
92 ),
93 ),
94 );
95
96const isGone = (
97 observed:
98 | { origin: string | undefined; status: string | undefined }
99 | undefined,
100): boolean =>
101 observed === undefined ||
102 observed.origin === undefined ||
103 observed.status === "pending_deletion" ||
104 observed.status === "deletion_timed_out";
105
106// Canonical `list()` test (zone-scoped singleton): there is no account-wide
107// API for the fallback origin, so `list()` enumerates every zone via
108// `listAllZones` and reads the singleton in each, skipping zones without
109// Cloudflare for SaaS (`SaasAccessNotGranted` / `Forbidden`). This read-only
110// assertion ALWAYS runs — on an unentitled account every zone is skipped and
111// the result is a well-typed empty `FallbackOriginAttributes[]`.
112test.provider(
113 "list enumerates fallback origins across all zones",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117
118 const provider = yield* Provider.findProvider(
119 Cloudflare.CustomHostname.FallbackOrigin,
120 );
121 const all = yield* provider.list();
122
123 // Well-typed `FallbackOriginAttributes[]`: each element matches the
124 // shape `read` produces.
125 expect(Array.isArray(all)).toBe(true);
126 for (const item of all) {
127 expect(typeof item.zoneId).toBe("string");
128 expect(typeof item.origin).toBe("string");
129 }
130
131 yield* stack.destroy();
132 }).pipe(logLevel),
133 {
134 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
135 },
136);
137
138// Entitlement-gated: when Cloudflare for SaaS is provisioned, deploy a
139// fallback origin and assert `list()` surfaces it. Skipped on the standing
140// test account, where every fallback-origin call returns
141// `SaasAccessNotGranted` (code 1456).
142testSaas(
143 "list surfaces a deployed fallback origin",
144 (stack) =>
145 Effect.gen(function* () {
146 const zoneId = yield* resolveZoneId;
147 yield* probeSaasEntitlement(zoneId);
148
149 yield* stack.destroy();
150
151 yield* stack.deploy(
152 Effect.gen(function* () {
153 const record = yield* Cloudflare.DNS.Record("OriginA", {
154 zoneId,
155 name: ORIGIN_A,
156 type: "A",
157 content: "203.0.113.50",
158 proxied: true,
159 }).pipe(adopt(true));
160 return yield* Cloudflare.CustomHostname.FallbackOrigin("Fallback", {
161 zoneId,
162 origin: record.name,
163 }).pipe(adopt(true));
164 }),
165 );
166
167 const provider = yield* Provider.findProvider(
168 Cloudflare.CustomHostname.FallbackOrigin,
169 );
170 const all = yield* provider.list();
171 expect(
172 all.some((o) => o.zoneId === zoneId && o.origin === ORIGIN_A),
173 ).toBe(true);
174
175 yield* stack.destroy();
176 }).pipe(logLevel),
177 {
178 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
179 timeout: 300_000,
180 },
181);
182
183testSaas(
184 "set, update and delete the zone fallback origin",
185 (stack) =>
186 Effect.gen(function* () {
187 const zoneId = yield* resolveZoneId;
188 yield* probeSaasEntitlement(zoneId);
189
190 yield* stack.destroy();
191
192 // Create: an origin DNS record plus the fallback origin pointing at it.
193 const initial = yield* stack.deploy(
194 Effect.gen(function* () {
195 const record = yield* Cloudflare.DNS.Record("OriginA", {
196 zoneId,
197 name: ORIGIN_A,
198 type: "A",
199 content: "203.0.113.50",
200 proxied: true,
201 }).pipe(adopt(true));
202 const fallback = yield* Cloudflare.CustomHostname.FallbackOrigin(
203 "Fallback",
204 {
205 zoneId,
206 origin: record.name,
207 },
208 ).pipe(adopt(true));
209 return { record, fallback };
210 }),
211 );
212
213 expect(initial.fallback.zoneId).toEqual(zoneId);
214 expect(initial.fallback.origin).toEqual(ORIGIN_A);
215 expect(initial.fallback.status).toBeDefined();
216
217 const liveA = yield* observeFallbackOrigin(zoneId);
218 expect(liveA?.origin).toEqual(ORIGIN_A);
219
220 // Re-deploying the same desired state is a no-op.
221 const again = yield* stack.deploy(
222 Effect.gen(function* () {
223 const record = yield* Cloudflare.DNS.Record("OriginA", {
224 zoneId,
225 name: ORIGIN_A,
226 type: "A",
227 content: "203.0.113.50",
228 proxied: true,
229 }).pipe(adopt(true));
230 const fallback = yield* Cloudflare.CustomHostname.FallbackOrigin(
231 "Fallback",
232 {
233 zoneId,
234 origin: record.name,
235 },
236 ).pipe(adopt(true));
237 return { record, fallback };
238 }),
239 );
240 expect(again.fallback.origin).toEqual(ORIGIN_A);
241
242 // Update: point at a different origin record (PUT is an upsert — the
243 // singleton is updated in place).
244 const updated = yield* stack.deploy(
245 Effect.gen(function* () {
246 const record = yield* Cloudflare.DNS.Record("OriginB", {
247 zoneId,
248 name: ORIGIN_B,
249 type: "A",
250 content: "203.0.113.51",
251 proxied: true,
252 }).pipe(adopt(true));
253 const fallback = yield* Cloudflare.CustomHostname.FallbackOrigin(
254 "Fallback",
255 {
256 zoneId,
257 origin: record.name,
258 },
259 ).pipe(adopt(true));
260 return { record, fallback };
261 }),
262 );
263
264 expect(updated.fallback.origin).toEqual(ORIGIN_B);
265
266 const liveB = yield* observeFallbackOrigin(zoneId);
267 expect(liveB?.origin).toEqual(ORIGIN_B);
268
269 yield* stack.destroy();
270
271 // Deletion is asynchronous — poll (bounded) until the fallback origin
272 // is gone or mid-deletion.
273 const gone = yield* observeFallbackOrigin(zoneId).pipe(
274 Effect.repeat({
275 schedule: Schedule.spaced("3 seconds"),
276 until: isGone,
277 times: 20,
278 }),
279 );
280 expect(isGone(gone)).toBe(true);
281 }).pipe(logLevel),
282 {
283 tags: ["provider:cloudflare", "provider:cloudflare:customhostname", "live"],
284 timeout: 300_000,
285 },
286);