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 {
13 CERT_1,
14 CERT_2,
15 CERT_7,
16 KEY_1,
17 KEY_2,
18 KEY_7,
19} from "./fixtures/certs.ts";
20
21const { test } = Test.make({ providers: Cloudflare.providers() });
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const zoneName =
29 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
30
31const resolveZoneId = Effect.gen(function* () {
32 const { accountId } = yield* yield* CloudflareEnvironment;
33 const zone = yield* findZoneByName({ accountId, name: zoneName });
34 if (!zone) {
35 return yield* Effect.die(
36 new Error(`zone "${zoneName}" not found in account`),
37 );
38 }
39 return zone.id;
40});
41
42// The scoped API token the test harness mints propagates eventually-
43// consistently across Cloudflare's edge — a fresh token intermittently 403s
44// with "Unable to authenticate request". Ride out the blips on the test's
45// own out-of-band calls by retrying the typed `Forbidden` error (part of
46// each operation's error union via distilled patches).
47//
48// CAPPED at 3s: a bare `Schedule.exponential("500 millis")` reaches a 64s
49// single delay by the 8th retry (~127s total), so a token blip would sleep
50// for over a minute between attempts and blow the test budget even though the
51// blip itself clears in seconds. Capping keeps the cadence steady.
52const forbiddenRetrySchedule = Schedule.min([
53 Schedule.exponential("500 millis"),
54 Schedule.spaced("3 seconds"),
55]);
56
57const getCertificate = (zoneId: string, certificateId: string) =>
58 originTls.getOriginTlsClientAuth({ zoneId, certificateId }).pipe(
59 Effect.retry({
60 while: (e) => e._tag === "Forbidden",
61 schedule: forbiddenRetrySchedule,
62 times: 8,
63 }),
64 );
65
66// Cloudflare keeps deleted zone client certificates readable by id as
67// `pending_deletion` / `deleted` tombstones (and excludes them from list),
68// so "gone" means tombstoned or 404.
69const waitForGone = (zoneId: string, certificateId: string) =>
70 getCertificate(zoneId, certificateId).pipe(
71 Effect.flatMap((cert) =>
72 cert.status === "pending_deletion" || cert.status === "deleted"
73 ? Effect.void
74 : Effect.fail({ _tag: "CertificateNotDeleted" } as const),
75 ),
76 Effect.catchTag("CertificateNotFound", () => Effect.void),
77 Effect.retry({
78 while: (e) => e._tag === "CertificateNotDeleted",
79 // STEADY 3s cadence, bounded ~60s. The previous
80 // `Schedule.exponential("500 millis")` (capped only by `recurs(10)`)
81 // ramped to 64s/128s/256s single delays — so once a tombstone lagged a
82 // little, the poll would *sleep* far past the deadline before noticing
83 // the cert was already gone. That overshoot — not genuinely-slow CF —
84 // was what blew the test budget. A fixed interval detects the tombstone
85 // within one poll of it actually happening.
86 schedule: Schedule.max([
87 Schedule.spaced("3 seconds"),
88 Schedule.recurs(45),
89 ]),
90 }),
91 );
92
93// Purge any live zone client certificates left behind by interrupted runs so
94// each test starts from a clean slate (the zone-level cert store is only
95// exercised by this suite).
96const purgeCertificates = (zoneId: string) =>
97 Effect.gen(function* () {
98 const list = yield* originTls.listOriginTlsClientAuths({ zoneId }).pipe(
99 Effect.retry({
100 while: (e) => e._tag === "Forbidden",
101 schedule: forbiddenRetrySchedule,
102 times: 8,
103 }),
104 );
105 yield* Effect.forEach(
106 (list.result ?? []).filter(
107 (c) =>
108 c.id && c.status !== "deleted" && c.status !== "pending_deletion",
109 ),
110 (c) =>
111 originTls
112 .deleteOriginTlsClientAuth({ zoneId, certificateId: c.id! })
113 .pipe(
114 // A leftover cert still propagating its initial deployment rejects
115 // the delete with code 1434 (pending deployment); ride it out so
116 // the purge actually clears the slate instead of cascading. A
117 // delete racing a sibling per-zone mutation is rejected with HTTP
118 // 409 (`ZoneClientCertConflict`) — retry that too.
119 Effect.retry({
120 while: (e) =>
121 e._tag === "CertificatePendingDeployment" ||
122 e._tag === "ZoneClientCertConflict",
123 schedule: Schedule.spaced("5 seconds"),
124 times: 12,
125 }),
126 // Deletion is idempotent: a cert that flipped into (or past)
127 // deletion between the list and this call answers HTTP 400
128 // "Certificate is already deleted." — both mean it is gone.
129 Effect.catchTag(
130 ["CertificateNotFound", "CertificateAlreadyDeleted"],
131 () => Effect.void,
132 ),
133 ),
134 );
135 });
136
137// The zone client-certificate store dedups by PEM content and the upload /
138// delete lifecycle is eventually consistent, so two cases uploading the same
139// `CERT_1` concurrently churn each other (collisions + stale list views).
140// Run the cases one at a time so each owns its certificate content.
141describe.sequential(
142 "Certificate",
143 {
144 tags: [
145 "provider:cloudflare",
146 "provider:cloudflare:origintlsclientauth",
147 "provider:cloudflare:zone",
148 "live",
149 ],
150 },
151 () => {
152 test.provider(
153 "uploads and deletes a zone client certificate",
154 (stack) =>
155 Effect.gen(function* () {
156 const zoneId = yield* resolveZoneId;
157
158 yield* stack.destroy();
159 yield* purgeCertificates(zoneId);
160
161 const cert = yield* stack.deploy(
162 Cloudflare.OriginTlsClientAuth.Certificate("AopCert", {
163 zoneId,
164 certificate: CERT_1,
165 privateKey: Redacted.make(KEY_1),
166 }),
167 );
168
169 expect(cert.certificateId).toBeDefined();
170 expect(cert.zoneId).toEqual(zoneId);
171 expect(cert.status).toBeDefined();
172 expect(cert.expiresOn).toBeDefined();
173 expect(cert.issuer).toContain("Alchemy AOP Test Cert 1");
174
175 const actual = yield* getCertificate(zoneId, cert.certificateId);
176 expect(actual.id).toEqual(cert.certificateId);
177 expect(actual.status).not.toEqual("deleted");
178 expect(actual.status).not.toEqual("pending_deletion");
179
180 yield* stack.destroy();
181
182 yield* waitForGone(zoneId, cert.certificateId);
183 }).pipe(
184 Effect.ensuring(
185 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
186 ),
187 logLevel,
188 ),
189 { timeout: 200_000 },
190 );
191
192 test.provider(
193 "replaces the certificate when the PEM changes",
194 (stack) =>
195 Effect.gen(function* () {
196 const zoneId = yield* resolveZoneId;
197
198 yield* stack.destroy();
199 yield* purgeCertificates(zoneId);
200
201 const original = yield* stack.deploy(
202 Cloudflare.OriginTlsClientAuth.Certificate("ReplaceCert", {
203 zoneId,
204 certificate: CERT_1,
205 privateKey: Redacted.make(KEY_1),
206 }),
207 );
208
209 const replaced = yield* stack.deploy(
210 Cloudflare.OriginTlsClientAuth.Certificate("ReplaceCert", {
211 zoneId,
212 certificate: CERT_2,
213 privateKey: Redacted.make(KEY_2),
214 }),
215 );
216
217 expect(replaced.certificateId).toBeDefined();
218 expect(replaced.certificateId).not.toEqual(original.certificateId);
219 expect(replaced.issuer).toContain("Alchemy AOP Test Cert 2");
220
221 // The old certificate must be gone after the replacement completes.
222 yield* waitForGone(zoneId, original.certificateId);
223
224 const actual = yield* getCertificate(zoneId, replaced.certificateId);
225 expect(actual.id).toEqual(replaced.certificateId);
226
227 yield* stack.destroy();
228
229 yield* waitForGone(zoneId, replaced.certificateId);
230 }).pipe(
231 Effect.ensuring(
232 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
233 ),
234 logLevel,
235 ),
236 { timeout: 200_000 },
237 );
238
239 // Canonical `list()` test (zone-scoped collection): `list()` fans out over
240 // every zone via `listAllZones` and enumerates the per-zone certificate store,
241 // hydrating each into the same `read` Attributes shape. Deploy a certificate
242 // to the standing test zone and assert it appears in the exhaustive result.
243 test.provider(
244 "list enumerates the deployed zone client certificate",
245 (stack) =>
246 Effect.gen(function* () {
247 const zoneId = yield* resolveZoneId;
248
249 yield* stack.destroy();
250 yield* purgeCertificates(zoneId);
251
252 const cert = yield* stack.deploy(
253 Cloudflare.OriginTlsClientAuth.Certificate("ListCert", {
254 zoneId,
255 // Dedicated PEM (see fixtures/certs.ts): keeps this certificate out
256 // of the upload/delete churn the sibling tests put CERT_1 through,
257 // so it appears in the eventually-consistent list promptly.
258 certificate: CERT_7,
259 privateKey: Redacted.make(KEY_7),
260 }),
261 );
262
263 const provider = yield* Provider.findProvider(
264 Cloudflare.OriginTlsClientAuth.Certificate,
265 );
266 // A freshly uploaded certificate can lag the zone list endpoint by tens
267 // of seconds — especially when the same PEM was recently deleted and
268 // re-created (the sibling tests churn CERT_1), so the list endpoint
269 // keeps serving the stale "gone" view for a while. Poll list() until it
270 // appears, bounded to ~60s, rather than asserting on a single read.
271 const found = yield* provider.list().pipe(
272 Effect.map((all) =>
273 all.find((c) => c.certificateId === cert.certificateId),
274 ),
275 Effect.flatMap((match) =>
276 match
277 ? Effect.succeed(match)
278 : Effect.fail({ _tag: "CertificateNotListed" } as const),
279 ),
280 Effect.retry({
281 while: (e) => e._tag === "CertificateNotListed",
282 schedule: Schedule.spaced("3 seconds"),
283 times: 20,
284 }),
285 );
286 expect(found.zoneId).toEqual(zoneId);
287
288 yield* stack.destroy();
289
290 yield* waitForGone(zoneId, cert.certificateId);
291 }).pipe(
292 Effect.ensuring(
293 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
294 ),
295 logLevel,
296 ),
297 // With the poll backoffs now CAPPED (steady cadence, see
298 // `waitForGone`/`forbiddenRetrySchedule`), this test's three sequential
299 // eventual-consistency waits are each bounded to ~60s — the list-appear
300 // poll, the destroy's pending-deployment delete retry, and `waitForGone` —
301 // for a deterministic worst case of ~200s. The timeout matches that bound;
302 // 120s was below the legitimate maximum (the acute flake was the old
303 // uncapped exponential sleeping past the deadline, now fixed).
304 { timeout: 240_000 },
305 );
306 },
307);