1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as certificateAuthorities from "@distilled.cloud/cloudflare/certificate-authorities";
7import * as mtls from "@distilled.cloud/cloudflare/mtls-certificates";
8import { describe, expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import { CA_CERT_1, CA_CERT_2 } from "./fixtures/certs.ts";
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22
23const 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.
40const forbiddenRetrySchedule = Schedule.exponential("500 millis");
41
42const 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.
57const 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.
70const 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
94const 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
115describe.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);