1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Result from "effect/Result";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// A freshly minted scoped token propagates eventually-consistently across
20// Cloudflare's edge — retry the typed `Forbidden` blips on out-of-band calls.
21const forbiddenRetrySchedule = Schedule.exponential("500 millis");
22
23const phase = "http_request_firewall_custom";
24
25const resolveAccountId = Effect.gen(function* () {
26 const { accountId } = yield* yield* CloudflareEnvironment;
27 return accountId;
28});
29
30const getRuleset = (accountId: string, rulesetId: string) =>
31 rulesets.getRulesetForAccount({ accountId, rulesetId }).pipe(
32 Effect.retry({
33 while: (e) => e._tag === "Forbidden",
34 schedule: forbiddenRetrySchedule,
35 times: 8,
36 }),
37 Effect.catchTag("RulesetNotFound", () => Effect.succeed(undefined)),
38 );
39
40// Account-level custom rulesets require an Enterprise plan. On the standard
41// testing account every create fails with the typed `PhaseNotEntitled` error
42// (code 50002: "not entitled to use the phase http_request_firewall_custom").
43// The test probes once: unentitled accounts assert the typed tag; entitled
44// accounts run the full lifecycle.
45test.provider(
46 "custom ruleset lifecycle (typed PhaseNotEntitled on unentitled accounts)",
47 (stack) =>
48 Effect.gen(function* () {
49 const accountId = yield* resolveAccountId;
50
51 yield* stack.destroy();
52
53 const probe = yield* rulesets
54 .createRulesetForAccount({
55 accountId,
56 kind: "custom",
57 name: "alchemy-customruleset-probe",
58 phase,
59 rules: [],
60 })
61 .pipe(
62 Effect.retry({
63 while: (e) => e._tag === "Forbidden",
64 schedule: forbiddenRetrySchedule,
65 times: 8,
66 }),
67 Effect.result,
68 );
69
70 if (Result.isFailure(probe)) {
71 // Unentitled — the distilled call must fail with the typed
72 // entitlement tag, never an untyped catch-all.
73 expect(probe.failure._tag).toEqual("PhaseNotEntitled");
74 yield* stack.destroy();
75 return;
76 }
77
78 // Entitled — clean up the probe ruleset and run the full lifecycle.
79 yield* rulesets
80 .deleteRulesetForAccount({ accountId, rulesetId: probe.success.id })
81 .pipe(Effect.catchTag("RulesetNotFound", () => Effect.void));
82
83 const created = yield* stack.deploy(
84 Effect.gen(function* () {
85 return yield* Cloudflare.Ruleset.CustomRuleset("WafRules", {
86 phase,
87 description: "alchemy custom ruleset v1",
88 rules: [
89 {
90 description: "Block exploit probes",
91 expression: `lower(http.request.uri.path) contains "/.env"`,
92 action: "block",
93 },
94 ],
95 });
96 }),
97 );
98 expect(created.accountId).toEqual(accountId);
99 expect(created.kind).toEqual("custom");
100 expect(created.phase).toEqual(phase);
101 expect(created.rules).toHaveLength(1);
102
103 // Out-of-band verification via the distilled API.
104 const live = yield* getRuleset(accountId, created.rulesetId);
105 expect(live?.description).toEqual("alchemy custom ruleset v1");
106 expect(live?.rules?.[0]).toMatchObject({
107 action: "block",
108 expression: `lower(http.request.uri.path) contains "/.env"`,
109 });
110
111 // Update rules + description in place — same physical ruleset.
112 const updated = yield* stack.deploy(
113 Effect.gen(function* () {
114 return yield* Cloudflare.Ruleset.CustomRuleset("WafRules", {
115 phase,
116 description: "alchemy custom ruleset v2",
117 rules: [
118 {
119 description: "Challenge exploit probes",
120 expression: `lower(http.request.uri.path) contains "/.env"`,
121 action: "managed_challenge",
122 },
123 ],
124 });
125 }),
126 );
127 expect(updated.rulesetId).toEqual(created.rulesetId);
128 expect(updated.description).toEqual("alchemy custom ruleset v2");
129 expect(updated.rules[0]?.action).toEqual("managed_challenge");
130
131 const patched = yield* getRuleset(accountId, created.rulesetId);
132 expect(patched?.rules?.[0]?.action).toEqual("managed_challenge");
133
134 // Destroy and verify the ruleset is gone (typed not-found read).
135 yield* stack.destroy();
136 const gone = yield* getRuleset(accountId, created.rulesetId);
137 expect(gone).toBeUndefined();
138 }).pipe(logLevel),
139 {
140 tags: ["provider:cloudflare", "provider:cloudflare:ruleset", "live"],
141 timeout: 120_000,
142 },
143);
144
145// Canonical `list()` test (account-scoped collection). `list()` enumerates the
146// account's rulesets, filters to `kind: custom`, and hydrates each into the
147// full `read` Attributes shape. Account-level custom WAF phases require an
148// Enterprise plan: unentitled accounts (the standard testing account) fail the
149// create with the typed `PhaseNotEntitled` error, so we still verify `list()`
150// returns an array without throwing; entitled accounts additionally deploy a
151// ruleset and assert it appears in the exhaustively-paginated result.
152test.provider(
153 "list enumerates the deployed custom ruleset",
154 (stack) =>
155 Effect.gen(function* () {
156 const accountId = yield* resolveAccountId;
157
158 yield* stack.destroy();
159
160 const probe = yield* rulesets
161 .createRulesetForAccount({
162 accountId,
163 kind: "custom",
164 name: "alchemy-customruleset-list-probe",
165 phase,
166 rules: [],
167 })
168 .pipe(
169 Effect.retry({
170 while: (e) => e._tag === "Forbidden",
171 schedule: forbiddenRetrySchedule,
172 times: 8,
173 }),
174 Effect.result,
175 );
176
177 if (Result.isFailure(probe)) {
178 // Unentitled — assert the typed entitlement tag, then verify the
179 // provider's `list()` still enumerates without error (returns []).
180 expect(probe.failure._tag).toEqual("PhaseNotEntitled");
181 const provider = yield* Provider.findProvider(
182 Cloudflare.Ruleset.CustomRuleset,
183 );
184 const all = yield* provider.list();
185 expect(Array.isArray(all)).toBe(true);
186 yield* stack.destroy();
187 return;
188 }
189
190 // Entitled — clean up the probe and run the full list assertion.
191 yield* rulesets
192 .deleteRulesetForAccount({ accountId, rulesetId: probe.success.id })
193 .pipe(Effect.catchTag("RulesetNotFound", () => Effect.void));
194
195 const deployed = yield* stack.deploy(
196 Effect.gen(function* () {
197 return yield* Cloudflare.Ruleset.CustomRuleset("ListWafRules", {
198 phase,
199 description: "alchemy custom ruleset list",
200 rules: [
201 {
202 description: "Block exploit probes",
203 expression: `lower(http.request.uri.path) contains "/.env"`,
204 action: "block",
205 },
206 ],
207 });
208 }),
209 );
210
211 const provider = yield* Provider.findProvider(
212 Cloudflare.Ruleset.CustomRuleset,
213 );
214 const all = yield* provider.list();
215
216 expect(all.some((r) => r.rulesetId === deployed.rulesetId)).toBe(true);
217 const found = all.find((r) => r.rulesetId === deployed.rulesetId);
218 expect(found?.kind).toEqual("custom");
219 expect(found?.rules).toHaveLength(1);
220
221 yield* stack.destroy();
222 }).pipe(logLevel),
223 {
224 tags: ["provider:cloudflare", "provider:cloudflare:ruleset", "live"],
225 timeout: 120_000,
226 },
227);