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 dns from "@distilled.cloud/cloudflare/dns";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18const zoneName =
19 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
20
21const resolveZoneId = Effect.gen(function* () {
22 const { accountId } = yield* yield* CloudflareEnvironment;
23 const zone = yield* findZoneByName({ accountId, name: zoneName });
24 if (!zone) {
25 return yield* Effect.die(
26 new Error(`zone "${zoneName}" not found in account`),
27 );
28 }
29 return zone.id;
30});
31
32// The scoped API token the test harness mints propagates eventually-
33// consistently across Cloudflare's edge — a fresh token intermittently 403s.
34// Ride out the blips on the test's own out-of-band calls by retrying the
35// typed `Forbidden` error (part of each op's error union via distilled
36// patches).
37const forbiddenRetrySchedule = Schedule.exponential("500 millis");
38
39const getDnssec = (zoneId: string) =>
40 dns.getDnssec({ zoneId }).pipe(
41 Effect.retry({
42 while: (e) => e._tag === "Forbidden",
43 schedule: forbiddenRetrySchedule,
44 times: 8,
45 }),
46 );
47
48/** Collapse the live five-state status into its desired-state family. */
49const family = (status: string | null | undefined): "active" | "disabled" =>
50 status === "active" || status === "pending" ? "active" : "disabled";
51
52// Normalize the zone to a known baseline (DNSSEC off) so each run starts
53// from the same cloud state regardless of what a previous (possibly
54// interrupted) run left behind. Only deactivates when needed so re-runs
55// don't trip "already disabled" API validation.
56const normalizeDisabled = (zoneId: string) =>
57 Effect.gen(function* () {
58 const observed = yield* getDnssec(zoneId);
59 if (family(observed.status) === "disabled") return;
60 // DELETE /dnssec only clears records after signing is already off —
61 // deactivation goes through PATCH status:"disabled".
62 yield* dns.patchDnssec({ zoneId, status: "disabled" }).pipe(
63 Effect.retry({
64 while: (e) => e._tag === "Forbidden",
65 schedule: forbiddenRetrySchedule,
66 times: 8,
67 }),
68 );
69 // Deactivation is eventually consistent — wait (bounded) for the zone
70 // to leave the active family before deploying on top of it.
71 yield* getDnssec(zoneId).pipe(
72 Effect.repeat({
73 schedule: Schedule.spaced("2 seconds"),
74 until: (o) => family(o.status) === "disabled",
75 times: 15,
76 }),
77 );
78 });
79
80// Both cases mutate the same zone-level DNSSEC singleton with opposite desired states; run them serially so they don't corrupt each other's captured baseline under the global concurrent test config.
81describe.sequential(
82 "Dnssec",
83 {
84 tags: [
85 "provider:cloudflare",
86 "provider:cloudflare:dns",
87 "provider:cloudflare:zone",
88 "live",
89 ],
90 },
91 () => {
92 test.provider(
93 "enables DNSSEC, captures the disabled baseline, destroy deactivates",
94 (stack) =>
95 Effect.gen(function* () {
96 const zoneId = yield* resolveZoneId;
97
98 yield* stack.destroy();
99 yield* normalizeDisabled(zoneId);
100
101 const dnssec = yield* stack.deploy(
102 Effect.gen(function* () {
103 return yield* Cloudflare.DNS.Dnssec("ZoneDnssec", { zoneId });
104 }),
105 );
106
107 expect(dnssec.zoneId).toEqual(zoneId);
108 // No DS record at the registrar, so the zone sits at `pending` —
109 // that's the active family as far as Cloudflare's side goes.
110 expect(family(dnssec.status)).toEqual("active");
111 // The pre-management state was captured for restore-on-destroy.
112 expect(dnssec.initialStatus).toEqual("disabled");
113 // The DS record string is what users paste at their registrar.
114 expect(dnssec.ds).toBeTypeOf("string");
115 expect(dnssec.ds!.length).toBeGreaterThan(0);
116
117 // Out-of-band verify via the SDK.
118 const live = yield* getDnssec(zoneId);
119 expect(family(live.status)).toEqual("active");
120
121 yield* stack.destroy();
122
123 // The zone was disabled before we managed it, so destroy deactivates.
124 // Deactivation is eventually consistent — typed bounded wait.
125 const restored = yield* getDnssec(zoneId).pipe(
126 Effect.repeat({
127 schedule: Schedule.spaced("2 seconds"),
128 until: (o) => family(o.status) === "disabled",
129 times: 15,
130 }),
131 );
132 expect(family(restored.status)).toEqual("disabled");
133
134 // Re-running destroy is idempotent (DNSSEC already off).
135 yield* stack.destroy();
136 const still = yield* getDnssec(zoneId);
137 expect(family(still.status)).toEqual("disabled");
138 }).pipe(logLevel),
139 { timeout: 300_000 },
140 );
141
142 test.provider(
143 "updates status in place — same zone singleton, no replacement",
144 (stack) =>
145 Effect.gen(function* () {
146 const zoneId = yield* resolveZoneId;
147
148 yield* stack.destroy();
149 yield* normalizeDisabled(zoneId);
150
151 const enabled = yield* stack.deploy(
152 Effect.gen(function* () {
153 return yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
154 zoneId,
155 status: "active",
156 });
157 }),
158 );
159 expect(family(enabled.status)).toEqual("active");
160 expect(enabled.initialStatus).toEqual("disabled");
161
162 // Flip the desired status in place — `zoneId` (the identity) is
163 // unchanged, so this patches the same singleton rather than
164 // replacing it.
165 const disabled = yield* stack.deploy(
166 Effect.gen(function* () {
167 return yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
168 zoneId,
169 status: "disabled",
170 });
171 }),
172 );
173 expect(disabled.zoneId).toEqual(zoneId);
174 expect(family(disabled.status)).toEqual("disabled");
175 // The captured baseline survives the in-place update.
176 expect(disabled.initialStatus).toEqual("disabled");
177
178 const live = yield* getDnssec(zoneId);
179 expect(family(live.status)).toEqual("disabled");
180
181 // Destroy is a no-op here (baseline disabled, currently disabled).
182 yield* stack.destroy();
183 const after = yield* getDnssec(zoneId);
184 expect(family(after.status)).toEqual("disabled");
185 }).pipe(logLevel),
186 { timeout: 300_000 },
187 );
188
189 // Canonical `list()` test (zone-scoped singleton): there is no account-wide
190 // API for per-zone DNSSEC, so `list()` enumerates every zone via
191 // `listAllZones` and reads each one's config, skipping disabled zones
192 // (which `read` treats as "not created"). Enable DNSSEC on the test zone
193 // so it appears in the result, assert it's present, then restore.
194 test.provider(
195 "list enumerates active DNSSEC across zones",
196 (stack) =>
197 Effect.gen(function* () {
198 const zoneId = yield* resolveZoneId;
199
200 yield* stack.destroy();
201 yield* normalizeDisabled(zoneId);
202
203 yield* stack.deploy(
204 Effect.gen(function* () {
205 return yield* Cloudflare.DNS.Dnssec("ZoneDnssec", { zoneId });
206 }),
207 );
208
209 const provider = yield* Provider.findProvider(Cloudflare.DNS.Dnssec);
210 const all = yield* provider.list();
211
212 expect(all.length).toBeGreaterThan(0);
213 expect(all.some((d) => d.zoneId === zoneId)).toBe(true);
214
215 yield* stack.destroy();
216 // Restore the disabled baseline (eventually consistent).
217 const restored = yield* getDnssec(zoneId).pipe(
218 Effect.repeat({
219 schedule: Schedule.spaced("2 seconds"),
220 until: (o) => family(o.status) === "disabled",
221 times: 15,
222 }),
223 );
224 expect(family(restored.status)).toEqual("disabled");
225 }).pipe(logLevel),
226 { timeout: 300_000 },
227 );
228 },
229);