| 1 | import { adopt } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import * as customHostnames from "@distilled.cloud/cloudflare/custom-hostnames"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import { MinimumLogLevel } from "effect/References"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | |
| 13 | const { 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. |
| 24 | const saasEnabled = !!process.env.CLOUDFLARE_SAAS_ENABLED; |
| 25 | const testSaas = test.provider.skipIf(!saasEnabled); |
| 26 | |
| 27 | const logLevel = Effect.provideService( |
| 28 | MinimumLogLevel, |
| 29 | process.env.DEBUG ? "Debug" : "Info", |
| 30 | ); |
| 31 | |
| 32 | const 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). |
| 37 | const ORIGIN_A = `alchemy-fallback-origin-a.${zoneName}`; |
| 38 | const ORIGIN_B = `alchemy-fallback-origin-b.${zoneName}`; |
| 39 | |
| 40 | const 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. |
| 55 | const 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. |
| 59 | const 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. |
| 77 | const 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 | |
| 96 | const 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[]`. |
| 112 | test.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). |
| 142 | testSaas( |
| 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 | |
| 183 | testSaas( |
| 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 | ); |