1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as clientCertificates from "@distilled.cloud/cloudflare/client-certificates";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import { CSR_A, CSR_B } from "./fixtures/csr.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
24const resolveZoneId = Effect.gen(function* () {
25 const { accountId } = yield* yield* CloudflareEnvironment;
26 const zone = yield* findZoneByName({ accountId, name: zoneName });
27 if (!zone) {
28 return yield* Effect.die(
29 new Error(`zone "${zoneName}" not found in account`),
30 );
31 }
32 return zone.id;
33});
34
35// The scoped API token the test harness mints propagates eventually-
36// consistently across Cloudflare's edge — a fresh token intermittently 403s.
37// Ride out the blips on the test's own out-of-band calls by retrying the
38// typed `Forbidden` error (part of every client-certificates operation's
39// error union via distilled patches).
40const forbiddenRetrySchedule = Schedule.exponential("500 millis");
41
42const getCertificate = (zoneId: string, clientCertificateId: string) =>
43 clientCertificates.getClientCertificate({ zoneId, clientCertificateId }).pipe(
44 Effect.retry({
45 while: (e) => e._tag === "Forbidden",
46 schedule: forbiddenRetrySchedule,
47 times: 8,
48 }),
49 );
50
51// DELETE revokes the certificate; revoked certificates stay listed on the
52// zone forever, so "gone" means the certificate is revoking, not a 404.
53// Revocation transits `pending_revocation` asynchronously and Cloudflare can
54// linger there well past the test's budget before flipping to `revoked` —
55// both states are the delete semantic (the certificate can no longer serve
56// mTLS), so treat either as "gone" and poll the typed GET until it lands,
57// bounded so the test fails fast instead of riding the vitest timeout.
58const isRevoking = (status: string | null | undefined): boolean =>
59 status === "revoked" || status === "pending_revocation";
60
61const waitUntilRevoked = (zoneId: string, clientCertificateId: string) =>
62 getCertificate(zoneId, clientCertificateId).pipe(
63 // Frequent, bounded spaced poll (~90s): revocation settles through
64 // `pending_revocation` asynchronously, so check every 3s rather than
65 // backing off exponentially (whose late gaps would overshoot the
66 // timeout). Bounded so a stuck revoke fails fast instead of riding the
67 // vitest timeout.
68 Effect.repeat({
69 schedule: Schedule.spaced("3 seconds"),
70 until: (cert) => isRevoking(cert.status),
71 times: 30,
72 }),
73 );
74
75test.provider(
76 "create signs the CSR, destroy revokes the certificate",
77 (stack) =>
78 Effect.gen(function* () {
79 const zoneId = yield* resolveZoneId;
80
81 yield* stack.destroy();
82
83 // adopt(true): revoked certificates stay listed forever and the
84 // cold-read identity is (csr, validityDays), so an interrupted prior
85 // run can leave a live certificate this deploy should converge onto.
86 const cert = yield* stack.deploy(
87 Effect.gen(function* () {
88 return yield* Cloudflare.ClientCertificate.ClientCertificate(
89 "CreateCert",
90 {
91 zoneId,
92 csr: CSR_A,
93 validityDays: 90,
94 },
95 ).pipe(adopt(true));
96 }),
97 );
98
99 expect(cert.clientCertificateId).toBeDefined();
100 expect(cert.zoneId).toEqual(zoneId);
101 expect(cert.status).toEqual("active");
102 expect(cert.validityDays).toEqual(90);
103 expect(cert.certificate).toContain("-----BEGIN CERTIFICATE-----");
104 expect(cert.commonName).toEqual(
105 "alchemy-client-cert-a.alchemy-test-2.us",
106 );
107 expect(cert.organization).toEqual("Alchemy");
108 expect(cert.country).toEqual("US");
109 expect(cert.issuedOn).toBeDefined();
110 expect(cert.expiresOn).toBeDefined();
111 expect(cert.serialNumber).toBeDefined();
112 expect(cert.fingerprintSha256).toBeDefined();
113
114 // Out-of-band verification through the raw API.
115 const live = yield* getCertificate(zoneId, cert.clientCertificateId);
116 expect(live.id).toEqual(cert.clientCertificateId);
117 expect(live.status).toEqual("active");
118 expect(live.validityDays).toEqual(90);
119
120 // Re-deploying the same props is a no-op — same physical certificate.
121 const again = yield* stack.deploy(
122 Effect.gen(function* () {
123 return yield* Cloudflare.ClientCertificate.ClientCertificate(
124 "CreateCert",
125 {
126 zoneId,
127 csr: CSR_A,
128 validityDays: 90,
129 },
130 ).pipe(adopt(true));
131 }),
132 );
133 expect(again.clientCertificateId).toEqual(cert.clientCertificateId);
134
135 yield* stack.destroy();
136
137 // Destroy revokes — the certificate remains listed but transitions to
138 // `revoked`, which this resource treats as deleted.
139 const revoked = yield* waitUntilRevoked(zoneId, cert.clientCertificateId);
140 expect(isRevoking(revoked.status)).toBe(true);
141 }).pipe(logLevel),
142 // Two deploys (issue + no-op re-deploy) on Cloudflare's per-zone-serialized
143 // client-cert API plus a ~90s spaced revoke poll — under a full concurrent
144 // `./test/Cloudflare` run this contends with sibling cert suites, so give
145 // headroom while staying bounded.
146 {
147 tags: [
148 "provider:cloudflare",
149 "provider:cloudflare:clientcertificate",
150 "provider:cloudflare:zone",
151 "live",
152 ],
153 timeout: 240_000,
154 },
155);
156
157test.provider(
158 "changing validityDays replaces — new id issued, old certificate revoked",
159 (stack) =>
160 Effect.gen(function* () {
161 const zoneId = yield* resolveZoneId;
162
163 yield* stack.destroy();
164
165 const initial = yield* stack.deploy(
166 Effect.gen(function* () {
167 return yield* Cloudflare.ClientCertificate.ClientCertificate(
168 "ReplaceCert",
169 {
170 zoneId,
171 csr: CSR_B,
172 validityDays: 30,
173 },
174 ).pipe(adopt(true));
175 }),
176 );
177 expect(initial.status).toEqual("active");
178 expect(initial.validityDays).toEqual(30);
179
180 // validityDays is immutable — the API cannot re-sign, so the engine
181 // replaces: a new certificate is issued and the old one is revoked.
182 const replaced = yield* stack.deploy(
183 Effect.gen(function* () {
184 return yield* Cloudflare.ClientCertificate.ClientCertificate(
185 "ReplaceCert",
186 {
187 zoneId,
188 csr: CSR_B,
189 validityDays: 60,
190 },
191 ).pipe(adopt(true));
192 }),
193 );
194
195 expect(replaced.clientCertificateId).not.toEqual(
196 initial.clientCertificateId,
197 );
198 expect(replaced.status).toEqual("active");
199 expect(replaced.validityDays).toEqual(60);
200
201 // The outgoing certificate was revoked as part of the replacement.
202 const oldCert = yield* waitUntilRevoked(
203 zoneId,
204 initial.clientCertificateId,
205 );
206 expect(isRevoking(oldCert.status)).toBe(true);
207
208 // The replacement is live and untouched by the old one's revocation.
209 const live = yield* getCertificate(zoneId, replaced.clientCertificateId);
210 expect(live.status).toEqual("active");
211
212 yield* stack.destroy();
213
214 const revoked = yield* waitUntilRevoked(
215 zoneId,
216 replaced.clientCertificateId,
217 );
218 expect(isRevoking(revoked.status)).toBe(true);
219 }).pipe(logLevel),
220 // This is a REPLACEMENT: the second deploy issues a brand-new certificate
221 // and revokes the old one, then the test runs TWO ~90s spaced revoke polls
222 // (the outgoing cert after replace, the replacement after destroy) on top
223 // of three serialized client-cert mutations. Under a full concurrent
224 // `./test/Cloudflare` run this far exceeds the default 120s, so give real
225 // headroom while every poll stays bounded.
226 {
227 tags: [
228 "provider:cloudflare",
229 "provider:cloudflare:clientcertificate",
230 "provider:cloudflare:zone",
231 "live",
232 ],
233 timeout: 300_000,
234 },
235);
236
237test.provider(
238 "list enumerates client certificates across zones",
239 (stack) =>
240 Effect.gen(function* () {
241 const zoneId = yield* resolveZoneId;
242
243 yield* stack.destroy();
244
245 const cert = yield* stack.deploy(
246 Effect.gen(function* () {
247 return yield* Cloudflare.ClientCertificate.ClientCertificate(
248 "ListCert",
249 {
250 zoneId,
251 csr: CSR_A,
252 validityDays: 90,
253 },
254 ).pipe(adopt(true));
255 }),
256 );
257
258 const provider = yield* Provider.findProvider(
259 Cloudflare.ClientCertificate.ClientCertificate,
260 );
261
262 // A freshly-issued client certificate is eventually consistent in the
263 // account-wide list fan-out — poll (bounded) until it shows up rather
264 // than asserting on a single immediate snapshot.
265 const found = yield* Effect.gen(function* () {
266 const all = yield* provider.list();
267 return all.find(
268 (c) => c.clientCertificateId === cert.clientCertificateId,
269 );
270 }).pipe(
271 Effect.flatMap((f) =>
272 f === undefined
273 ? Effect.fail("not-yet-listed" as const)
274 : Effect.succeed(f),
275 ),
276 Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }),
277 );
278
279 expect(found).toBeDefined();
280 expect(found?.zoneId).toEqual(zoneId);
281 expect(found?.status).not.toEqual("revoked");
282 }).pipe(
283 Effect.ensuring(stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore)),
284 logLevel,
285 ),
286 // `list()` fans out over every zone in the account and exhaustively
287 // paginates each, plus a deploy on the per-zone-serialized client-cert API
288 // — give headroom under a full concurrent `./test/Cloudflare` run.
289 {
290 tags: [
291 "provider:cloudflare",
292 "provider:cloudflare:clientcertificate",
293 "provider:cloudflare:zone",
294 "live",
295 ],
296 timeout: 180_000,
297 },
298);