1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import * as Provider from "@/Provider";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as originCa from "@distilled.cloud/cloudflare/origin-ca-certificates";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12import { TEST_CSR } 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 = "alchemy-test-2.us";
22// Each test owns a DISTINCT hostname. Adoption keys purely off the hostname
23// set (the engine probes `read` with `olds: news` even on a fresh deploy, so
24// `findByHostnames` runs every time), and Origin CA certs carry no other
25// identity. A shared hostname therefore couples the tests: a leftover cert
26// from another test or a prior crashed run can be adopted, and a sibling's
27// destroy can revoke a cert this test is mid-verifying. Per-test hostnames
28// make each test fully self-contained.
29
30// The scoped API token the test harness mints propagates eventually-
31// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
32// declared in the distilled error union) on out-of-band verification calls.
33const getCertificate = (certificateId: string) =>
34 originCa.getOriginCaCertificate({ certificateId }).pipe(
35 Effect.retry({
36 while: (e) => e._tag === "Forbidden",
37 schedule: Schedule.exponential("500 millis"),
38 times: 8,
39 }),
40 );
41
42// After destroy, the certificate is revoked. GET keeps returning a revoked
43// certificate (with `revokedAt` set) for a while, and may eventually flip to
44// `CertificateNotFound` — either state counts as gone.
45const expectRevoked = (certificateId: string) =>
46 getCertificate(certificateId).pipe(
47 Effect.flatMap((cert) =>
48 cert.revokedAt
49 ? Effect.void
50 : Effect.fail({ _tag: "CertificateNotRevoked" } as const),
51 ),
52 Effect.catchTag("CertificateNotFound", () => Effect.void),
53 Effect.retry({
54 while: (e) => e._tag === "CertificateNotRevoked",
55 schedule: Schedule.max([
56 Schedule.exponential("500 millis"),
57 Schedule.recurs(10),
58 ]),
59 }),
60 );
61
62test.provider(
63 "issue, verify, and revoke a certificate",
64 (stack) =>
65 Effect.gen(function* () {
66 const hostname = `originissue.${zoneName}`;
67 yield* stack.destroy();
68
69 const cert = yield* stack.deploy(
70 Cloudflare.OriginCaCertificate.OriginCaCertificate("Cert", {
71 csr: TEST_CSR,
72 hostnames: [hostname],
73 requestType: "origin-rsa",
74 requestedValidity: 90,
75 }).pipe(adopt(true)),
76 );
77
78 // Issuance is synchronous — the signed PEM comes back on create.
79 expect(cert.certificateId).toBeTruthy();
80 expect(cert.certificate).toContain("-----BEGIN CERTIFICATE-----");
81 expect(cert.csr).toContain("-----BEGIN CERTIFICATE REQUEST-----");
82 expect(cert.hostnames).toEqual([hostname]);
83 expect(cert.requestType).toEqual("origin-rsa");
84 expect(cert.requestedValidity).toEqual(90);
85 expect(cert.expiresOn).toBeTruthy();
86
87 // Out-of-band verification: the certificate is live and not revoked.
88 const live = yield* getCertificate(cert.certificateId);
89 expect(live.id).toEqual(cert.certificateId);
90 expect(live.hostnames).toEqual([hostname]);
91 expect(live.revokedAt ?? null).toBeNull();
92
93 // Redeploying identical props is a no-op (same certificate).
94 const noop = yield* stack.deploy(
95 Cloudflare.OriginCaCertificate.OriginCaCertificate("Cert", {
96 csr: TEST_CSR,
97 hostnames: [hostname],
98 requestType: "origin-rsa",
99 requestedValidity: 90,
100 }).pipe(adopt(true)),
101 );
102 expect(noop.certificateId).toEqual(cert.certificateId);
103
104 // Destroy revokes the certificate; a second destroy is idempotent.
105 yield* stack.destroy();
106 yield* expectRevoked(cert.certificateId);
107 yield* stack.destroy();
108 }).pipe(logLevel),
109 {
110 tags: [
111 "provider:cloudflare",
112 "provider:cloudflare:origincacertificate",
113 "live",
114 ],
115 },
116);
117
118test.provider(
119 "list enumerates issued certificates",
120 (stack) =>
121 Effect.gen(function* () {
122 const hostname = `originlist.${zoneName}`;
123 yield* stack.destroy();
124
125 const cert = yield* stack.deploy(
126 Cloudflare.OriginCaCertificate.OriginCaCertificate("ListCert", {
127 csr: TEST_CSR,
128 hostnames: [hostname],
129 requestType: "origin-rsa",
130 requestedValidity: 90,
131 }).pipe(adopt(true)),
132 );
133
134 const provider = yield* Provider.findProvider(
135 Cloudflare.OriginCaCertificate.OriginCaCertificate,
136 );
137 const all = yield* provider.list();
138
139 // `list()` is account-wide but enumerated per zone with the `zone_id`
140 // query param; a zone the scoped token can't read for Origin CA rejects
141 // with the typed `Forbidden` tag, which is swallowed so that zone simply
142 // contributes []. The standing token can list the test zone, so the
143 // freshly issued certificate must appear in the exhaustively-paginated
144 // result in the `read` Attributes shape.
145 expect(Array.isArray(all)).toBe(true);
146 const match = all.find((c) => c.certificateId === cert.certificateId);
147 expect(match).toBeDefined();
148 expect(match!.certificateId).toEqual(cert.certificateId);
149 expect(match!.hostnames).toEqual([hostname]);
150 expect(match!.requestType).toEqual("origin-rsa");
151
152 yield* stack.destroy();
153 yield* expectRevoked(cert.certificateId);
154 }).pipe(logLevel),
155 {
156 tags: [
157 "provider:cloudflare",
158 "provider:cloudflare:origincacertificate",
159 "live",
160 ],
161 },
162);
163
164// Explicit revoke for certificates the wedged-state recovery orphans out of
165// engine state (their rows lose `attr`, so `stack.destroy()` can't reclaim
166// them). Revoked/absent both count as cleaned up.
167const revokeQuietly = (certificateId: string) =>
168 originCa.deleteOriginCaCertificate({ certificateId }).pipe(
169 Effect.retry({
170 while: (e) => e._tag === "Forbidden",
171 schedule: Schedule.exponential("500 millis"),
172 times: 8,
173 }),
174 Effect.catchTag("CertificateNotFound", () => Effect.void),
175 Effect.catchTag("CertificateAlreadyRevoked", () => Effect.void),
176 );
177
178test.provider(
179 "recovers a creating-state row whose Output-valued hostnames were lost (#736)",
180 (stack) =>
181 Effect.gen(function* () {
182 const hostname = `originwedged.${zoneName}`;
183 yield* stack.destroy();
184
185 const deployCert = () =>
186 stack.deploy(
187 Cloudflare.OriginCaCertificate.OriginCaCertificate("WedgedCert", {
188 csr: TEST_CSR,
189 hostnames: [hostname],
190 requestType: "origin-rsa",
191 requestedValidity: 90,
192 }).pipe(adopt(true)),
193 );
194
195 const created = yield* deployCert();
196 expect(created.certificateId).toBeTruthy();
197
198 // Rewrite the certificate's persisted row into the wedged shape an
199 // interrupted deploy leaves behind: `creating`, no attributes, and the
200 // Output-valued props lost in the state round-trip (#736).
201 const state = yield* yield* State;
202 const stage = stack.stage;
203 const wedgeRow = (junk: Record<string, unknown>) =>
204 Effect.gen(function* () {
205 const fqns = yield* state.list({ stack: stack.name, stage });
206 const rows = yield* Effect.forEach(fqns, (fqn) =>
207 state
208 .get({ stack: stack.name, stage, fqn })
209 .pipe(Effect.map((row) => ({ fqn, row }))),
210 );
211 const wedged = rows.find(
212 (r): r is { fqn: string; row: ResourceState } =>
213 isResourceState(r.row) &&
214 r.row.resourceType ===
215 "Cloudflare.OriginCaCertificate.OriginCaCertificate",
216 );
217 if (!wedged) {
218 return yield* Effect.die(
219 new Error("no OriginCaCertificate state row found after deploy"),
220 );
221 }
222 yield* state.set({
223 stack: stack.name,
224 stage,
225 fqn: wedged.fqn,
226 value: {
227 ...wedged.row,
228 status: "creating",
229 attr: undefined,
230 props: { ...wedged.row.props, ...junk },
231 },
232 });
233 });
234
235 // The wedge orphans the previous certificate out of engine state, so
236 // destroy can never reclaim it — revoke it explicitly on scope close
237 // even if the body fails mid-way.
238 yield* Effect.addFinalizer(() =>
239 revokeQuietly(created.certificateId).pipe(Effect.ignore),
240 );
241
242 // Wedge 1 — the #736 shape: the hostnames array survives serialization
243 // but its Output-valued ELEMENT deserializes as undefined. Before the
244 // fix, read's cold path only checked `hostnames?.length`, passed the
245 // truthy-length junk array into `findByHostnames`, and crashed on
246 // `hostnames[0].replace(...)`. After the fix, read reports "missing"
247 // and the engine re-creates.
248 yield* wedgeRow({ hostnames: [undefined] });
249 const recovered = yield* deployCert();
250 expect(recovered.certificateId).toBeTruthy();
251 expect(recovered.hostnames).toEqual([hostname]);
252 const live = yield* getCertificate(recovered.certificateId);
253 expect(live.hostnames).toEqual([hostname]);
254 expect(live.revokedAt ?? null).toBeNull();
255
256 yield* Effect.addFinalizer(() =>
257 revokeQuietly(recovered.certificateId).pipe(Effect.ignore),
258 );
259
260 // Wedge 2 — every Output-valued prop lost wholesale (`undefined`, not
261 // `[undefined]`): the whole hostnames array AND the csr. Guarded both
262 // before and after the fix in `read`; csr must be wedged too because
263 // `diff` keys its "no prior props" guard off `olds.csr`. Recovery is
264 // the same: read reports missing, the engine re-creates.
265 yield* wedgeRow({ hostnames: undefined, csr: undefined });
266 const recovered2 = yield* deployCert();
267 expect(recovered2.certificateId).toBeTruthy();
268 expect(recovered2.hostnames).toEqual([hostname]);
269 const live2 = yield* getCertificate(recovered2.certificateId);
270 expect(live2.hostnames).toEqual([hostname]);
271 expect(live2.revokedAt ?? null).toBeNull();
272
273 yield* stack.destroy();
274 yield* expectRevoked(recovered2.certificateId);
275
276 // Explicitly reclaim the certificates the wedges orphaned.
277 yield* revokeQuietly(created.certificateId);
278 yield* revokeQuietly(recovered.certificateId);
279 yield* expectRevoked(created.certificateId);
280 yield* expectRevoked(recovered.certificateId);
281 }).pipe(logLevel),
282 {
283 tags: [
284 "provider:cloudflare",
285 "provider:cloudflare:origincacertificate",
286 "live",
287 ],
288 timeout: 240_000,
289 },
290);
291
292test.provider(
293 "replacement on requestedValidity change",
294 (stack) =>
295 Effect.gen(function* () {
296 const hostname = `originvalidity.${zoneName}`;
297 yield* stack.destroy();
298
299 const initial = yield* stack.deploy(
300 Cloudflare.OriginCaCertificate.OriginCaCertificate("ValidityCert", {
301 csr: TEST_CSR,
302 hostnames: [hostname],
303 requestType: "origin-rsa",
304 requestedValidity: 90,
305 }).pipe(adopt(true)),
306 );
307 expect(initial.requestedValidity).toEqual(90);
308
309 // There is no update API — changing the validity issues a new
310 // certificate and revokes the old one.
311 const replaced = yield* stack.deploy(
312 Cloudflare.OriginCaCertificate.OriginCaCertificate("ValidityCert", {
313 csr: TEST_CSR,
314 hostnames: [hostname],
315 requestType: "origin-rsa",
316 requestedValidity: 30,
317 }).pipe(adopt(true)),
318 );
319
320 expect(replaced.certificateId).not.toEqual(initial.certificateId);
321 expect(replaced.requestedValidity).toEqual(30);
322 yield* expectRevoked(initial.certificateId);
323
324 const live = yield* getCertificate(replaced.certificateId);
325 expect(live.revokedAt ?? null).toBeNull();
326
327 yield* stack.destroy();
328 yield* expectRevoked(replaced.certificateId);
329 }).pipe(logLevel),
330 {
331 tags: [
332 "provider:cloudflare",
333 "provider:cloudflare:origincacertificate",
334 "live",
335 ],
336 },
337);
338
339test.provider(
340 "replacement on hostnames change",
341 (stack) =>
342 Effect.gen(function* () {
343 const hostname = `originhostsa.${zoneName}`;
344 const altHostname = `originhostsb.${zoneName}`;
345 yield* stack.destroy();
346
347 const initial = yield* stack.deploy(
348 Cloudflare.OriginCaCertificate.OriginCaCertificate("HostnamesCert", {
349 csr: TEST_CSR,
350 hostnames: [hostname],
351 requestType: "origin-rsa",
352 requestedValidity: 90,
353 }).pipe(adopt(true)),
354 );
355 expect(initial.hostnames).toEqual([hostname]);
356
357 // Hostnames are immutable — changing the set issues a new certificate
358 // and revokes the old one.
359 const replaced = yield* stack.deploy(
360 Cloudflare.OriginCaCertificate.OriginCaCertificate("HostnamesCert", {
361 csr: TEST_CSR,
362 hostnames: [hostname, altHostname],
363 requestType: "origin-rsa",
364 requestedValidity: 90,
365 }).pipe(adopt(true)),
366 );
367
368 expect(replaced.certificateId).not.toEqual(initial.certificateId);
369 expect([...replaced.hostnames].sort()).toEqual(
370 [hostname, altHostname].sort(),
371 );
372 yield* expectRevoked(initial.certificateId);
373
374 yield* stack.destroy();
375 yield* expectRevoked(replaced.certificateId);
376 }).pipe(logLevel),
377 {
378 tags: [
379 "provider:cloudflare",
380 "provider:cloudflare:origincacertificate",
381 "live",
382 ],
383 },
384);