1import { adopt } from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as firewall from "@distilled.cloud/cloudflare/firewall";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
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 =
22 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
23
24// Deterministic per-test URL patterns. A lockdown rule's URL set is its
25// identity within a zone (duplicates are rejected), so each test owns
26// disjoint paths and the same values are reused on every run (never
27// Date.now()/random). Zone Lockdown quota on Pro is 3 rules — each test
28// holds at most one rule at a time.
29const URL_LIFECYCLE_V1 = `${zoneName}/alchemy-lockdown-lifecycle*`;
30const URL_LIFECYCLE_V2 = `${zoneName}/alchemy-lockdown-lifecycle-v2*`;
31const IP_V1 = "198.51.100.111";
32const RANGE_V2 = "203.0.113.0/24";
33
34const URL_LIST = `${zoneName}/alchemy-lockdown-list*`;
35const IP_LIST = "198.51.100.222";
36
37const resolveZoneId = Effect.gen(function* () {
38 const { accountId } = yield* yield* CloudflareEnvironment;
39 const zone = yield* findZoneByName({ accountId, name: zoneName });
40 if (!zone) {
41 return yield* Effect.die(
42 new Error(`zone "${zoneName}" not found in account`),
43 );
44 }
45 return zone.id;
46});
47
48// The scoped API token the test harness mints propagates eventually-
49// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
50// declared in the distilled error union via patches) on the test's own
51// out-of-band verification calls.
52const forbiddenRetry = {
53 while: (e: { _tag: string }) => e._tag === "Forbidden",
54 schedule: Schedule.exponential("500 millis"),
55 times: 8,
56} as const;
57
58const getLockdown = (zoneId: string, lockdownId: string) =>
59 firewall
60 .getLockdown({ zoneId, lockDownsId: lockdownId })
61 .pipe(Effect.retry(forbiddenRetry));
62
63// List every lockdown whose URL set intersects the given urls — used both
64// for assertions and to purge leftovers from interrupted runs so each test
65// starts from a clean slate (a leaked rule would surface as
66// Unowned/duplicate because the URL set is identity).
67const listByUrls = (zoneId: string, urls: string[]) =>
68 firewall.listLockdowns.items({ zoneId }).pipe(
69 Stream.filter((r) => r.urls.some((u) => urls.includes(u))),
70 Stream.runCollect,
71 Effect.map((chunk) => Array.from(chunk)),
72 Effect.retry(forbiddenRetry),
73 );
74
75const purgeLockdowns = (zoneId: string, urls: string[]) =>
76 listByUrls(zoneId, urls).pipe(
77 Effect.flatMap(
78 Effect.forEach((r) =>
79 firewall.deleteLockdown({ zoneId, lockDownsId: r.id }).pipe(
80 Effect.retry(forbiddenRetry),
81 Effect.catchTag("LockdownNotFound", () => Effect.void),
82 ),
83 ),
84 ),
85 );
86
87// Poll until the rule is gone — a missing rule surfaces as the typed
88// `LockdownNotFound` (Cloudflare code 10001, zonelockdown.api.not_found).
89const expectLockdownGone = (zoneId: string, lockdownId: string) =>
90 getLockdown(zoneId, lockdownId).pipe(
91 Effect.flatMap(() => Effect.fail({ _tag: "LockdownNotDeleted" } as const)),
92 Effect.catchTag("LockdownNotFound", () => Effect.void),
93 Effect.retry({
94 while: (e) => e._tag === "LockdownNotDeleted",
95 schedule: Schedule.max([
96 Schedule.exponential("500 millis"),
97 Schedule.recurs(10),
98 ]),
99 }),
100 );
101
102test.provider(
103 "create, update urls/configurations/description/paused in place, destroy",
104 (stack) =>
105 Effect.gen(function* () {
106 const zoneId = yield* resolveZoneId;
107
108 yield* stack.destroy();
109 yield* purgeLockdowns(zoneId, [URL_LIFECYCLE_V1, URL_LIFECYCLE_V2]);
110
111 const initial = yield* stack.deploy(
112 Effect.gen(function* () {
113 return yield* Cloudflare.Firewall.Lockdown("LifecycleLockdown", {
114 zoneId,
115 urls: [URL_LIFECYCLE_V1],
116 configurations: [{ target: "ip", value: IP_V1 }],
117 description: "alchemy lockdown test (v1)",
118 }).pipe(adopt(true));
119 }),
120 );
121
122 expect(initial.lockdownId).toBeDefined();
123 expect(initial.zoneId).toEqual(zoneId);
124 expect(initial.urls).toEqual([URL_LIFECYCLE_V1]);
125 expect(initial.configurations).toEqual([{ target: "ip", value: IP_V1 }]);
126 expect(initial.description).toEqual("alchemy lockdown test (v1)");
127 expect(initial.paused).toEqual(false);
128
129 const live = yield* getLockdown(zoneId, initial.lockdownId);
130 expect(live.id).toEqual(initial.lockdownId);
131 expect(live.urls).toEqual([URL_LIFECYCLE_V1]);
132 expect(live.configurations).toEqual([{ target: "ip", value: IP_V1 }]);
133
134 // Update every mutable aspect in one PUT: add a second url, add an
135 // ip_range allow entry, change the description, and pause the rule.
136 const updated = yield* stack.deploy(
137 Effect.gen(function* () {
138 return yield* Cloudflare.Firewall.Lockdown("LifecycleLockdown", {
139 zoneId,
140 urls: [URL_LIFECYCLE_V1, URL_LIFECYCLE_V2],
141 configurations: [
142 { target: "ip", value: IP_V1 },
143 { target: "ip_range", value: RANGE_V2 },
144 ],
145 description: "alchemy lockdown test (v2)",
146 paused: true,
147 }).pipe(adopt(true));
148 }),
149 );
150
151 // Same rule updated in place — not a replacement.
152 expect(updated.lockdownId).toEqual(initial.lockdownId);
153 expect([...updated.urls].sort()).toEqual(
154 [URL_LIFECYCLE_V1, URL_LIFECYCLE_V2].sort(),
155 );
156 expect(updated.configurations).toHaveLength(2);
157 expect(updated.description).toEqual("alchemy lockdown test (v2)");
158 expect(updated.paused).toEqual(true);
159
160 const liveUpdated = yield* getLockdown(zoneId, updated.lockdownId);
161 expect([...liveUpdated.urls].sort()).toEqual(
162 [URL_LIFECYCLE_V1, URL_LIFECYCLE_V2].sort(),
163 );
164 expect(liveUpdated.configurations).toHaveLength(2);
165 expect(liveUpdated.description).toEqual("alchemy lockdown test (v2)");
166 expect(liveUpdated.paused).toEqual(true);
167
168 // Redeploying identical props is a no-op (still the same rule).
169 const noop = yield* stack.deploy(
170 Effect.gen(function* () {
171 return yield* Cloudflare.Firewall.Lockdown("LifecycleLockdown", {
172 zoneId,
173 urls: [URL_LIFECYCLE_V1, URL_LIFECYCLE_V2],
174 configurations: [
175 { target: "ip", value: IP_V1 },
176 { target: "ip_range", value: RANGE_V2 },
177 ],
178 description: "alchemy lockdown test (v2)",
179 paused: true,
180 }).pipe(adopt(true));
181 }),
182 );
183 expect(noop.lockdownId).toEqual(initial.lockdownId);
184
185 yield* stack.destroy();
186
187 yield* expectLockdownGone(zoneId, initial.lockdownId);
188 }).pipe(logLevel),
189 {
190 tags: [
191 "provider:cloudflare",
192 "provider:cloudflare:firewall",
193 "provider:cloudflare:zone",
194 "live",
195 ],
196 },
197);
198
199// Canonical `list()` test (zone-scoped collection): `list()` fans out over
200// every zone via `listAllZones`, exhaustively paginates each zone's lockdown
201// rules, and hydrates them into the `read` Attributes shape. Deploy a rule and
202// assert it appears in the enumerated result.
203test.provider(
204 "list enumerates the deployed lockdown rule",
205 (stack) =>
206 Effect.gen(function* () {
207 const zoneId = yield* resolveZoneId;
208
209 yield* stack.destroy();
210 yield* purgeLockdowns(zoneId, [URL_LIST]);
211
212 const deployed = yield* stack.deploy(
213 Effect.gen(function* () {
214 return yield* Cloudflare.Firewall.Lockdown("ListLockdown", {
215 zoneId,
216 urls: [URL_LIST],
217 configurations: [{ target: "ip", value: IP_LIST }],
218 description: "alchemy lockdown list test",
219 }).pipe(adopt(true));
220 }),
221 );
222
223 const provider = yield* Provider.findProvider(
224 Cloudflare.Firewall.Lockdown,
225 );
226 const all = yield* provider.list();
227
228 expect(all.some((r) => r.lockdownId === deployed.lockdownId)).toBe(true);
229 const found = all.find((r) => r.lockdownId === deployed.lockdownId);
230 expect(found?.zoneId).toEqual(zoneId);
231 expect(found?.urls).toEqual([URL_LIST]);
232
233 yield* stack.destroy();
234
235 yield* expectLockdownGone(zoneId, deployed.lockdownId);
236 }).pipe(logLevel),
237 {
238 tags: [
239 "provider:cloudflare",
240 "provider:cloudflare:firewall",
241 "provider:cloudflare:zone",
242 "live",
243 ],
244 },
245);