| 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 | import * as Stream from "effect/Stream"; |
| 13 | |
| 14 | const { 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. |
| 26 | const saasEnabled = !!process.env.CLOUDFLARE_SAAS_ENABLED; |
| 27 | const testSaas = test.provider.skipIf(!saasEnabled); |
| 28 | |
| 29 | const logLevel = Effect.provideService( |
| 30 | MinimumLogLevel, |
| 31 | process.env.DEBUG ? "Debug" : "Info", |
| 32 | ); |
| 33 | |
| 34 | const 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). |
| 41 | const HOST_DEFAULT = "alchemy-ch-default.alchemy-saas-example.com"; |
| 42 | const HOST_UPDATE = "alchemy-ch-update.alchemy-saas-example.com"; |
| 43 | const HOST_REPLACE_A = "alchemy-ch-replace-a.alchemy-saas-example.com"; |
| 44 | const HOST_REPLACE_B = "alchemy-ch-replace-b.alchemy-saas-example.com"; |
| 45 | |
| 46 | const 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. |
| 62 | const forbiddenBlips = Schedule.exponential("500 millis"); |
| 63 | |
| 64 | const 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 | |
| 78 | const 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. |
| 91 | const 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 | |
| 109 | testSaas( |
| 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 | |
| 150 | testSaas( |
| 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. |
| 212 | test.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). |
| 241 | testSaas( |
| 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 | |
| 279 | testSaas( |
| 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 | ); |