1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as dnsFirewall from "@distilled.cloud/cloudflare/dns-firewall";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const logLevel = Effect.provideService(
14 MinimumLogLevel,
15 process.env.DEBUG ? "Debug" : "Info",
16);
17
18// DNS Firewall is a paid add-on (Enterprise / contract). On the standard
19// testing account `POST /accounts/{id}/dns_firewall` fails with Cloudflare
20// error code 10101 — "You do not have access to this feature." — surfaced
21// as the typed `DnsFirewallNotEntitled` error. The full lifecycle tests
22// below are gated behind an entitled account supplied via env.
23const entitled = !!process.env.CLOUDFLARE_TEST_DNS_FIREWALL;
24
25// The scoped API token the test harness mints propagates eventually-
26// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
27// declared in the distilled error union) on out-of-band verification calls.
28const getCluster = (accountId: string, dnsFirewallId: string) =>
29 dnsFirewall.getDnsFirewall({ accountId, dnsFirewallId }).pipe(
30 Effect.retry({
31 while: (e) => e._tag === "Forbidden",
32 schedule: Schedule.exponential("500 millis"),
33 times: 8,
34 }),
35 );
36
37// Poll until the cluster is gone — a missing cluster surfaces as the typed
38// `DnsFirewallNotFound` (Cloudflare error code 11001).
39const expectGone = (accountId: string, dnsFirewallId: string) =>
40 getCluster(accountId, dnsFirewallId).pipe(
41 Effect.flatMap(() => Effect.fail({ _tag: "ClusterNotDeleted" } as const)),
42 Effect.catchTag("DnsFirewallNotFound", () => Effect.void),
43 Effect.retry({
44 while: (e) => e._tag === "ClusterNotDeleted",
45 schedule: Schedule.exponential("500 millis"),
46 times: 10,
47 }),
48 );
49
50test.provider.skipIf(entitled)(
51 "surfaces the typed DnsFirewallNotEntitled error on unentitled accounts",
52 (stack) =>
53 Effect.gen(function* () {
54 const { accountId } = yield* yield* CloudflareEnvironment;
55
56 yield* stack.destroy();
57
58 // The testing account has no DNS Firewall entitlement — the distilled
59 // create call must fail with the typed entitlement tag (Cloudflare
60 // error code 10101). Reads on the same account succeed (and show no
61 // clusters), proving the gate is on creation, not on the API token.
62 const error = yield* dnsFirewall
63 .createDnsFirewall({
64 accountId,
65 name: "alchemy-dnsfw-entitlement-probe",
66 upstreamIps: ["192.0.2.1"],
67 })
68 .pipe(Effect.flip);
69 expect(error._tag).toEqual("DnsFirewallNotEntitled");
70
71 const list = yield* dnsFirewall.listDnsFirewalls({ accountId }).pipe(
72 Effect.retry({
73 while: (e) => e._tag === "Forbidden",
74 schedule: Schedule.exponential("500 millis"),
75 times: 8,
76 }),
77 );
78 expect(list.result).toEqual([]);
79
80 yield* stack.destroy();
81 }).pipe(logLevel),
82 { tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"] },
83);
84
85test.provider.skipIf(!entitled)(
86 "create, verify out-of-band, destroy, and wait until gone",
87 (stack) =>
88 Effect.gen(function* () {
89 const { accountId } = yield* yield* CloudflareEnvironment;
90
91 yield* stack.destroy();
92
93 const cluster = yield* stack.deploy(
94 Cloudflare.DNS.Firewall("TestCluster", {
95 name: "alchemy-dnsfw-lifecycle",
96 upstreamIps: ["192.0.2.1"],
97 }),
98 );
99
100 expect(cluster.dnsFirewallId).toBeTruthy();
101 expect(cluster.accountId).toEqual(accountId);
102 expect(cluster.name).toEqual("alchemy-dnsfw-lifecycle");
103 expect(cluster.upstreamIps).toEqual(["192.0.2.1"]);
104 // The key output — the assigned anycast IPs NS glue points at.
105 expect(cluster.dnsFirewallIps.length).toBeGreaterThan(0);
106 // Documented defaults.
107 expect(cluster.deprecateAnyRequests).toEqual(false);
108 expect(cluster.ecsFallback).toEqual(false);
109 expect(cluster.retries).toEqual(2);
110
111 const live = yield* getCluster(accountId, cluster.dnsFirewallId);
112 expect(live.name).toEqual("alchemy-dnsfw-lifecycle");
113 expect([...live.upstreamIps]).toEqual(["192.0.2.1"]);
114
115 yield* stack.destroy();
116
117 yield* expectGone(accountId, cluster.dnsFirewallId);
118 }).pipe(logLevel),
119 {
120 tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"],
121 timeout: 120_000,
122 },
123);
124
125test.provider.skipIf(!entitled)(
126 "update mutable settings in place, replace on rename",
127 (stack) =>
128 Effect.gen(function* () {
129 const { accountId } = yield* yield* CloudflareEnvironment;
130
131 yield* stack.destroy();
132
133 const initial = yield* stack.deploy(
134 Cloudflare.DNS.Firewall("UpdateCluster", {
135 name: "alchemy-dnsfw-update",
136 upstreamIps: ["192.0.2.1"],
137 }),
138 );
139
140 // In-place update: settings + an extra upstream IP, same id.
141 const updated = yield* stack.deploy(
142 Cloudflare.DNS.Firewall("UpdateCluster", {
143 name: "alchemy-dnsfw-update",
144 upstreamIps: ["192.0.2.1", "192.0.2.2"],
145 ratelimit: 600,
146 minimumCacheTtl: 120,
147 attackMitigation: { enabled: true },
148 }),
149 );
150 expect(updated.dnsFirewallId).toEqual(initial.dnsFirewallId);
151 expect([...updated.upstreamIps].sort()).toEqual([
152 "192.0.2.1",
153 "192.0.2.2",
154 ]);
155 expect(updated.ratelimit).toEqual(600);
156 expect(updated.minimumCacheTtl).toEqual(120);
157 expect(updated.attackMitigation.enabled).toEqual(true);
158
159 const live = yield* getCluster(accountId, updated.dnsFirewallId);
160 expect(live.ratelimit).toEqual(600);
161 expect(live.minimumCacheTtl).toEqual(120);
162
163 // No-op redeploy keeps the same cluster.
164 const noop = yield* stack.deploy(
165 Cloudflare.DNS.Firewall("UpdateCluster", {
166 name: "alchemy-dnsfw-update",
167 upstreamIps: ["192.0.2.1", "192.0.2.2"],
168 ratelimit: 600,
169 minimumCacheTtl: 120,
170 attackMitigation: { enabled: true },
171 }),
172 );
173 expect(noop.dnsFirewallId).toEqual(initial.dnsFirewallId);
174
175 // Rename — the name is the cold-state recovery identity, so a new
176 // cluster replaces the old one.
177 const renamed = yield* stack.deploy(
178 Cloudflare.DNS.Firewall("UpdateCluster", {
179 name: "alchemy-dnsfw-update-v2",
180 upstreamIps: ["192.0.2.1", "192.0.2.2"],
181 ratelimit: 600,
182 minimumCacheTtl: 120,
183 attackMitigation: { enabled: true },
184 }),
185 );
186 expect(renamed.dnsFirewallId).not.toEqual(initial.dnsFirewallId);
187 expect(renamed.name).toEqual("alchemy-dnsfw-update-v2");
188
189 yield* expectGone(accountId, initial.dnsFirewallId);
190
191 yield* stack.destroy();
192
193 yield* expectGone(accountId, renamed.dnsFirewallId);
194 }).pipe(logLevel),
195 {
196 tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"],
197 timeout: 120_000,
198 },
199);
200
201// Canonical `list()` test (account collection): the enumeration endpoint
202// (`GET /accounts/{id}/dns_firewall`) is NOT entitlement-gated — only
203// cluster *creation* is — so `list()` runs on the unentitled testing
204// account and returns the (empty) cluster Attributes array. This read-only
205// assertion proves `list()` exhaustively paginates and produces the exact
206// `read` Attributes shape.
207test.provider(
208 "list returns the DNS firewall cluster Attributes array",
209 (stack) =>
210 Effect.gen(function* () {
211 yield* stack.destroy();
212
213 const provider = yield* Provider.findProvider(Cloudflare.DNS.Firewall);
214 const all = yield* provider.list();
215 expect(Array.isArray(all)).toBe(true);
216
217 yield* stack.destroy();
218 }).pipe(logLevel),
219 { tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"] },
220);
221
222// Entitled accounts: deploy a real cluster and assert it appears in the
223// exhaustively-paginated `list()` result with the exact `read` Attributes.
224test.provider.skipIf(!entitled)(
225 "list enumerates the deployed DNS firewall cluster",
226 (stack) =>
227 Effect.gen(function* () {
228 yield* stack.destroy();
229
230 const deployed = yield* stack.deploy(
231 Cloudflare.DNS.Firewall("ListCluster", {
232 name: "alchemy-dnsfw-list",
233 upstreamIps: ["192.0.2.1"],
234 }),
235 );
236
237 const provider = yield* Provider.findProvider(Cloudflare.DNS.Firewall);
238 const all = yield* provider.list();
239
240 const found = all.find((c) => c.dnsFirewallId === deployed.dnsFirewallId);
241 expect(found).toBeTruthy();
242 expect(found?.name).toEqual("alchemy-dnsfw-list");
243 expect(found?.accountId).toEqual(deployed.accountId);
244
245 yield* stack.destroy();
246 }).pipe(logLevel),
247 {
248 tags: ["provider:cloudflare", "provider:cloudflare:dns", "live"],
249 timeout: 120_000,
250 },
251);