1import * as AdoptPolicy 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 RemovalPolicy from "@/RemovalPolicy";
7import { isResourceState, State, type ResourceState } from "@/State";
8import * as Test from "@/Test/Alchemy";
9import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
10import { describe, expect } from "alchemy-test";
11import * as Cause from "effect/Cause";
12import * as Effect from "effect/Effect";
13import * as Predicate from "effect/Predicate";
14import { MinimumLogLevel } from "effect/References";
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17// Cloudflare intermittently blocks *all* zone creation on an account with
18// error 1052 ("An error(zone setup) has occurred and it has been logged");
19// the block is account-level and can persist for hours. The unresolved-zone
20// test below provisions a fresh zone, so soft-skip it while the account is in
21// that state; every other failure propagates unchanged.
22const softSkipWhenZoneCreationBlocked = <A, E, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | undefined, E, R> =>
25 effect.pipe(
26 Effect.catchCause((cause) => {
27 // Match by tag rather than `instanceof zones.ZoneCreationBlocked`: the
28 // error is constructed inside the provider's copy of the distilled
29 // module, which vitest resolves as a separate module instance from this
30 // test's import, so `instanceof` across that boundary is always false.
31 const blocked = cause.reasons
32 .map((reason) =>
33 Cause.isFailReason(reason)
34 ? reason.error
35 : Cause.isDieReason(reason)
36 ? reason.defect
37 : undefined,
38 )
39 .some(
40 (value) =>
41 Predicate.hasProperty(value, "_tag") &&
42 value._tag === "ZoneCreationBlocked",
43 );
44 return blocked
45 ? Effect.logWarning(
46 "Cloudflare has zone creation blocked on this account (error 1052) — skipping zone-provisioning ruleset assertions",
47 ).pipe(Effect.as(undefined))
48 : Effect.failCause(cause);
49 }),
50 );
51
52const logLevel = Effect.provideService(
53 MinimumLogLevel,
54 process.env.DEBUG ? "Debug" : "Info",
55);
56
57const zoneName =
58 process.env.CLOUDFLARE_TEST_RULESET_ZONE_NAME ?? "alchemy-test-2.us";
59// The unresolved-zone test owns a phase entrypoint on a *separate* zone so it
60// never clobbers the CRUD test's rules.
61const unresolvedZoneName =
62 process.env.CLOUDFLARE_TEST_RULESET_ZONE_NAME_2 ??
63 "alchemy-test-unresolved.us";
64const phase = "http_request_firewall_custom";
65type TestRulesetPhase = typeof phase;
66
67// The CRUD test and the `list` test both manage the same zone+phase entrypoint
68// on `alchemy-test-2.us`; run them serially so they can't clobber each other
69// under the global concurrent test config.
70describe.sequential(
71 "Ruleset",
72 {
73 tags: [
74 "provider:cloudflare",
75 "provider:cloudflare:ruleset",
76 "provider:cloudflare:zone",
77 "live",
78 ],
79 },
80 () => {
81 test.provider(
82 "creates, updates, and deletes a zone phase entrypoint ruleset",
83 (stack) =>
84 Effect.gen(function* () {
85 yield* stack.destroy();
86
87 const initial = yield* stack.deploy(
88 Effect.gen(function* () {
89 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
90 name: zoneName,
91 }).pipe(AdoptPolicy.adopt(true));
92 return yield* Cloudflare.Ruleset.Ruleset("TestRuleset", {
93 zone,
94 phase,
95 rules: [
96 {
97 description: "Alchemy test rule",
98 expression:
99 'http.request.uri.path eq "/__alchemy_ruleset_test__"',
100 action: "block",
101 },
102 ],
103 });
104 }),
105 );
106
107 expect(initial.phase).toEqual(phase);
108 expect(initial.rules).toHaveLength(1);
109
110 // Verify the rule actually exists in Cloudflare, not just in the
111 // stack output.
112 const createdRules = yield* getPhaseRules(initial.zoneId, phase);
113 expect(createdRules).toHaveLength(1);
114 expect(createdRules[0]).toMatchObject({
115 description: "Alchemy test rule",
116 action: "block",
117 expression: 'http.request.uri.path eq "/__alchemy_ruleset_test__"',
118 });
119
120 const updated = yield* stack.deploy(
121 Effect.gen(function* () {
122 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
123 name: zoneName,
124 }).pipe(AdoptPolicy.adopt(true));
125 return yield* Cloudflare.Ruleset.Ruleset("TestRuleset", {
126 zone,
127 phase,
128 rules: [
129 {
130 description: "Updated Alchemy test rule",
131 expression:
132 'http.request.uri.path eq "/__alchemy_ruleset_test__"',
133 action: "managed_challenge",
134 },
135 ],
136 });
137 }),
138 );
139
140 expect(updated.zoneId).toEqual(initial.zoneId);
141 expect(updated.rules[0]?.description).toEqual(
142 "Updated Alchemy test rule",
143 );
144 expect(updated.rules[0]?.action).toEqual("managed_challenge");
145
146 // Verify the update landed in Cloudflare itself.
147 const updatedRules = yield* getPhaseRules(initial.zoneId, phase);
148 expect(updatedRules).toHaveLength(1);
149 expect(updatedRules[0]).toMatchObject({
150 description: "Updated Alchemy test rule",
151 action: "managed_challenge",
152 });
153
154 yield* stack.destroy();
155
156 // Confirm the phase entrypoint was emptied in Cloudflare on destroy.
157 const actualRules = yield* getPhaseRules(initial.zoneId, phase);
158 expect(actualRules).toEqual([]);
159 }).pipe(logLevel),
160 );
161
162 test.provider(
163 "creates and tears down a ruleset whose zone is provisioned in the same deploy",
164 (stack) =>
165 Effect.gen(function* () {
166 // Start from a clean slate so a leftover zone/ruleset from an
167 // interrupted run can't mask a regression.
168 yield* stack.destroy();
169
170 // The zone is created in the same deploy as the ruleset, so its `zoneId`
171 // is an unresolved Output while the ruleset is planned. `adopt(true)`
172 // takes over a leftover zone from a prior run; `destroy()` overrides the
173 // zone's default `retain` so it is actually deleted on teardown.
174 const initial = yield* stack.deploy(
175 Effect.gen(function* () {
176 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
177 name: unresolvedZoneName,
178 }).pipe(AdoptPolicy.adopt(true), RemovalPolicy.destroy());
179 return yield* Cloudflare.Ruleset.Ruleset("TestRuleset", {
180 zone,
181 phase,
182 rules: [
183 {
184 description: "Unresolved zone rule v1",
185 expression:
186 'http.request.uri.path eq "/__alchemy_ruleset_unresolved__"',
187 action: "block",
188 },
189 ],
190 });
191 }),
192 );
193
194 const createdRules = yield* getPhaseRules(initial.zoneId, phase);
195 expect(createdRules).toHaveLength(1);
196 expect(createdRules[0]).toMatchObject({
197 description: "Unresolved zone rule v1",
198 action: "block",
199 });
200
201 // Re-deploy with changed rules; the ruleset must update in place.
202 const updated = yield* stack.deploy(
203 Effect.gen(function* () {
204 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
205 name: unresolvedZoneName,
206 }).pipe(AdoptPolicy.adopt(true), RemovalPolicy.destroy());
207 return yield* Cloudflare.Ruleset.Ruleset("TestRuleset", {
208 zone,
209 phase,
210 rules: [
211 {
212 description: "Unresolved zone rule v2",
213 expression:
214 'http.request.uri.path eq "/__alchemy_ruleset_unresolved__"',
215 action: "managed_challenge",
216 },
217 ],
218 });
219 }),
220 );
221
222 expect(updated.zoneId).toEqual(initial.zoneId);
223 const updatedRules = yield* getPhaseRules(updated.zoneId, phase);
224 expect(updatedRules).toHaveLength(1);
225 expect(updatedRules[0]).toMatchObject({
226 description: "Unresolved zone rule v2",
227 action: "managed_challenge",
228 });
229
230 yield* stack.destroy();
231
232 // The zone itself is deleted (destroy, not retain) — assert it's gone in
233 // Cloudflare. (Querying the phase entrypoint here is meaningless: the
234 // zone no longer exists, so Cloudflare answers Unauthorized, not 404.)
235 const { accountId } = yield* yield* CloudflareEnvironment;
236 const zoneAfter = yield* findZoneByName({
237 accountId,
238 name: unresolvedZoneName,
239 });
240 expect(zoneAfter).toBeUndefined();
241 }).pipe(softSkipWhenZoneCreationBlocked, logLevel),
242 );
243
244 // A `creating` state row persisted before upstream Outputs resolve cannot
245 // round-trip Output-valued props — the `zone` prop deserializes as
246 // `undefined`. The engine's creating-with-no-attr recovery path then calls
247 // `read` with those junk props as `olds` and `output: undefined`. Before the
248 // #770 guard, `zoneIdOf(olds.zone)` dereferenced `.zoneId` on `undefined`
249 // and crashed the deploy; after it, `read` returns "not found" and
250 // `reconcile` re-converges the phase entrypoint from the resolved news.
251 test.provider(
252 "recovers a half-created ruleset whose creating-state lost the Output-valued zone (#736)",
253 (stack) =>
254 Effect.gen(function* () {
255 yield* stack.destroy();
256
257 const deployRuleset = () =>
258 stack.deploy(
259 Effect.gen(function* () {
260 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
261 name: zoneName,
262 }).pipe(AdoptPolicy.adopt(true));
263 return yield* Cloudflare.Ruleset.Ruleset("WedgedRuleset", {
264 zone,
265 phase,
266 rules: [
267 {
268 description: "Alchemy wedged recovery rule",
269 expression:
270 'http.request.uri.path eq "/__alchemy_ruleset_wedged__"',
271 action: "block",
272 },
273 ],
274 });
275 }),
276 );
277
278 const created = yield* deployRuleset();
279
280 // Rewrite the ruleset's persisted row into the wedged shape an
281 // interrupted deploy leaves behind: `creating`, no attributes, and the
282 // Output-valued `zone` prop lost in the round-trip.
283 const state = yield* yield* State;
284 const stage = stack.stage;
285 const fqns = yield* state.list({ stack: stack.name, stage });
286 const rows = yield* Effect.forEach(fqns, (fqn) =>
287 state
288 .get({ stack: stack.name, stage, fqn })
289 .pipe(Effect.map((row) => ({ fqn, row }))),
290 );
291 const wedged = rows.find(
292 (r): r is { fqn: string; row: ResourceState } =>
293 isResourceState(r.row) &&
294 r.row.resourceType === "Cloudflare.Ruleset.Ruleset",
295 );
296 if (!wedged) {
297 return yield* Effect.die(
298 new Error(
299 "no Cloudflare.Ruleset.Ruleset state row found after deploy",
300 ),
301 );
302 }
303 yield* state.set({
304 stack: stack.name,
305 stage,
306 fqn: wedged.fqn,
307 value: {
308 ...wedged.row,
309 status: "creating",
310 attr: undefined,
311 props: { ...wedged.row.props, zone: undefined },
312 },
313 });
314
315 // Before the fix this crashed in `read` with
316 // `TypeError: undefined is not an object (evaluating 'zone.zoneId')`.
317 const recovered = yield* deployRuleset();
318 expect(recovered.rulesetId).toEqual(created.rulesetId);
319 expect(recovered.zoneId).toEqual(created.zoneId);
320
321 // The recovered entrypoint converged to the desired rules.
322 const recoveredRules = yield* getPhaseRules(recovered.zoneId, phase);
323 expect(recoveredRules).toHaveLength(1);
324 expect(recoveredRules[0]).toMatchObject({
325 description: "Alchemy wedged recovery rule",
326 action: "block",
327 });
328
329 yield* stack.destroy();
330 }).pipe(logLevel),
331 { timeout: 240_000 },
332 );
333
334 // Canonical `list()` test (zone-scoped collection): enumerate every zone via
335 // `listAllZones`, list each zone's rulesets, keep the phase entrypoints, and
336 // hydrate them. Deploy a ruleset on the standing test zone, then assert it is
337 // present in the exhaustively-paginated result.
338 //
339 // GATED: `list()` hydrates *every* phase entrypoint across *every* zone via
340 // `getPhasForZone`. The standing test zone (alchemy-test-2.us) carries a
341 // pre-existing `http_request_dynamic_redirect` entrypoint whose redirect rule
342 // returns `action_parameters.from_value.status_code` as a NUMBER (301), but
343 // distilled's `GetPhasResponse` schema types `statusCode` as string-only
344 // (`Schema.Literals(["301",...]) | String`). Decode fails with the exact
345 // error:
346 // CloudflareHttpError { status: 200, statusText: "Schema decode failed" }
347 // GET /zones/{zone_id}/rulesets/phases/{rulesetPhase}/entrypoint
348 // NEEDED DISTILLED PATCH (rulesets/getPhas.json, response): the redirect
349 // variant's `actionParameters.fromValue.statusCode` (and the matching
350 // `getRuleset` / list-version response schemas) must also accept `Number`.
351 // Gated on CLOUDFLARE_TEST_RULESET_LIST until that patch lands.
352 test.provider.skipIf(!process.env.CLOUDFLARE_TEST_RULESET_LIST)(
353 "list enumerates the deployed zone phase entrypoint",
354 (stack) =>
355 Effect.gen(function* () {
356 yield* stack.destroy();
357
358 const deployed = yield* stack.deploy(
359 Effect.gen(function* () {
360 const zone = yield* Cloudflare.Zone.Zone("TestZone", {
361 name: zoneName,
362 }).pipe(AdoptPolicy.adopt(true));
363 return yield* Cloudflare.Ruleset.Ruleset("TestRuleset", {
364 zone,
365 phase,
366 rules: [
367 {
368 description: "Alchemy list test rule",
369 expression:
370 'http.request.uri.path eq "/__alchemy_ruleset_list_test__"',
371 action: "block",
372 },
373 ],
374 });
375 }),
376 );
377
378 const provider = yield* Provider.findProvider(
379 Cloudflare.Ruleset.Ruleset,
380 );
381 const all = yield* provider.list();
382
383 expect(all.length).toBeGreaterThan(0);
384 expect(
385 all.some(
386 (r) =>
387 r.rulesetId === deployed.rulesetId &&
388 r.zoneId === deployed.zoneId &&
389 r.phase === phase,
390 ),
391 ).toBe(true);
392
393 yield* stack.destroy();
394 }).pipe(logLevel),
395 );
396 },
397);
398
399const getPhaseRules = Effect.fn(function* (
400 zoneId: string,
401 phase: TestRulesetPhase,
402) {
403 return yield* rulesets
404 .getPhasForZone({
405 zoneId,
406 rulesetPhase: phase,
407 })
408 .pipe(
409 Effect.map((ruleset) => ruleset.rules ?? []),
410 Effect.catchTag("RulesetNotFound", () => Effect.succeed([])),
411 );
412});