1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import { isResourceState, State, type ResourceState } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import * as originTls from "@distilled.cloud/cloudflare/origin-tls-client-auth";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import {
14 CERT_2,
15 CERT_3,
16 CERT_4,
17 CERT_8,
18 CERT_9,
19 KEY_2,
20 KEY_3,
21 KEY_4,
22 KEY_8,
23 KEY_9,
24} from "./fixtures/certs.ts";
25
26const { test } = Test.make({ providers: Cloudflare.providers() });
27
28const logLevel = Effect.provideService(
29 MinimumLogLevel,
30 process.env.DEBUG ? "Debug" : "Info",
31);
32
33const zoneName =
34 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
35
36const resolveZoneId = Effect.gen(function* () {
37 const { accountId } = yield* yield* CloudflareEnvironment;
38 const zone = yield* findZoneByName({ accountId, name: zoneName });
39 if (!zone) {
40 return yield* Effect.die(
41 new Error(`zone "${zoneName}" not found in account`),
42 );
43 }
44 return zone.id;
45});
46
47// The scoped API token the test harness mints propagates eventually-
48// consistently across Cloudflare's edge — a fresh token intermittently 403s
49// with "Unable to authenticate request". Ride out the blips on the test's
50// own out-of-band calls by retrying the typed `Forbidden` error (part of
51// each operation's error union via distilled patches).
52const forbiddenRetrySchedule = Schedule.exponential("500 millis");
53
54const getCertificate = (zoneId: string, certificateId: string) =>
55 originTls.getHostnameCertificate({ zoneId, certificateId }).pipe(
56 Effect.retry({
57 while: (e) => e._tag === "Forbidden",
58 schedule: forbiddenRetrySchedule,
59 times: 8,
60 }),
61 );
62
63// Re-uploading a PEM that collides with a not-yet-cleared tombstone (a prior
64// run's `pending_deletion` cert for the same PEM) resurrects it under the same
65// id, and the certificate can briefly read back as `pending_deletion` before
66// transitioning to a live status. Poll until it settles, bounded.
67const waitForLive = (zoneId: string, certificateId: string) =>
68 getCertificate(zoneId, certificateId).pipe(
69 Effect.flatMap((cert) =>
70 cert.status !== "pending_deletion" && cert.status !== "deleted"
71 ? Effect.succeed(cert)
72 : Effect.fail({ _tag: "CertificateNotLive" } as const),
73 ),
74 Effect.retry({
75 while: (e) => e._tag === "CertificateNotLive",
76 schedule: Schedule.max([
77 Schedule.spaced("3 seconds"),
78 Schedule.recurs(15),
79 ]),
80 }),
81 );
82
83// Cloudflare keeps deleted hostname client certificates readable by id as
84// `pending_deletion` / `deleted` tombstones, so "gone" means tombstoned or
85// 404.
86const waitForGone = (zoneId: string, certificateId: string) =>
87 getCertificate(zoneId, certificateId).pipe(
88 Effect.flatMap((cert) =>
89 cert.status === "pending_deletion" || cert.status === "deleted"
90 ? Effect.void
91 : Effect.fail({ _tag: "CertificateNotDeleted" } as const),
92 ),
93 Effect.catchTag("HostnameCertificateNotFound", () => Effect.void),
94 Effect.retry({
95 while: (e) => e._tag === "CertificateNotDeleted",
96 schedule: Schedule.max([
97 Schedule.exponential("500 millis"),
98 Schedule.recurs(10),
99 ]),
100 }),
101 );
102
103// Purge any live hostname client certificates with this suite's fixture PEMs
104// left behind by interrupted runs. Filtering by PEM content keeps the purge
105// scoped to this file — other suites in the directory own other fixtures.
106const purgeCertificates = (zoneId: string, pems: string[]) =>
107 Effect.gen(function* () {
108 const list = yield* originTls.listHostnameCertificates({ zoneId }).pipe(
109 Effect.retry({
110 while: (e) => e._tag === "Forbidden",
111 schedule: forbiddenRetrySchedule,
112 times: 8,
113 }),
114 );
115 const targets = pems.map((p) => p.trim());
116 yield* Effect.forEach(
117 list.result.filter(
118 (c) =>
119 c.id &&
120 c.status !== "deleted" &&
121 c.status !== "pending_deletion" &&
122 targets.includes((c.certificate ?? "").trim()),
123 ),
124 (c) =>
125 originTls
126 .deleteHostnameCertificate({ zoneId, certificateId: c.id! })
127 .pipe(
128 Effect.retry({
129 while: (e) => e._tag === "CertificatePendingDeployment",
130 schedule: Schedule.spaced("5 seconds"),
131 times: 10,
132 }),
133 Effect.catchTag(
134 ["HostnameCertificateNotFound", "CertificatePendingDeletion"],
135 () => Effect.void,
136 ),
137 ),
138 );
139 });
140
141test.provider(
142 "uploads and deletes a hostname client certificate",
143 (stack) =>
144 Effect.gen(function* () {
145 const zoneId = yield* resolveZoneId;
146
147 // Dedicated PEM (CERT_9): the sibling "replaces" test churns CERT_3
148 // concurrently under the global concurrent config, and its
149 // `pending_deletion` tombstone for CERT_3 would otherwise collide here
150 // (a re-upload resurrects the half-deleted cert, which never goes live).
151 yield* stack.destroy();
152 yield* purgeCertificates(zoneId, [CERT_9]);
153
154 const cert = yield* stack.deploy(
155 Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", {
156 zoneId,
157 certificate: CERT_9,
158 privateKey: Redacted.make(KEY_9),
159 }),
160 );
161
162 expect(cert.certificateId).toBeDefined();
163 expect(cert.zoneId).toEqual(zoneId);
164 expect(cert.status).toBeDefined();
165 expect(cert.issuer).toContain("Alchemy AOP Test Cert 9");
166
167 const actual = yield* waitForLive(zoneId, cert.certificateId);
168 expect(actual.id).toEqual(cert.certificateId);
169 expect(actual.status).not.toEqual("deleted");
170 expect(actual.status).not.toEqual("pending_deletion");
171
172 yield* stack.destroy();
173
174 yield* waitForGone(zoneId, cert.certificateId);
175 }).pipe(
176 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
177 logLevel,
178 ),
179 {
180 tags: [
181 "provider:cloudflare",
182 "provider:cloudflare:origintlsclientauth",
183 "provider:cloudflare:zone",
184 "live",
185 ],
186 timeout: 120_000,
187 },
188);
189
190test.provider(
191 "replaces the hostname certificate when the PEM changes",
192 (stack) =>
193 Effect.gen(function* () {
194 const zoneId = yield* resolveZoneId;
195
196 yield* stack.destroy();
197 yield* purgeCertificates(zoneId, [CERT_3, CERT_4]);
198
199 const original = yield* stack.deploy(
200 Cloudflare.OriginTlsClientAuth.HostnameCertificate("ReplaceHostCert", {
201 zoneId,
202 certificate: CERT_3,
203 privateKey: Redacted.make(KEY_3),
204 }),
205 );
206
207 const replaced = yield* stack.deploy(
208 Cloudflare.OriginTlsClientAuth.HostnameCertificate("ReplaceHostCert", {
209 zoneId,
210 certificate: CERT_4,
211 privateKey: Redacted.make(KEY_4),
212 }),
213 );
214
215 expect(replaced.certificateId).toBeDefined();
216 expect(replaced.certificateId).not.toEqual(original.certificateId);
217 expect(replaced.issuer).toContain("Alchemy AOP Test Cert 4");
218
219 // The old certificate must be gone after the replacement completes.
220 yield* waitForGone(zoneId, original.certificateId);
221
222 const actual = yield* getCertificate(zoneId, replaced.certificateId);
223 expect(actual.id).toEqual(replaced.certificateId);
224
225 yield* stack.destroy();
226
227 yield* waitForGone(zoneId, replaced.certificateId);
228 }).pipe(
229 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
230 logLevel,
231 ),
232 {
233 tags: [
234 "provider:cloudflare",
235 "provider:cloudflare:origintlsclientauth",
236 "provider:cloudflare:zone",
237 "live",
238 ],
239 timeout: 120_000,
240 },
241);
242
243test.provider(
244 "recovers a creating-state row whose Output-valued certificate was lost (#736)",
245 (stack) =>
246 Effect.gen(function* () {
247 const zoneId = yield* resolveZoneId;
248
249 // Dedicated PEM (CERT_2): the other hostname-certificate tests own
250 // CERT_3/4/8/9; CERT_2 is otherwise only used by the *zone-level*
251 // Certificate suite, which is a separate Cloudflare collection.
252 yield* stack.destroy();
253 yield* purgeCertificates(zoneId, [CERT_2]);
254
255 const deployCert = () =>
256 stack.deploy(
257 Cloudflare.OriginTlsClientAuth.HostnameCertificate("WedgedHostCert", {
258 zoneId,
259 certificate: CERT_2,
260 privateKey: Redacted.make(KEY_2),
261 }),
262 );
263
264 const created = yield* deployCert();
265 expect(created.certificateId).toBeDefined();
266
267 // Rewrite the persisted row into the wedged shape an interrupted deploy
268 // leaves behind when `certificate` was Output-valued: `creating`, no
269 // attributes, and the certificate lost in the round-trip (#736).
270 const state = yield* yield* State;
271 const stage = stack.stage;
272 const fqns = yield* state.list({ stack: stack.name, stage });
273 const rows = yield* Effect.forEach(fqns, (fqn) =>
274 state
275 .get({ stack: stack.name, stage, fqn })
276 .pipe(Effect.map((row) => ({ fqn, row }))),
277 );
278 const wedged = rows.find(
279 (r): r is { fqn: string; row: ResourceState } =>
280 isResourceState(r.row) &&
281 r.row.resourceType ===
282 "Cloudflare.OriginTlsClientAuth.HostnameCertificate",
283 );
284 if (!wedged) {
285 return yield* Effect.die(
286 new Error("no HostnameCertificate state row found after deploy"),
287 );
288 }
289 yield* state.set({
290 stack: stack.name,
291 stage,
292 fqn: wedged.fqn,
293 value: {
294 ...wedged.row,
295 status: "creating",
296 attr: undefined,
297 props: { ...wedged.row.props, certificate: undefined },
298 },
299 });
300
301 // `read` cannot content-match without `olds.certificate`, so planning
302 // falls through to `diff` with the junk olds. Before the fix this
303 // crashed with `TypeError: undefined is not an object (evaluating
304 // 'pem.trim')` in normalizePem; after it, diff falls through and
305 // reconcile re-adopts the live certificate by PEM content.
306 const recovered = yield* deployCert();
307 expect(recovered.certificateId).toEqual(created.certificateId);
308
309 yield* stack.destroy();
310
311 yield* waitForGone(zoneId, recovered.certificateId);
312 }).pipe(
313 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
314 logLevel,
315 ),
316 {
317 tags: [
318 "provider:cloudflare",
319 "provider:cloudflare:origintlsclientauth",
320 "provider:cloudflare:zone",
321 "live",
322 ],
323 timeout: 120_000,
324 },
325);
326
327test.provider(
328 "list enumerates the deployed hostname certificate",
329 (stack) =>
330 Effect.gen(function* () {
331 const zoneId = yield* resolveZoneId;
332
333 yield* stack.destroy();
334 yield* purgeCertificates(zoneId, [CERT_8]);
335
336 const cert = yield* stack.deploy(
337 Cloudflare.OriginTlsClientAuth.HostnameCertificate("ListHostCert", {
338 zoneId,
339 // Dedicated PEM (see fixtures/certs.ts): keeps this certificate out
340 // of the upload/delete churn the sibling tests put CERT_3 through,
341 // so it appears in the eventually-consistent list promptly.
342 certificate: CERT_8,
343 privateKey: Redacted.make(KEY_8),
344 }),
345 );
346
347 const provider = yield* Provider.findProvider(
348 Cloudflare.OriginTlsClientAuth.HostnameCertificate,
349 );
350 // A freshly uploaded certificate can lag the zone list endpoint by
351 // tens of seconds — especially when the same PEM was recently deleted
352 // and re-created (the prior suites churn CERT_3), so the list endpoint
353 // keeps serving the stale "gone" view for a while. Poll list() until it
354 // appears, bounded to ~60s.
355 const found = yield* provider.list().pipe(
356 Effect.map((all) =>
357 all.find((c) => c.certificateId === cert.certificateId),
358 ),
359 Effect.flatMap((match) =>
360 match
361 ? Effect.succeed(match)
362 : Effect.fail({ _tag: "CertificateNotListed" } as const),
363 ),
364 Effect.retry({
365 while: (e) => e._tag === "CertificateNotListed",
366 schedule: Schedule.spaced("3 seconds"),
367 times: 20,
368 }),
369 );
370 expect(found.zoneId).toEqual(zoneId);
371
372 yield* stack.destroy();
373
374 yield* waitForGone(zoneId, cert.certificateId);
375 }).pipe(
376 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
377 logLevel,
378 ),
379 {
380 tags: [
381 "provider:cloudflare",
382 "provider:cloudflare:origintlsclientauth",
383 "provider:cloudflare:zone",
384 "live",
385 ],
386 timeout: 120_000,
387 },
388);