1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import type { CertificateAttributes } from "@/Cloudflare/Gateway/Certificate";
6import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
7import { expect } from "alchemy-test";
8import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15// Cloudflare caps Zero Trust certificate *generation* at 3 per 24h per account
16// and does NOT refund that rolling counter on delete (it is separate from the
17// "10 active certificates" count limit), so neither `stack.destroy()` nor
18// `bun nuke` can reclaim budget. The replacement test mints two certificates
19// per run, so it exhausts the quota fastest — skip it by default. Set
20// RUN_GATEWAY_CERT_TESTS=1 to run it against an account with fresh daily budget.
21const runGatewayCertTests = !!process.env.RUN_GATEWAY_CERT_TESTS;
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28// Ride out 403 blips (`Forbidden`) while the harness-minted token
29// propagates across Cloudflare's edge.
30const getCertificate = (accountId: string, certificateId: string) =>
31 zeroTrust.getGatewayCertificate({ accountId, certificateId }).pipe(
32 Effect.retry({
33 while: (e) => e._tag === "Forbidden",
34 schedule: Schedule.exponential("500 millis"),
35 times: 8,
36 }),
37 );
38
39// A deleted certificate surfaces as `GatewayCertificateNotFound`
40// (Cloudflare code 2027, "Certificate not found in SSL store").
41const expectGone = (accountId: string, certificateId: string) =>
42 getCertificate(accountId, certificateId).pipe(
43 Effect.flatMap(() =>
44 Effect.fail({ _tag: "CertificateNotDeleted" } as const),
45 ),
46 Effect.catchTag("GatewayCertificateNotFound", () => Effect.void),
47 Effect.retry({
48 while: (e) => e._tag === "CertificateNotDeleted",
49 schedule: Schedule.max([
50 Schedule.exponential("500 millis"),
51 Schedule.recurs(10),
52 ]),
53 }),
54 );
55
56/**
57 * Pull the typed {@link zeroTrust.GatewayCertificateQuotaReached} value out of
58 * a Cause regardless of whether the engine raised it as a failure or a defect.
59 */
60const findQuotaError = (
61 cause: Cause.Cause<unknown>,
62): zeroTrust.GatewayCertificateQuotaReached | undefined =>
63 cause.reasons
64 .map((reason) =>
65 Cause.isFailReason(reason)
66 ? reason.error
67 : Cause.isDieReason(reason)
68 ? reason.defect
69 : undefined,
70 )
71 .find(
72 (value): value is zeroTrust.GatewayCertificateQuotaReached =>
73 value instanceof zeroTrust.GatewayCertificateQuotaReached,
74 );
75
76/**
77 * Deploy, but converge a `GatewayCertificateQuotaReached` failure (Cloudflare
78 * caps gateway certificate creation at 3 per 24 hours per account) to
79 * `undefined` so the calling test can skip its assertions gracefully instead
80 * of failing on same-day reruns.
81 */
82const deployUnlessQuotaReached = (
83 stack: Test.ScratchStack,
84 eff: Effect.Effect<any, any, any>,
85): Effect.Effect<CertificateAttributes | undefined, any, any> =>
86 stack
87 .deploy(eff)
88 .pipe(
89 Effect.catchCause(
90 (
91 cause,
92 ): Effect.Effect<CertificateAttributes | undefined, any, never> =>
93 findQuotaError(cause)
94 ? Effect.succeed(undefined)
95 : Effect.failCause(cause),
96 ),
97 );
98
99const logQuotaSkip = (what: string) =>
100 Effect.logWarning(
101 `skipping ${what}: Cloudflare's 3-per-24h gateway certificate creation quota is exhausted (GatewayCertificateQuotaReached)`,
102 );
103
104test.provider(
105 "create an activated certificate, deactivate in place, destroy",
106 (stack) =>
107 Effect.gen(function* () {
108 const { accountId } = yield* yield* CloudflareEnvironment;
109
110 yield* stack.destroy();
111
112 const cert = yield* deployUnlessQuotaReached(
113 stack,
114 Cloudflare.Gateway.Certificate("InspectionCa", {
115 validityPeriodDays: 30,
116 }),
117 );
118 if (cert === undefined) {
119 yield* logQuotaSkip("activate/deactivate assertions");
120 yield* stack.destroy();
121 return;
122 }
123
124 expect(cert.certificateId).toBeTruthy();
125 expect(cert.accountId).toEqual(accountId);
126 expect(cert.certificateType).toEqual("gateway_managed");
127 expect(cert.certificate).toContain("BEGIN CERTIFICATE");
128 expect(cert.fingerprint).toBeTruthy();
129 // Activation settles within seconds; the provider waits for it.
130 expect(cert.bindingStatus).toEqual("available");
131
132 const live = yield* getCertificate(accountId, cert.certificateId);
133 expect(live.bindingStatus).toEqual("available");
134 expect(live.fingerprint).toEqual(cert.fingerprint);
135
136 // Deactivate in place — same certificate, new binding status.
137 const deactivated = yield* stack.deploy(
138 Cloudflare.Gateway.Certificate("InspectionCa", {
139 validityPeriodDays: 30,
140 activate: false,
141 }),
142 );
143 expect(deactivated.certificateId).toEqual(cert.certificateId);
144 expect(deactivated.bindingStatus).toEqual("inactive");
145
146 yield* stack.destroy();
147 yield* expectGone(accountId, cert.certificateId);
148 }).pipe(logLevel),
149 {
150 tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"],
151 timeout: 120_000,
152 },
153);
154
155test.provider.skipIf(!runGatewayCertTests)(
156 "changing the validity period replaces the certificate",
157 (stack) =>
158 Effect.gen(function* () {
159 const { accountId } = yield* yield* CloudflareEnvironment;
160
161 yield* stack.destroy();
162
163 // Keep both generations inactive so the test stays fast — no edge
164 // activation round-trips.
165 const first = yield* deployUnlessQuotaReached(
166 stack,
167 Cloudflare.Gateway.Certificate("ShortCa", {
168 validityPeriodDays: 30,
169 activate: false,
170 }),
171 );
172 if (first === undefined) {
173 yield* logQuotaSkip("replacement assertions");
174 yield* stack.destroy();
175 return;
176 }
177 expect(first.bindingStatus).toEqual("inactive");
178
179 const second = yield* deployUnlessQuotaReached(
180 stack,
181 Cloudflare.Gateway.Certificate("ShortCa", {
182 validityPeriodDays: 60,
183 activate: false,
184 }),
185 );
186 if (second === undefined) {
187 yield* logQuotaSkip("replacement assertions (second generation)");
188 yield* stack.destroy();
189 return;
190 }
191 // The validity period is immutable — a new certificate is minted
192 // and the old one deleted.
193 expect(second.certificateId).not.toEqual(first.certificateId);
194 yield* expectGone(accountId, first.certificateId);
195
196 yield* stack.destroy();
197 yield* expectGone(accountId, second.certificateId);
198 }).pipe(logLevel),
199 {
200 tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"],
201 timeout: 120_000,
202 },
203);
204
205test.provider(
206 "list enumerates the deployed gateway certificate",
207 (stack) =>
208 Effect.gen(function* () {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210
211 yield* stack.destroy();
212
213 const cert = yield* deployUnlessQuotaReached(
214 stack,
215 Cloudflare.Gateway.Certificate("ListCa", {
216 validityPeriodDays: 30,
217 activate: false,
218 }),
219 );
220
221 const provider = yield* Provider.findProvider(
222 Cloudflare.Gateway.Certificate,
223 );
224 const all = yield* provider.list();
225
226 // Always a well-typed array of Attributes scoped to this account.
227 expect(Array.isArray(all)).toBe(true);
228 expect(all.every((c) => c.accountId === accountId)).toBe(true);
229
230 if (cert === undefined) {
231 // Same-day reruns can exhaust the 3-per-24h creation quota; the
232 // read-only list assertion above still proves enumeration works.
233 yield* logQuotaSkip("list presence assertion");
234 yield* stack.destroy();
235 return;
236 }
237
238 const found = all.find((c) => c.certificateId === cert.certificateId);
239 expect(found).toBeDefined();
240 expect(found?.accountId).toEqual(accountId);
241
242 yield* stack.destroy();
243 yield* expectGone(accountId, cert.certificateId);
244 }).pipe(logLevel),
245 {
246 tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"],
247 timeout: 120_000,
248 },
249);