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 originTls from "@distilled.cloud/cloudflare/origin-tls-client-auth";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import { CERT_5, CERT_6, KEY_5, KEY_6 } from "./fixtures/certs.ts";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const zoneName =
22 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
23
24// Deterministic per-test hostnames. Each test owns a disjoint subdomain so
25// reruns never collide (never derive names from Date.now()/random).
26const HOST_LIFECYCLE = `alchemy-aop-assoc.${zoneName}`;
27const HOST_REPLACE_A = `alchemy-aop-assoc-replace-a.${zoneName}`;
28const HOST_REPLACE_B = `alchemy-aop-assoc-replace-b.${zoneName}`;
29
30const resolveZoneId = Effect.gen(function* () {
31 const { accountId } = yield* yield* CloudflareEnvironment;
32 const zone = yield* findZoneByName({ accountId, name: zoneName });
33 if (!zone) {
34 return yield* Effect.die(
35 new Error(`zone "${zoneName}" not found in account`),
36 );
37 }
38 return zone.id;
39});
40
41// The scoped API token the test harness mints propagates eventually-
42// consistently across Cloudflare's edge — a fresh token intermittently 403s
43// with "Unable to authenticate request". Ride out the blips on the test's
44// own out-of-band calls by retrying the typed `Forbidden` error (part of
45// each operation's error union via distilled patches).
46const forbiddenRetrySchedule = Schedule.exponential("500 millis");
47
48// Read a hostname's association out-of-band; "never configured" (404 / code
49// 1553) and "voided" (`enabled: null`) both read as undefined.
50const getAssociation = (zoneId: string, hostname: string) =>
51 originTls.getHostname({ zoneId, hostname }).pipe(
52 Effect.map((assoc) =>
53 assoc.enabled === null || assoc.enabled === undefined ? undefined : assoc,
54 ),
55 Effect.catchTag("HostnameAssociationNotFound", () =>
56 Effect.succeed(undefined),
57 ),
58 Effect.retry({
59 while: (e) => e._tag === "Forbidden",
60 schedule: forbiddenRetrySchedule,
61 times: 8,
62 }),
63 );
64
65// Void any association left behind by interrupted runs so the test starts
66// from a clean slate.
67const voidAssociation = (zoneId: string, hostname: string) =>
68 Effect.gen(function* () {
69 const observed = yield* getAssociation(zoneId, hostname);
70 if (!observed?.certId) return;
71 yield* originTls
72 .putHostname({
73 zoneId,
74 config: [
75 {
76 hostname,
77 certId: observed.certId,
78 enabled: null,
79 },
80 ],
81 })
82 .pipe(
83 // The pinned certificate may already be gone (code 1415); the
84 // association is inert without it.
85 Effect.catchTag("InvalidHostnameConfig", () => Effect.void),
86 );
87 });
88
89// Purge any live hostname client certificates with this suite's fixture PEMs
90// left behind by interrupted runs. Filtering by PEM content keeps the purge
91// scoped to this file — other suites in the directory own other fixtures.
92const purgeCertificates = (zoneId: string, pems: string[]) =>
93 Effect.gen(function* () {
94 const list = yield* originTls.listHostnameCertificates({ zoneId }).pipe(
95 Effect.retry({
96 while: (e) => e._tag === "Forbidden",
97 schedule: forbiddenRetrySchedule,
98 times: 8,
99 }),
100 );
101 const targets = pems.map((p) => p.trim());
102 yield* Effect.forEach(
103 list.result.filter(
104 (c) =>
105 c.id &&
106 c.status !== "deleted" &&
107 c.status !== "pending_deletion" &&
108 targets.includes((c.certificate ?? "").trim()),
109 ),
110 (c) =>
111 originTls
112 .deleteHostnameCertificate({ zoneId, certificateId: c.id! })
113 .pipe(
114 Effect.retry({
115 while: (e) =>
116 e._tag === "CertificatePendingDeployment" ||
117 e._tag === "HostnameCertificateInUse",
118 schedule: Schedule.spaced("5 seconds"),
119 times: 10,
120 }),
121 Effect.catchTag(
122 ["HostnameCertificateNotFound", "CertificatePendingDeletion"],
123 () => Effect.void,
124 ),
125 ),
126 );
127 });
128
129// Both certificates stay deployed across every step so updates/replacements
130// only touch the association itself (replacing a resource while removing its
131// old dependency in the same deploy is a known engine deadlock).
132const program = (opts: {
133 zoneId: string;
134 hostname: string;
135 cert: "5" | "6";
136 enabled: boolean;
137}) =>
138 Effect.gen(function* () {
139 const cert5 = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate(
140 "AssocCert5",
141 {
142 zoneId: opts.zoneId,
143 certificate: CERT_5,
144 privateKey: Redacted.make(KEY_5),
145 },
146 );
147 const cert6 = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate(
148 "AssocCert6",
149 {
150 zoneId: opts.zoneId,
151 certificate: CERT_6,
152 privateKey: Redacted.make(KEY_6),
153 },
154 );
155 const pinned = opts.cert === "6" ? cert6 : cert5;
156 const association =
157 yield* Cloudflare.OriginTlsClientAuth.HostnameAssociation("AopHost", {
158 zoneId: opts.zoneId,
159 hostname: opts.hostname,
160 certId: pinned.certificateId,
161 enabled: opts.enabled,
162 });
163 return { cert5, cert6, association };
164 });
165
166describe.skipIf(!!process.env.FAST)(
167 "HostnameAssociation",
168 {
169 tags: [
170 "provider:cloudflare",
171 "provider:cloudflare:origintlsclientauth",
172 "live",
173 ],
174 },
175 () => {
176 // Hostname associations are keyed entirely by {zoneId, hostname} and
177 // Cloudflare exposes no endpoint that enumerates which hostnames in a zone
178 // have an AOP association, so `list()` is non-listable and returns []. This
179 // read-only assertion always runs (no cert/hostname deploy required).
180 test.provider("list returns [] for the non-listable association", (stack) =>
181 Effect.gen(function* () {
182 yield* stack.destroy();
183
184 const provider = yield* Provider.findProvider(
185 Cloudflare.OriginTlsClientAuth.HostnameAssociation,
186 );
187 const all = yield* provider.list();
188 expect(all).toEqual([]);
189
190 yield* stack.destroy();
191 }).pipe(
192 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
193 logLevel,
194 ),
195 );
196
197 test.provider(
198 "associates a hostname, updates cert and enablement in place, voids on destroy",
199 (stack) =>
200 Effect.gen(function* () {
201 const zoneId = yield* resolveZoneId;
202
203 yield* stack.destroy();
204 yield* voidAssociation(zoneId, HOST_LIFECYCLE);
205 yield* purgeCertificates(zoneId, [CERT_5, CERT_6]);
206
207 // Create — pin cert 5, enabled.
208 const initial = yield* stack.deploy(
209 program({
210 zoneId,
211 hostname: HOST_LIFECYCLE,
212 cert: "5",
213 enabled: true,
214 }),
215 );
216
217 expect(initial.association.zoneId).toEqual(zoneId);
218 expect(initial.association.hostname).toEqual(HOST_LIFECYCLE);
219 expect(initial.association.certId).toEqual(
220 initial.cert5.certificateId,
221 );
222 expect(initial.association.enabled).toEqual(true);
223
224 const observed = yield* getAssociation(zoneId, HOST_LIFECYCLE);
225 expect(observed?.certId).toEqual(initial.cert5.certificateId);
226 expect(observed?.enabled).toEqual(true);
227
228 // Update in place — repin to cert 6 and disable enforcement.
229 const updated = yield* stack.deploy(
230 program({
231 zoneId,
232 hostname: HOST_LIFECYCLE,
233 cert: "6",
234 enabled: false,
235 }),
236 );
237
238 expect(updated.association.hostname).toEqual(HOST_LIFECYCLE);
239 expect(updated.association.certId).toEqual(
240 updated.cert6.certificateId,
241 );
242 expect(updated.association.enabled).toEqual(false);
243
244 const reobserved = yield* getAssociation(zoneId, HOST_LIFECYCLE);
245 expect(reobserved?.certId).toEqual(updated.cert6.certificateId);
246 expect(reobserved?.enabled).toEqual(false);
247
248 // Destroy — the association is voided (`enabled: null`).
249 yield* stack.destroy();
250
251 const gone = yield* getAssociation(zoneId, HOST_LIFECYCLE).pipe(
252 Effect.flatMap((assoc) =>
253 assoc === undefined
254 ? Effect.void
255 : Effect.fail({ _tag: "AssociationNotVoided" } as const),
256 ),
257 // Frequent, bounded spaced poll (~240s): voiding settles through
258 // edge propagation asynchronously and can occasionally take well
259 // past two minutes, so poll every 5s with a generous bound rather
260 // than backing off exponentially (whose late gaps overshoot).
261 Effect.retry({
262 while: (e) => e._tag === "AssociationNotVoided",
263 schedule: Schedule.spaced("5 seconds"),
264 times: 48,
265 }),
266 Effect.map(() => true),
267 );
268 expect(gone).toEqual(true);
269 }).pipe(
270 Effect.ensuring(
271 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
272 ),
273 logLevel,
274 ),
275 // Two sequential deploys, each uploading two hostname client-certificates
276 // plus the association, on Cloudflare's per-zone-serialized client-cert
277 // API. Under a full concurrent `./test/Cloudflare` run this zone's cert
278 // mutations contend with the sibling Certificate/HostnameCertificate
279 // suites (each create/delete bounded-retries the per-zone 409), then a
280 // ~240s spaced void poll — give real headroom while staying bounded.
281 { tags: ["provider:cloudflare:zone"], timeout: 420_000 },
282 );
283
284 test.provider(
285 "replaces the association when the hostname changes",
286 (stack) =>
287 Effect.gen(function* () {
288 const zoneId = yield* resolveZoneId;
289
290 yield* stack.destroy();
291 yield* voidAssociation(zoneId, HOST_REPLACE_A);
292 yield* voidAssociation(zoneId, HOST_REPLACE_B);
293 yield* purgeCertificates(zoneId, [CERT_5, CERT_6]);
294
295 const initial = yield* stack.deploy(
296 program({
297 zoneId,
298 hostname: HOST_REPLACE_A,
299 cert: "5",
300 enabled: true,
301 }),
302 );
303 expect(initial.association.hostname).toEqual(HOST_REPLACE_A);
304
305 // Changing the hostname is a replacement — the old hostname's
306 // association is voided and the new hostname gets its own entry.
307 const replaced = yield* stack.deploy(
308 program({
309 zoneId,
310 hostname: HOST_REPLACE_B,
311 cert: "5",
312 enabled: true,
313 }),
314 );
315 expect(replaced.association.hostname).toEqual(HOST_REPLACE_B);
316 expect(replaced.association.certId).toEqual(
317 replaced.cert5.certificateId,
318 );
319
320 const observedNew = yield* getAssociation(zoneId, HOST_REPLACE_B);
321 expect(observedNew?.enabled).toEqual(true);
322
323 // The old hostname's association must be voided by the replacement.
324 const oldGone = yield* getAssociation(zoneId, HOST_REPLACE_A).pipe(
325 Effect.flatMap((assoc) =>
326 assoc === undefined
327 ? Effect.void
328 : Effect.fail({ _tag: "AssociationNotVoided" } as const),
329 ),
330 // Frequent, bounded spaced poll (~120s): voiding settles through
331 // edge propagation asynchronously, so check every 5s rather than
332 // backing off exponentially (whose late gaps overshoot the timeout).
333 Effect.retry({
334 while: (e) => e._tag === "AssociationNotVoided",
335 schedule: Schedule.spaced("5 seconds"),
336 times: 24,
337 }),
338 Effect.map(() => true),
339 );
340 expect(oldGone).toEqual(true);
341
342 yield* stack.destroy();
343
344 // Voiding is asynchronous (the void PUT settles through edge
345 // propagation) — poll until the association reads as absent rather
346 // than asserting on a single (potentially stale) read.
347 const newGone = yield* getAssociation(zoneId, HOST_REPLACE_B).pipe(
348 Effect.flatMap((assoc) =>
349 assoc === undefined
350 ? Effect.void
351 : Effect.fail({ _tag: "AssociationNotVoided" } as const),
352 ),
353 // Frequent, bounded spaced poll (~120s): voiding settles through
354 // edge propagation asynchronously, so check every 5s rather than
355 // backing off exponentially (whose late gaps overshoot the timeout).
356 Effect.retry({
357 while: (e) => e._tag === "AssociationNotVoided",
358 schedule: Schedule.spaced("5 seconds"),
359 times: 24,
360 }),
361 Effect.map(() => true),
362 );
363 expect(newGone).toEqual(true);
364 }).pipe(
365 Effect.ensuring(
366 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
367 ),
368 logLevel,
369 ),
370 // Same per-zone-serialized client-cert contention as the lifecycle case
371 // above, plus two spaced void polls — give real headroom under load.
372 { tags: ["provider:cloudflare:zone"], timeout: 300_000 },
373 );
374 },
375);