| 1 | import { adopt } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as pages from "@distilled.cloud/cloudflare/pages"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 13 | |
| 14 | const logLevel = Effect.provideService( |
| 15 | MinimumLogLevel, |
| 16 | process.env.DEBUG ? "Debug" : "Info", |
| 17 | ); |
| 18 | |
| 19 | const zoneName = |
| 20 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 21 | |
| 22 | // Deterministic per-test names (never derived from Date.now() or |
| 23 | // randomness). Each test owns disjoint identifiers so reruns never collide. |
| 24 | const PROJECT_CRUD = "alchemy-e3-pages-dom-crud"; |
| 25 | const PROJECT_REPLACE = "alchemy-e3-pages-dom-repl"; |
| 26 | const PROJECT_LIST = "alchemy-e3-pages-dom-list"; |
| 27 | const DOMAIN_LIST = `alchemy-pages-list.${zoneName}`; |
| 28 | const DOMAIN_CRUD = `alchemy-pages-crud.${zoneName}`; |
| 29 | const DOMAIN_REPLACE_A = `alchemy-pages-replace-a.${zoneName}`; |
| 30 | const DOMAIN_REPLACE_B = `alchemy-pages-replace-b.${zoneName}`; |
| 31 | |
| 32 | // The scoped API token the test harness mints propagates eventually- |
| 33 | // consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`, |
| 34 | // declared in the distilled error union) on the test's own out-of-band |
| 35 | // verification calls. |
| 36 | const forbiddenRetry = { |
| 37 | while: (e: { _tag: string }) => e._tag === "Forbidden", |
| 38 | schedule: Schedule.exponential("500 millis"), |
| 39 | times: 8, |
| 40 | } as const; |
| 41 | |
| 42 | const getDomain = ( |
| 43 | accountId: string, |
| 44 | projectName: string, |
| 45 | domainName: string, |
| 46 | ) => |
| 47 | pages |
| 48 | .getProjectDomain({ accountId, projectName, domainName }) |
| 49 | .pipe(Effect.retry(forbiddenRetry)); |
| 50 | |
| 51 | // Attaching a domain is asynchronous: the attachment, its zone tag and |
| 52 | // validation/verification blocks propagate across Cloudflare's edge a beat |
| 53 | // after `create` returns. Poll `getProjectDomain` with a bounded schedule, |
| 54 | // riding out the brief `PagesDomainNotFound` window, until the domain is |
| 55 | // observable with a status. We assert reachability — NOT a terminal `active` |
| 56 | // status, since certificate issuance needs a real CNAME and stays pending. |
| 57 | const waitForDomain = ( |
| 58 | accountId: string, |
| 59 | projectName: string, |
| 60 | domainName: string, |
| 61 | ) => |
| 62 | pages.getProjectDomain({ accountId, projectName, domainName }).pipe( |
| 63 | Effect.retry({ |
| 64 | while: (e) => e._tag === "Forbidden" || e._tag === "PagesDomainNotFound", |
| 65 | schedule: Schedule.max([ |
| 66 | Schedule.exponential("500 millis"), |
| 67 | Schedule.recurs(10), |
| 68 | ]), |
| 69 | }), |
| 70 | ); |
| 71 | |
| 72 | const expectDomainGone = ( |
| 73 | accountId: string, |
| 74 | projectName: string, |
| 75 | domainName: string, |
| 76 | ) => |
| 77 | getDomain(accountId, projectName, domainName).pipe( |
| 78 | Effect.flatMap(() => Effect.fail({ _tag: "DomainNotDeleted" } as const)), |
| 79 | // A detached domain surfaces as `PagesDomainNotFound` (code 8000021); |
| 80 | // if the whole project is gone the API reports `ProjectNotFound` |
| 81 | // (code 8000007). Both mean the attachment no longer exists. |
| 82 | Effect.catchTag("PagesDomainNotFound", () => Effect.void), |
| 83 | Effect.catchTag("ProjectNotFound", () => Effect.void), |
| 84 | Effect.retry({ |
| 85 | while: (e) => e._tag === "DomainNotDeleted", |
| 86 | schedule: Schedule.max([ |
| 87 | Schedule.exponential("500 millis"), |
| 88 | Schedule.recurs(10), |
| 89 | ]), |
| 90 | }), |
| 91 | ); |
| 92 | |
| 93 | // Purge leftovers from interrupted runs so deterministically-named tests |
| 94 | // start from a clean slate (deleting the project detaches its domains). |
| 95 | const purgeProject = (accountId: string, projectName: string) => |
| 96 | pages.deleteProject({ accountId, projectName }).pipe( |
| 97 | Effect.catchTag("ProjectNotFound", () => Effect.void), |
| 98 | Effect.retry(forbiddenRetry), |
| 99 | ); |
| 100 | |
| 101 | test.provider( |
| 102 | "attach and detach a custom domain", |
| 103 | (stack) => |
| 104 | Effect.gen(function* () { |
| 105 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 106 | |
| 107 | yield* stack.destroy(); |
| 108 | yield* purgeProject(accountId, PROJECT_CRUD); |
| 109 | |
| 110 | const { project, domain } = yield* stack.deploy( |
| 111 | Effect.gen(function* () { |
| 112 | const project = yield* Cloudflare.Pages.Project("DomainCrudProject", { |
| 113 | name: PROJECT_CRUD, |
| 114 | }).pipe(adopt(true)); |
| 115 | const domain = yield* Cloudflare.Pages.Domain("CrudDomain", { |
| 116 | projectName: project.name, |
| 117 | name: DOMAIN_CRUD, |
| 118 | }).pipe(adopt(true)); |
| 119 | return { project, domain }; |
| 120 | }), |
| 121 | ); |
| 122 | |
| 123 | expect(domain.domainId).toBeDefined(); |
| 124 | expect(domain.accountId).toEqual(accountId); |
| 125 | expect(domain.projectName).toEqual(project.name); |
| 126 | expect(domain.name).toEqual(DOMAIN_CRUD); |
| 127 | // Certificate issuance is asynchronous — the domain legitimately stays |
| 128 | // `initializing`/`pending` (the cert authority, validation/verification |
| 129 | // blocks and zone tag fill in over time). This test exercises CRUD, not |
| 130 | // certificate issuance, so only assert the attachment exists with a |
| 131 | // status, never a terminal `active`. |
| 132 | expect(domain.status).toBeTruthy(); |
| 133 | expect(domain.createdOn).toBeTruthy(); |
| 134 | |
| 135 | // The attachment propagates a beat after `create` returns — poll until |
| 136 | // it is observable rather than asserting it is immediately readable. |
| 137 | const live = yield* waitForDomain(accountId, project.name, DOMAIN_CRUD); |
| 138 | expect(live.domainId).toEqual(domain.domainId); |
| 139 | expect(live.name).toEqual(DOMAIN_CRUD); |
| 140 | expect(live.status).toBeTruthy(); |
| 141 | |
| 142 | // Redeploying identical props is a no-op (same attachment). |
| 143 | const noop = yield* stack.deploy( |
| 144 | Effect.gen(function* () { |
| 145 | const project = yield* Cloudflare.Pages.Project("DomainCrudProject", { |
| 146 | name: PROJECT_CRUD, |
| 147 | }).pipe(adopt(true)); |
| 148 | const domain = yield* Cloudflare.Pages.Domain("CrudDomain", { |
| 149 | projectName: project.name, |
| 150 | name: DOMAIN_CRUD, |
| 151 | }).pipe(adopt(true)); |
| 152 | return { project, domain }; |
| 153 | }), |
| 154 | ); |
| 155 | expect(noop.domain.domainId).toEqual(domain.domainId); |
| 156 | |
| 157 | yield* stack.destroy(); |
| 158 | |
| 159 | yield* expectDomainGone(accountId, PROJECT_CRUD, DOMAIN_CRUD); |
| 160 | }).pipe( |
| 161 | logLevel, |
| 162 | // Guarantee teardown even if an assertion throws mid-test, so a failed |
| 163 | // run never leaves a dangling Pages.Domain / Pages.Project behind. |
| 164 | // `stack.destroy()` tears down everything in the scratch stack (domain |
| 165 | // then project); the next run's start-of-test purge is the backstop. |
| 166 | Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)), |
| 167 | ), |
| 168 | { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] }, |
| 169 | ); |
| 170 | |
| 171 | test.provider( |
| 172 | "changing the domain name triggers replacement", |
| 173 | (stack) => |
| 174 | Effect.gen(function* () { |
| 175 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 176 | |
| 177 | yield* stack.destroy(); |
| 178 | yield* purgeProject(accountId, PROJECT_REPLACE); |
| 179 | |
| 180 | const initial = yield* stack.deploy( |
| 181 | Effect.gen(function* () { |
| 182 | const project = yield* Cloudflare.Pages.Project("DomainReplProject", { |
| 183 | name: PROJECT_REPLACE, |
| 184 | }).pipe(adopt(true)); |
| 185 | const domain = yield* Cloudflare.Pages.Domain("ReplaceDomain", { |
| 186 | projectName: project.name, |
| 187 | name: DOMAIN_REPLACE_A, |
| 188 | }).pipe(adopt(true)); |
| 189 | return domain; |
| 190 | }), |
| 191 | ); |
| 192 | |
| 193 | expect(initial.name).toEqual(DOMAIN_REPLACE_A); |
| 194 | |
| 195 | const replaced = yield* stack.deploy( |
| 196 | Effect.gen(function* () { |
| 197 | const project = yield* Cloudflare.Pages.Project("DomainReplProject", { |
| 198 | name: PROJECT_REPLACE, |
| 199 | }).pipe(adopt(true)); |
| 200 | const domain = yield* Cloudflare.Pages.Domain("ReplaceDomain", { |
| 201 | projectName: project.name, |
| 202 | name: DOMAIN_REPLACE_B, |
| 203 | }).pipe(adopt(true)); |
| 204 | return domain; |
| 205 | }), |
| 206 | ); |
| 207 | |
| 208 | // The domain name is the attachment's identity — a new physical |
| 209 | // attachment exists. |
| 210 | expect(replaced.domainId).not.toEqual(initial.domainId); |
| 211 | expect(replaced.name).toEqual(DOMAIN_REPLACE_B); |
| 212 | |
| 213 | // The old domain was detached as part of the replacement. |
| 214 | yield* expectDomainGone(accountId, PROJECT_REPLACE, DOMAIN_REPLACE_A); |
| 215 | |
| 216 | const live = yield* getDomain( |
| 217 | accountId, |
| 218 | PROJECT_REPLACE, |
| 219 | DOMAIN_REPLACE_B, |
| 220 | ); |
| 221 | expect(live.domainId).toEqual(replaced.domainId); |
| 222 | |
| 223 | yield* stack.destroy(); |
| 224 | |
| 225 | yield* expectDomainGone(accountId, PROJECT_REPLACE, DOMAIN_REPLACE_B); |
| 226 | }).pipe(logLevel), |
| 227 | { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] }, |
| 228 | ); |
| 229 | |
| 230 | // Canonical `list()` test (parent fan-out): domains have no account-wide |
| 231 | // enumeration API, so `list()` enumerates every Pages project and lists each |
| 232 | // project's domains. Deploy a project + domain, then assert the attachment |
| 233 | // appears in the exhaustively-paginated result with its full `read` |
| 234 | // Attributes shape. |
| 235 | // |
| 236 | // SKIP-GATED on a distilled schema bug: `pages.listProjects` fails to decode |
| 237 | // the live response with |
| 238 | // CloudflareHttpError { status: 200, statusText: "Schema decode failed" } |
| 239 | // because `ListProjectsResponse.result[].canonicalDeployment` and |
| 240 | // `.latestDeployment` declare `source` as a *required* `Schema.Struct` |
| 241 | // (pages.ts ~1540 / ~2002), but Cloudflare omits `source` on deployment |
| 242 | // objects. NEEDED PATCH: make `source` optional/nullable on both deployment |
| 243 | // shapes in the `listProjects` response schema, regenerate the `pages` |
| 244 | // service, then drop this gate. Re-enable with CLOUDFLARE_TEST_PAGES_LIST=1. |
| 245 | test.provider.skipIf(!process.env.CLOUDFLARE_TEST_PAGES_LIST)( |
| 246 | "list enumerates domains across all Pages projects", |
| 247 | (stack) => |
| 248 | Effect.gen(function* () { |
| 249 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 250 | |
| 251 | yield* stack.destroy(); |
| 252 | yield* purgeProject(accountId, PROJECT_LIST); |
| 253 | |
| 254 | const { domain } = yield* stack.deploy( |
| 255 | Effect.gen(function* () { |
| 256 | const project = yield* Cloudflare.Pages.Project("DomainListProject", { |
| 257 | name: PROJECT_LIST, |
| 258 | }).pipe(adopt(true)); |
| 259 | const domain = yield* Cloudflare.Pages.Domain("ListDomain", { |
| 260 | projectName: project.name, |
| 261 | name: DOMAIN_LIST, |
| 262 | }).pipe(adopt(true)); |
| 263 | return { domain }; |
| 264 | }), |
| 265 | ); |
| 266 | |
| 267 | const provider = yield* Provider.findProvider(Cloudflare.Pages.Domain); |
| 268 | const all = yield* provider.list(); |
| 269 | |
| 270 | const found = all.find((d) => d.domainId === domain.domainId); |
| 271 | expect(found).toBeDefined(); |
| 272 | expect(found?.name).toEqual(DOMAIN_LIST); |
| 273 | expect(found?.projectName).toEqual(PROJECT_LIST); |
| 274 | expect(found?.accountId).toEqual(accountId); |
| 275 | |
| 276 | yield* stack.destroy(); |
| 277 | |
| 278 | yield* expectDomainGone(accountId, PROJECT_LIST, DOMAIN_LIST); |
| 279 | }).pipe(logLevel), |
| 280 | { tags: ["provider:cloudflare", "provider:cloudflare:pages", "live"] }, |
| 281 | ); |