| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import * as Provider from "@/Provider"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import * as certificateAuthorities from "@distilled.cloud/cloudflare/certificate-authorities"; |
| 7 | import * as mtls from "@distilled.cloud/cloudflare/mtls-certificates"; |
| 8 | import { describe, 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 { CA_CERT_1, CA_CERT_2 } from "./fixtures/certs.ts"; |
| 13 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 14 | |
| 15 | const logLevel = Effect.provideService( |
| 16 | MinimumLogLevel, |
| 17 | process.env.DEBUG ? "Debug" : "Info", |
| 18 | ); |
| 19 | |
| 20 | const zoneName = |
| 21 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 22 | |
| 23 | const resolveZoneId = Effect.gen(function* () { |
| 24 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 25 | const zone = yield* findZoneByName({ accountId, name: zoneName }); |
| 26 | if (!zone) { |
| 27 | return yield* Effect.die( |
| 28 | new Error(`zone "${zoneName}" not found in account`), |
| 29 | ); |
| 30 | } |
| 31 | return zone.id; |
| 32 | }); |
| 33 | |
| 34 | // The scoped API token the test harness mints propagates eventually- |
| 35 | // consistently across Cloudflare's edge — a fresh token intermittently 403s |
| 36 | // with "Unable to authenticate request". Ride out the blips on the test's |
| 37 | // own out-of-band calls by retrying the typed `Forbidden` error (part of |
| 38 | // each operation's error union via distilled patches). Bounded: |
| 39 | // 6 retries of 500ms exponential backoff = max ~31.5s. |
| 40 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 41 | |
| 42 | const getHostnames = (zoneId: string, mtlsCertificateId?: string) => |
| 43 | certificateAuthorities |
| 44 | .getHostnameAssociation({ zoneId, mtlsCertificateId }) |
| 45 | .pipe( |
| 46 | Effect.map((r) => [...(r.hostnames ?? [])].sort()), |
| 47 | Effect.retry({ |
| 48 | while: (e) => e._tag === "Forbidden", |
| 49 | schedule: forbiddenRetrySchedule, |
| 50 | times: 6, |
| 51 | }), |
| 52 | ); |
| 53 | |
| 54 | // Normalize the keyed association to a known (empty) baseline so each run |
| 55 | // starts from the same cloud state regardless of what a previous (possibly |
| 56 | // interrupted) run left behind. |
| 57 | const clearAssociation = (zoneId: string, mtlsCertificateId?: string) => |
| 58 | certificateAuthorities |
| 59 | .putHostnameAssociation({ zoneId, mtlsCertificateId, hostnames: [] }) |
| 60 | .pipe( |
| 61 | Effect.retry({ |
| 62 | while: (e) => e._tag === "Forbidden", |
| 63 | schedule: forbiddenRetrySchedule, |
| 64 | times: 6, |
| 65 | }), |
| 66 | ); |
| 67 | |
| 68 | // PUT→GET on the association is eventually consistent at the edge — poll |
| 69 | // with a typed, bounded retry until the observed set matches. |
| 70 | const waitForHostnames = ( |
| 71 | zoneId: string, |
| 72 | expected: ReadonlyArray<string>, |
| 73 | mtlsCertificateId?: string, |
| 74 | ) => |
| 75 | getHostnames(zoneId, mtlsCertificateId).pipe( |
| 76 | Effect.flatMap((observed) => |
| 77 | observed.length === expected.length && |
| 78 | observed.every((h, i) => h === [...expected].sort()[i]) |
| 79 | ? Effect.succeed(observed) |
| 80 | : Effect.fail({ _tag: "HostnamesNotConverged", observed } as const), |
| 81 | ), |
| 82 | Effect.retry({ |
| 83 | while: (e) => e._tag === "HostnamesNotConverged", |
| 84 | // Bounded: 25 polls x 3s = max ~75s. PUT→GET convergence at the edge |
| 85 | // runs well past the earlier ~30s under full-suite parallel load (the |
| 86 | // API is being throttled, which stretches the propagation window). |
| 87 | schedule: Schedule.max([ |
| 88 | Schedule.spaced("3 seconds"), |
| 89 | Schedule.recurs(25), |
| 90 | ]), |
| 91 | }), |
| 92 | ); |
| 93 | |
| 94 | const waitForCertDelete = (accountId: string, mtlsCertificateId: string) => |
| 95 | mtls.getMtlsCertificate({ accountId, mtlsCertificateId }).pipe( |
| 96 | Effect.flatMap((cert) => |
| 97 | cert.id === mtlsCertificateId |
| 98 | ? Effect.fail({ _tag: "CertificateNotDeleted" } as const) |
| 99 | : Effect.void, |
| 100 | ), |
| 101 | Effect.catchTag("CertificateNotFound", () => Effect.void), |
| 102 | Effect.retry({ |
| 103 | while: (e) => e._tag === "CertificateNotDeleted", |
| 104 | // Bounded: 30 polls x 3s = max ~90s of waiting. The CA certificate |
| 105 | // delete only completes once the hostname-association clear (issued |
| 106 | // first by destroy) has propagated, and under full-suite load that |
| 107 | // combined window was observed to exceed the previous ~30s budget. |
| 108 | schedule: Schedule.max([ |
| 109 | Schedule.spaced("3 seconds"), |
| 110 | Schedule.recurs(30), |
| 111 | ]), |
| 112 | }), |
| 113 | ); |
| 114 | |
| 115 | describe.sequential( |
| 116 | "HostnameAssociation", |
| 117 | { |
| 118 | tags: [ |
| 119 | "provider:cloudflare", |
| 120 | "provider:cloudflare:certificateauthorities", |
| 121 | "provider:cloudflare:zone", |
| 122 | "live", |
| 123 | ], |
| 124 | }, |
| 125 | () => { |
| 126 | test.provider( |
| 127 | "pins Managed CA hostnames, updates in place, and clears on destroy", |
| 128 | (stack) => |
| 129 | Effect.gen(function* () { |
| 130 | const zoneId = yield* resolveZoneId; |
| 131 | |
| 132 | yield* stack.destroy(); |
| 133 | // Known baseline: no Managed CA hostname associations. |
| 134 | yield* clearAssociation(zoneId); |
| 135 | |
| 136 | const created = yield* stack.deploy( |
| 137 | Cloudflare.CertificateAuthorities.HostnameAssociation( |
| 138 | "ManagedCaHosts", |
| 139 | { |
| 140 | zoneId, |
| 141 | hostnames: [`mtls.${zoneName}`], |
| 142 | }, |
| 143 | ), |
| 144 | ); |
| 145 | |
| 146 | expect(created.zoneId).toEqual(zoneId); |
| 147 | expect(created.mtlsCertificateId).toBeUndefined(); |
| 148 | expect(created.hostnames).toEqual([`mtls.${zoneName}`]); |
| 149 | |
| 150 | yield* waitForHostnames(zoneId, [`mtls.${zoneName}`]); |
| 151 | |
| 152 | // In-place update — hostnames are the mutable aspect of the singleton. |
| 153 | const updated = yield* stack.deploy( |
| 154 | Cloudflare.CertificateAuthorities.HostnameAssociation( |
| 155 | "ManagedCaHosts", |
| 156 | { |
| 157 | zoneId, |
| 158 | hostnames: [`mtls2.${zoneName}`, `mtls.${zoneName}`], |
| 159 | }, |
| 160 | ), |
| 161 | ); |
| 162 | |
| 163 | expect([...updated.hostnames].sort()).toEqual([ |
| 164 | `mtls.${zoneName}`, |
| 165 | `mtls2.${zoneName}`, |
| 166 | ]); |
| 167 | |
| 168 | yield* waitForHostnames(zoneId, [ |
| 169 | `mtls.${zoneName}`, |
| 170 | `mtls2.${zoneName}`, |
| 171 | ]); |
| 172 | |
| 173 | yield* stack.destroy(); |
| 174 | |
| 175 | // Destroy cleared the association (PUT of an empty hostname list). |
| 176 | yield* waitForHostnames(zoneId, []); |
| 177 | }).pipe(logLevel), |
| 178 | // Three sequential edge-convergence waits (create, update, clear), each up |
| 179 | // to ~75s under throttled full-suite load, exceed the 120s default. |
| 180 | { timeout: 300_000 }, |
| 181 | ); |
| 182 | |
| 183 | test.provider( |
| 184 | "associates hostnames with an uploaded CA and destroys before the cert", |
| 185 | (stack) => |
| 186 | Effect.gen(function* () { |
| 187 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 188 | const zoneId = yield* resolveZoneId; |
| 189 | |
| 190 | yield* stack.destroy(); |
| 191 | |
| 192 | const { cert, assoc } = yield* stack.deploy( |
| 193 | Effect.gen(function* () { |
| 194 | const cert = yield* Cloudflare.MtlsCertificate.MtlsCertificate( |
| 195 | "CertAuthCa", |
| 196 | { |
| 197 | ca: true, |
| 198 | certificates: CA_CERT_1, |
| 199 | }, |
| 200 | ); |
| 201 | const assoc = |
| 202 | yield* Cloudflare.CertificateAuthorities.HostnameAssociation( |
| 203 | "CaHosts", |
| 204 | { |
| 205 | zoneId, |
| 206 | mtlsCertificateId: cert.mtlsCertificateId, |
| 207 | hostnames: [`mtls-ca.${zoneName}`], |
| 208 | }, |
| 209 | ); |
| 210 | return { cert, assoc }; |
| 211 | }), |
| 212 | ); |
| 213 | |
| 214 | expect(cert.mtlsCertificateId).toBeDefined(); |
| 215 | expect(assoc.mtlsCertificateId).toEqual(cert.mtlsCertificateId); |
| 216 | expect(assoc.hostnames).toEqual([`mtls-ca.${zoneName}`]); |
| 217 | |
| 218 | yield* waitForHostnames( |
| 219 | zoneId, |
| 220 | [`mtls-ca.${zoneName}`], |
| 221 | cert.mtlsCertificateId, |
| 222 | ); |
| 223 | |
| 224 | // Destroy must clear the association before deleting the certificate — |
| 225 | // Cloudflare refuses to delete a CA that hostnames still reference. |
| 226 | yield* stack.destroy(); |
| 227 | |
| 228 | yield* waitForCertDelete(accountId, cert.mtlsCertificateId); |
| 229 | }).pipe(logLevel), |
| 230 | { tags: ["provider:cloudflare:mtlscertificate"] }, |
| 231 | ); |
| 232 | |
| 233 | test.provider( |
| 234 | "changing the certificate key replaces the association", |
| 235 | (stack) => |
| 236 | Effect.gen(function* () { |
| 237 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 238 | const zoneId = yield* resolveZoneId; |
| 239 | |
| 240 | yield* stack.destroy(); |
| 241 | yield* clearAssociation(zoneId); |
| 242 | |
| 243 | const first = yield* stack.deploy( |
| 244 | Effect.gen(function* () { |
| 245 | const assoc = |
| 246 | yield* Cloudflare.CertificateAuthorities.HostnameAssociation( |
| 247 | "ReplaceHosts", |
| 248 | { |
| 249 | zoneId, |
| 250 | hostnames: [`mtls-replace.${zoneName}`], |
| 251 | }, |
| 252 | ); |
| 253 | return { assoc }; |
| 254 | }), |
| 255 | ); |
| 256 | |
| 257 | expect(first.assoc.mtlsCertificateId).toBeUndefined(); |
| 258 | yield* waitForHostnames(zoneId, [`mtls-replace.${zoneName}`]); |
| 259 | |
| 260 | // mtlsCertificateId keys the association — switching from the Managed |
| 261 | // CA to an uploaded CA is a replacement: the new keyed association is |
| 262 | // created and the old Managed CA list is cleared as the old instance |
| 263 | // deletes. |
| 264 | const second = yield* stack.deploy( |
| 265 | Effect.gen(function* () { |
| 266 | const cert = yield* Cloudflare.MtlsCertificate.MtlsCertificate( |
| 267 | "ReplaceCa", |
| 268 | { |
| 269 | ca: true, |
| 270 | certificates: CA_CERT_2, |
| 271 | }, |
| 272 | ); |
| 273 | const assoc = |
| 274 | yield* Cloudflare.CertificateAuthorities.HostnameAssociation( |
| 275 | "ReplaceHosts", |
| 276 | { |
| 277 | zoneId, |
| 278 | mtlsCertificateId: cert.mtlsCertificateId, |
| 279 | hostnames: [`mtls-replace.${zoneName}`], |
| 280 | }, |
| 281 | ); |
| 282 | return { cert, assoc }; |
| 283 | }), |
| 284 | ); |
| 285 | |
| 286 | expect(second.assoc.mtlsCertificateId).toEqual( |
| 287 | second.cert.mtlsCertificateId, |
| 288 | ); |
| 289 | |
| 290 | yield* waitForHostnames( |
| 291 | zoneId, |
| 292 | [`mtls-replace.${zoneName}`], |
| 293 | second.cert.mtlsCertificateId, |
| 294 | ); |
| 295 | // The old Managed CA association was cleared by the replacement. |
| 296 | yield* waitForHostnames(zoneId, []); |
| 297 | |
| 298 | yield* stack.destroy(); |
| 299 | |
| 300 | yield* waitForCertDelete(accountId, second.cert.mtlsCertificateId); |
| 301 | }).pipe(logLevel), |
| 302 | { tags: ["provider:cloudflare:mtlscertificate"] }, |
| 303 | ); |
| 304 | }, |
| 305 | ); |