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 rum from "@distilled.cloud/cloudflare/rum";
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
18// Use a different zone than Site.test.ts — both suites manage zone-scoped
19// Web Analytics sites, and Cloudflare keys those to the zone, so running
20// the two files in parallel against the same zone would interfere.
21//
22// RUM rules require a zone-based (orange-clouded) site: only those own an
23// implicit ruleset (gray-clouded host-based sites do not). A zone-based site
24// is account-unique per zone, so creating "another" for the same zone simply
25// returns the existing site + ruleset. Every case in this file therefore
26// shares the SAME site + ruleset on `alchemy-test-3.us`. Under the global
27// concurrent test config that collision is fatal — a sibling's cold read
28// adopts the shared site (`OwnedBySomeoneElse`) and all cases pile rules onto
29// the one ruleset (account-wide `MaxRulesExceeded`). So they MUST run
30// serially: `describe.sequential` keeps each create→update→delete fully
31// isolated in time, and `cleanupLeftoverSites` self-heals against any
32// foreign/leftover site for the zone before each deploy.
33const zoneName = "alchemy-test-3.us";
34
35// The scoped API token the test harness mints propagates eventually-
36// consistently across Cloudflare's edge — ride out 403 blips
37// (`Forbidden`, declared in the distilled error union) on the test's
38// own out-of-band verification calls.
39const listRules = (accountId: string, rulesetId: string) =>
40 rum.listRules({ accountId, rulesetId }).pipe(
41 Effect.retry({
42 while: (e) => e._tag === "Forbidden",
43 schedule: Schedule.exponential("500 millis"),
44 times: 8,
45 }),
46 );
47
48const findRule = (accountId: string, rulesetId: string, ruleId: string) =>
49 listRules(accountId, rulesetId).pipe(
50 Effect.map((response) =>
51 (response.rules ?? []).find((rule) => rule.id === ruleId),
52 ),
53 );
54
55// A deleted rule disappears from the ruleset's rule list; once the parent
56// site is destroyed the whole ruleset is gone (`RulesetNotFound`). Both are
57// the success condition here. Poll on a fixed cadence with a hard recur cap
58// (~45s total) so that if the rule never disappears the test fails fast with
59// a clear `RuleNotDeleted` predicate failure instead of an exponential
60// backoff ballooning past the per-test timeout.
61const expectGone = (accountId: string, rulesetId: string, ruleId: string) =>
62 findRule(accountId, rulesetId, ruleId).pipe(
63 Effect.flatMap((rule) =>
64 rule === undefined
65 ? Effect.void
66 : Effect.fail({ _tag: "RuleNotDeleted", ruleId } as const),
67 ),
68 Effect.catchTag("RulesetNotFound", () => Effect.void),
69 Effect.retry({
70 while: (e) => e._tag === "RuleNotDeleted",
71 schedule: Schedule.spaced("3 seconds"),
72 times: 15,
73 }),
74 );
75
76// The free plan caps Web Analytics rule creation at a tiny account-wide
77// quota (`MaxRulesExceeded`, HTTP 409 / code 10012
78// `web_analytics.configuration.api.maxRulesError`). Empirically the cap
79// behaves like a rolling created-rules counter: deleting rules does not
80// promptly free it, so repeated test runs can exhaust it for a while even
81// when zero rules exist on the account. Ride out short contention with a
82// bounded retry on the typed tag, then surface `undefined` so the test can
83// skip gracefully instead of failing on a platform quota.
84const retryingQuota = <A, R>(deploy: Effect.Effect<A, any, R>) =>
85 deploy.pipe(
86 Effect.retry({
87 while: (e) => e._tag === "MaxRulesExceeded",
88 schedule: Schedule.spaced("6 seconds"),
89 times: 5,
90 }),
91 Effect.catchTag("MaxRulesExceeded", () => Effect.succeed(undefined)),
92 );
93
94const quotaExhausted = (stack: { destroy: () => Effect.Effect<void, any> }) =>
95 Effect.gen(function* () {
96 yield* Effect.logWarning(
97 "skipping Rule assertions: account-wide Web Analytics rule quota " +
98 "is exhausted (MaxRulesExceeded, code 10012, " +
99 "web_analytics.configuration.api.maxRulesError)",
100 );
101 yield* stack.destroy();
102 });
103
104// A failed earlier run (or a foreign stack) can leave a zone-based site
105// measuring the test zone; the Site provider's cold read would then surface
106// it as `Unowned` and refuse the deploy (`OwnedBySomeoneElse`). Delete any
107// leftovers for this zone out-of-band before deploying so the test cannot
108// wedge on a site it doesn't recognise as its own.
109const cleanupLeftoverSites = (accountId: string, zoneTag: string) =>
110 Effect.gen(function* () {
111 const sites = yield* rum.listSiteInfos({ accountId, perPage: 100 }).pipe(
112 Effect.retry({
113 while: (e) => e._tag === "Forbidden",
114 schedule: Schedule.exponential("500 millis"),
115 times: 8,
116 }),
117 );
118 for (const site of sites.result ?? []) {
119 if (site.ruleset?.zoneTag === zoneTag && site.siteTag) {
120 yield* rum
121 .deleteSiteInfo({ accountId, siteId: site.siteTag })
122 .pipe(Effect.catchTag("SiteNotFound", () => Effect.void));
123 }
124 }
125 });
126
127// One program deploying the zone-based site (which owns the implicit
128// ruleset) and the rule under it. The rule's `rulesetId` references the
129// site's output, so the engine orders rule-last on deploy (and first on
130// destroy).
131const program = (
132 zoneId: string,
133 rule: Omit<Cloudflare.Rum.RuleProps, "rulesetId">,
134) =>
135 Effect.gen(function* () {
136 const site = yield* Cloudflare.Rum.Site("RuleSite", {
137 zoneTag: zoneId,
138 });
139 const ruleResource = yield* Cloudflare.Rum.Rule("Rule", {
140 rulesetId: site.rulesetId.as<string>(),
141 ...rule,
142 });
143 return { site, rule: ruleResource };
144 });
145
146// These cases all manage the single account-unique zone-based site +
147// ruleset on `alchemy-test-3.us`, so they must not run concurrently.
148describe.sequential(
149 "Rule",
150 {
151 tags: [
152 "provider:cloudflare",
153 "provider:cloudflare:rum",
154 "provider:cloudflare:zone",
155 "live",
156 ],
157 },
158 () => {
159 test.provider("create, update in place, and delete a rule", (stack) =>
160 Effect.gen(function* () {
161 const { accountId } = yield* yield* CloudflareEnvironment;
162 const zone = yield* findZoneByName({ accountId, name: zoneName });
163 if (!zone) {
164 return yield* Effect.die(
165 new Error(`zone "${zoneName}" not found in account`),
166 );
167 }
168
169 yield* stack.destroy();
170 yield* cleanupLeftoverSites(accountId, zone.id);
171
172 const initial = yield* retryingQuota(
173 stack.deploy(
174 program(zone.id, {
175 host: zoneName,
176 paths: ["/blog/*"],
177 inclusive: false,
178 }),
179 ),
180 );
181 if (initial === undefined) return yield* quotaExhausted(stack);
182
183 expect(initial.site.rulesetId).toBeTruthy();
184 expect(initial.rule.id).toBeTruthy();
185 expect(initial.rule.rulesetId).toEqual(initial.site.rulesetId);
186 expect(initial.rule.accountId).toEqual(accountId);
187 expect(initial.rule.host).toEqual(zoneName);
188 expect(initial.rule.paths).toEqual(["/blog/*"]);
189 expect(initial.rule.inclusive).toEqual(false);
190 expect(initial.rule.isPaused).toEqual(false);
191
192 // Verify out-of-band against the live API.
193 const live = yield* findRule(
194 accountId,
195 initial.rule.rulesetId,
196 initial.rule.id,
197 );
198 expect(live).toBeDefined();
199 expect(live?.host).toEqual(zoneName);
200 expect(live?.paths).toEqual(["/blog/*"]);
201 expect(live?.inclusive).toEqual(false);
202
203 // Update paths and pause the rule in place — same rule id.
204 const updated = yield* stack.deploy(
205 program(zone.id, {
206 host: zoneName,
207 paths: ["/blog/*", "/admin/*"],
208 inclusive: false,
209 isPaused: true,
210 }),
211 );
212 expect(updated.rule.id).toEqual(initial.rule.id);
213 expect(updated.rule.paths).toEqual(["/blog/*", "/admin/*"]);
214 expect(updated.rule.isPaused).toEqual(true);
215
216 const liveUpdated = yield* findRule(
217 accountId,
218 updated.rule.rulesetId,
219 updated.rule.id,
220 );
221 expect(liveUpdated?.paths).toEqual(["/blog/*", "/admin/*"]);
222 expect(liveUpdated?.isPaused).toEqual(true);
223
224 // Redeploying identical props is a no-op (still the same rule).
225 const noop = yield* stack.deploy(
226 program(zone.id, {
227 host: zoneName,
228 paths: ["/blog/*", "/admin/*"],
229 inclusive: false,
230 isPaused: true,
231 }),
232 );
233 expect(noop.rule.id).toEqual(initial.rule.id);
234
235 const rulesetId = initial.rule.rulesetId;
236 yield* stack.destroy();
237
238 yield* expectGone(accountId, rulesetId, initial.rule.id);
239 }).pipe(logLevel),
240 );
241
242 // Canonical `list()` test: rules live under a Site's implicit ruleset and
243 // there is no account-wide rule list, so `list()` enumerates every site
244 // (paginated) and fans out the per-ruleset rule list. Deploy a real rule,
245 // then assert it appears in the exhaustively-enumerated result. Gated behind
246 // the same account-wide `MaxRulesExceeded` quota the other cases handle.
247 test.provider("list enumerates rules across all rulesets", (stack) =>
248 Effect.gen(function* () {
249 const { accountId } = yield* yield* CloudflareEnvironment;
250 const zone = yield* findZoneByName({ accountId, name: zoneName });
251 if (!zone) {
252 return yield* Effect.die(
253 new Error(`zone "${zoneName}" not found in account`),
254 );
255 }
256
257 yield* stack.destroy();
258 yield* cleanupLeftoverSites(accountId, zone.id);
259
260 const deployed = yield* retryingQuota(
261 stack.deploy(
262 program(zone.id, {
263 host: zoneName,
264 paths: ["/list-test/*"],
265 inclusive: false,
266 }),
267 ),
268 );
269 if (deployed === undefined) return yield* quotaExhausted(stack);
270
271 const provider = yield* Provider.findProvider(Cloudflare.Rum.Rule);
272 const all = yield* provider.list();
273
274 expect(
275 all.some(
276 (r) =>
277 r.id === deployed.rule.id &&
278 r.rulesetId === deployed.rule.rulesetId,
279 ),
280 ).toBe(true);
281
282 yield* stack.destroy();
283 }).pipe(logLevel),
284 );
285
286 test.provider("recreates after out-of-band delete", (stack) =>
287 Effect.gen(function* () {
288 const { accountId } = yield* yield* CloudflareEnvironment;
289 const zone = yield* findZoneByName({ accountId, name: zoneName });
290 if (!zone) {
291 return yield* Effect.die(
292 new Error(`zone "${zoneName}" not found in account`),
293 );
294 }
295
296 yield* stack.destroy();
297 yield* cleanupLeftoverSites(accountId, zone.id);
298
299 const initial = yield* retryingQuota(
300 stack.deploy(
301 program(zone.id, {
302 host: zoneName,
303 paths: ["/heal/*"],
304 inclusive: false,
305 }),
306 ),
307 );
308 if (initial === undefined) return yield* quotaExhausted(stack);
309
310 // Delete the rule out-of-band. A redeploy with identical props is a
311 // planner no-op, so change a prop to force reconcile — it must observe
312 // the rule as missing and recreate it instead of failing.
313 yield* rum
314 .deleteRule({
315 accountId,
316 rulesetId: initial.rule.rulesetId,
317 ruleId: initial.rule.id,
318 })
319 .pipe(
320 Effect.retry({
321 while: (e) => e._tag === "Forbidden",
322 schedule: Schedule.exponential("500 millis"),
323 times: 8,
324 }),
325 );
326
327 const healed = yield* retryingQuota(
328 stack.deploy(
329 program(zone.id, {
330 host: zoneName,
331 paths: ["/heal-v2/*"],
332 inclusive: false,
333 }),
334 ),
335 );
336 if (healed === undefined) return yield* quotaExhausted(stack);
337
338 expect(healed.rule.id).not.toEqual(initial.rule.id);
339 expect(healed.rule.paths).toEqual(["/heal-v2/*"]);
340
341 const live = yield* findRule(
342 accountId,
343 healed.rule.rulesetId,
344 healed.rule.id,
345 );
346 expect(live?.paths).toEqual(["/heal-v2/*"]);
347
348 const rulesetId = healed.rule.rulesetId;
349 yield* stack.destroy();
350
351 yield* expectGone(accountId, rulesetId, healed.rule.id);
352 }).pipe(logLevel),
353 );
354 },
355);