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 ssl from "@distilled.cloud/cloudflare/ssl";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Result from "effect/Result";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22
23// Ordering an advanced certificate pack requires the Advanced Certificate
24// Manager subscription ($10/mo add-on per zone). The standard testing zone
25// does not carry it, so the full lifecycle test below is gated behind a
26// zone name supplied via env whose zone has ACM purchased.
27const acmZoneName = process.env.CLOUDFLARE_TEST_ACM_ZONE_NAME;
28
29const resolveZoneId = (name: string) =>
30 Effect.gen(function* () {
31 const { accountId } = yield* yield* CloudflareEnvironment;
32 const zone = yield* findZoneByName({ accountId, name });
33 if (!zone) {
34 return yield* Effect.die(new Error(`zone "${name}" not found`));
35 }
36 return zone.id;
37 });
38
39// A freshly minted scoped API token propagates eventually-consistently
40// across Cloudflare's edge — retry the typed `Forbidden` blips on the
41// test's own out-of-band calls.
42const transientRetrySchedule = Schedule.exponential("500 millis");
43
44const getPack = (zoneId: string, certificatePackId: string) =>
45 ssl.getCertificatePack({ zoneId, certificatePackId }).pipe(
46 Effect.map((pack): ssl.GetCertificatePackResponse | undefined => pack),
47 Effect.catchTag("CertificatePackNotFound", () => Effect.succeed(undefined)),
48 Effect.retry({
49 while: (e) => e._tag === "Forbidden" || e._tag === "TooManyRequests",
50 schedule: transientRetrySchedule,
51 times: 8,
52 }),
53 );
54
55test.provider(
56 "surfaces the typed AdvancedCertificateManagerRequired error on unentitled zones",
57 (stack) =>
58 Effect.gen(function* () {
59 const zoneId = yield* resolveZoneId(zoneName);
60
61 yield* stack.destroy();
62
63 // The standard testing zone lacks the ACM subscription — ordering
64 // must fail with the typed entitlement tag (Cloudflare code 1450).
65 const result = yield* ssl
66 .createCertificatePack({
67 zoneId,
68 type: "advanced",
69 certificateAuthority: "google",
70 hosts: [zoneName, `acm-probe.${zoneName}`],
71 validationMethod: "txt",
72 validityDays: 90,
73 })
74 .pipe(
75 Effect.retry({
76 while: (e) =>
77 e._tag === "Forbidden" || e._tag === "TooManyRequests",
78 schedule: transientRetrySchedule,
79 times: 8,
80 }),
81 Effect.result,
82 );
83
84 if (Result.isSuccess(result)) {
85 // Safety net: should the account ever become entitled, clean up
86 // the accidental order so the probe stays side-effect free.
87 yield* ssl
88 .deleteCertificatePack({
89 zoneId,
90 certificatePackId: result.success.id,
91 })
92 .pipe(Effect.catchTag("CertificatePackNotFound", () => Effect.void));
93 }
94 expect(Result.isFailure(result)).toBe(true);
95 if (Result.isFailure(result)) {
96 expect(result.failure._tag).toEqual(
97 "AdvancedCertificateManagerRequired",
98 );
99 }
100
101 yield* stack.destroy();
102 }).pipe(logLevel),
103 {
104 tags: [
105 "provider:cloudflare",
106 "provider:cloudflare:ssl",
107 "provider:cloudflare:zone",
108 "live",
109 ],
110 },
111);
112
113test.provider(
114 "list enumerates advanced certificate packs across zones",
115 (stack) =>
116 Effect.gen(function* () {
117 yield* stack.destroy();
118
119 // list() fans out over every zone in the account and returns the
120 // exact `read` Attributes shape for each advanced pack. The standard
121 // testing zone has no ACM subscription (so no advanced packs), but
122 // list() must still return a well-typed, exhaustively-paginated array.
123 const provider = yield* Provider.findProvider(
124 Cloudflare.Ssl.CertificatePack,
125 );
126 const all = yield* provider.list();
127
128 expect(Array.isArray(all)).toBe(true);
129 for (const pack of all) {
130 expect(typeof pack.certificatePackId).toBe("string");
131 expect(typeof pack.zoneId).toBe("string");
132 expect(Array.isArray(pack.hosts)).toBe(true);
133 }
134
135 yield* stack.destroy();
136 }).pipe(logLevel),
137 {
138 tags: ["provider:cloudflare", "provider:cloudflare:ssl", "live"],
139 timeout: 120_000,
140 },
141);
142
143test.provider.skipIf(!acmZoneName)(
144 "list includes a deployed advanced certificate pack",
145 (stack) =>
146 Effect.gen(function* () {
147 const name = acmZoneName!;
148 const zoneId = yield* resolveZoneId(name);
149 const hosts = [name, `acmlist.${name}`];
150
151 yield* stack.destroy();
152
153 const pack = yield* stack.deploy(
154 Effect.gen(function* () {
155 return yield* Cloudflare.Ssl.CertificatePack("ListPack", {
156 zoneId,
157 certificateAuthority: "google",
158 hosts,
159 validationMethod: "txt",
160 validityDays: 90,
161 });
162 }),
163 );
164
165 const provider = yield* Provider.findProvider(
166 Cloudflare.Ssl.CertificatePack,
167 );
168 const all = yield* provider.list();
169
170 expect(
171 all.some((p) => p.certificatePackId === pack.certificatePackId),
172 ).toBe(true);
173
174 yield* stack.destroy();
175 }).pipe(logLevel),
176 {
177 tags: [
178 "provider:cloudflare",
179 "provider:cloudflare:ssl",
180 "provider:cloudflare:zone",
181 "live",
182 ],
183 timeout: 120_000,
184 },
185);
186
187test.provider.skipIf(!acmZoneName)(
188 "orders a pack, updates validation method in place, and deletes it",
189 (stack) =>
190 Effect.gen(function* () {
191 const name = acmZoneName!;
192 const zoneId = yield* resolveZoneId(name);
193 const hosts = [name, `acmtest.${name}`];
194
195 yield* stack.destroy();
196
197 // Create — order the advanced pack. Issuance is async: the
198 // resource returns as soon as the order is placed.
199 const pack = yield* stack.deploy(
200 Effect.gen(function* () {
201 return yield* Cloudflare.Ssl.CertificatePack("Pack", {
202 zoneId,
203 certificateAuthority: "google",
204 hosts,
205 validationMethod: "txt",
206 validityDays: 90,
207 });
208 }),
209 );
210
211 expect(pack.zoneId).toEqual(zoneId);
212 expect(pack.certificatePackId).toBeTruthy();
213 expect([...pack.hosts].sort()).toEqual([...hosts].sort());
214 expect(pack.certificateAuthority).toEqual("google");
215
216 // Out-of-band verify via the distilled API.
217 const live = yield* getPack(zoneId, pack.certificatePackId);
218 expect(live?.id).toEqual(pack.certificatePackId);
219 expect(live?.type).toEqual("advanced");
220
221 // Update in place — validationMethod is mutable via the SSL
222 // verification API; the pack id must not change.
223 const updated = yield* stack.deploy(
224 Effect.gen(function* () {
225 return yield* Cloudflare.Ssl.CertificatePack("Pack", {
226 zoneId,
227 certificateAuthority: "google",
228 hosts,
229 validationMethod: "http",
230 validityDays: 90,
231 });
232 }),
233 );
234 expect(updated.certificatePackId).toEqual(pack.certificatePackId);
235
236 yield* stack.destroy();
237
238 // Wait until the pack is actually gone (deletion is async — the
239 // pack may linger briefly in `pending_deletion`).
240 const gone = yield* getPack(zoneId, pack.certificatePackId).pipe(
241 Effect.repeat({
242 schedule: Schedule.spaced("3 seconds"),
243 until: (p) => p === undefined || p.status === "deleted",
244 times: 20,
245 }),
246 );
247 expect(gone === undefined || gone.status === "deleted").toBe(true);
248 }).pipe(logLevel),
249 {
250 tags: [
251 "provider:cloudflare",
252 "provider:cloudflare:ssl",
253 "provider:cloudflare:zone",
254 "live",
255 ],
256 timeout: 120_000,
257 },
258);