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 { poll } from "@/Util/poll";
8import * as snippets from "@distilled.cloud/cloudflare/snippets";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
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 snippet names — same value on every run.
25const NAME_RULES_A = "alchemy_snippet_rules_a";
26const NAME_RULES_B = "alchemy_snippet_rules_b";
27
28const EXPRESSION_V1 = 'http.request.uri.path wildcard "/alchemy-snippet/*"';
29const EXPRESSION_V2 = 'http.request.uri.path wildcard "/alchemy-snippet-v2/*"';
30const EXPRESSION_B = 'http.request.uri.path wildcard "/alchemy-snippet-b/*"';
31
32const code = `
33export default {
34 async fetch(request) {
35 const response = await fetch(request);
36 const newResponse = new Response(response.body, response);
37 newResponse.headers.set("x-alchemy-snippet", "rules-test");
38 return newResponse;
39 },
40};
41`;
42
43const resolveZoneId = Effect.gen(function* () {
44 const { accountId } = yield* yield* CloudflareEnvironment;
45 const zone = yield* findZoneByName({ accountId, name: zoneName });
46 if (!zone) {
47 return yield* Effect.die(
48 new Error(`zone "${zoneName}" not found in account`),
49 );
50 }
51 return zone.id;
52});
53
54interface WireRule {
55 readonly snippetName?: string;
56 readonly expression?: string;
57 readonly enabled?: boolean;
58 readonly description?: string | null;
59}
60
61// Ride out eventually-consistent 403s (freshly-minted scoped tokens
62// propagate slowly across Cloudflare's edge) on the test's own
63// out-of-band verification calls.
64const forbiddenRetryPolicy = {
65 schedule: Schedule.exponential("500 millis"),
66 times: 8,
67} as const;
68
69const listLiveRules = (zoneId: string) =>
70 snippets.listRules({ zoneId }).pipe(
71 Effect.map((result) =>
72 Array.isArray(result) ? (result as WireRule[]) : [],
73 ),
74 Effect.retry({
75 while: (e) => e._tag === "Forbidden",
76 ...forbiddenRetryPolicy,
77 }),
78 );
79
80// The live rule list is eventually consistent after a PUT — poll the
81// out-of-band read until it reaches the expected length before asserting.
82const pollLiveRules = (zoneId: string, expectedLength: number) =>
83 poll({
84 description: `snippet rules length === ${expectedLength}`,
85 effect: listLiveRules(zoneId),
86 predicate: (rules) => rules.length === expectedLength,
87 schedule: Schedule.max([
88 Schedule.exponential("500 millis"),
89 Schedule.recurs(10),
90 ]),
91 });
92
93const findSnippet = (zoneId: string, name: string) =>
94 snippets.listSnippets({ zoneId, perPage: 100 }).pipe(
95 Effect.map((page) =>
96 (page.result ?? []).find((s) => s.snippetName === name),
97 ),
98 Effect.retry({
99 while: (e) => e._tag === "Forbidden",
100 ...forbiddenRetryPolicy,
101 }),
102 );
103
104// The zone's snippet-rule list is a singleton; purge any leftovers from
105// interrupted runs so the test starts from a clean slate.
106const purgeRules = (zoneId: string) =>
107 listLiveRules(zoneId).pipe(
108 Effect.catchTag("SnippetRulesNotFound", () => Effect.succeed([])),
109 Effect.flatMap((rules) =>
110 rules.length === 0 ? Effect.void : snippets.deleteRule({ zoneId }),
111 ),
112 Effect.retry({
113 while: (e) => e._tag === "Forbidden",
114 ...forbiddenRetryPolicy,
115 }),
116 Effect.catchTag(
117 ["SnippetRulesNotFound", "SnippetZoneNotFound"],
118 () => Effect.void,
119 ),
120 );
121
122// The zone's snippet-rule list is a per-zone SINGLETON, and snippets are
123// keyed by name within a zone. There is only one standing test zone, so two
124// `test.provider` cases (which run CONCURRENTLY within a file) would fight
125// over the same singleton rule list and the same snippet name — one case's
126// `purgeRules`/`destroy` empties the other's freshly-PUT rule list, and one
127// case's snippet delete races the other's rule that still references it
128// (`snippet is still used`). They are therefore folded into ONE sequential
129// case: create/update/destroy lifecycle PLUS the `list()` enumeration.
130test.provider(
131 "snippet rules — create, update, list, and destroy in dependency order",
132 (stack) =>
133 Effect.gen(function* () {
134 const zoneId = yield* resolveZoneId;
135
136 yield* stack.destroy();
137 yield* purgeRules(zoneId);
138
139 // Create a snippet plus one rule activating it.
140 const initial = yield* stack.deploy(
141 Effect.gen(function* () {
142 const snippet = yield* Cloudflare.Snippets.Snippet("RulesSnippetA", {
143 zoneId,
144 name: NAME_RULES_A,
145 code,
146 }).pipe(adopt(true));
147 const rules = yield* Cloudflare.Snippets.SnippetRules("Rules", {
148 zoneId,
149 rules: [
150 {
151 snippetName: snippet.name,
152 expression: EXPRESSION_V1,
153 description: "alchemy snippet rules test",
154 },
155 ],
156 }).pipe(adopt(true));
157 return { snippet, rules };
158 }),
159 );
160
161 expect(initial.rules.zoneId).toEqual(zoneId);
162 expect(initial.rules.rules).toHaveLength(1);
163 expect(initial.rules.rules[0].snippetName).toEqual(NAME_RULES_A);
164 expect(initial.rules.rules[0].expression).toEqual(EXPRESSION_V1);
165 expect(initial.rules.rules[0].enabled).toEqual(true);
166
167 const live = yield* pollLiveRules(zoneId, 1);
168 expect(live).toHaveLength(1);
169 expect(live[0].snippetName).toEqual(NAME_RULES_A);
170 expect(live[0].expression).toEqual(EXPRESSION_V1);
171
172 // `list()` enumerates every zone (no account-wide rule-list API) and
173 // reads the rule list in each, skipping zones with no rules / no
174 // access. Our deployed rule list must surface for the test zone.
175 const provider = yield* Provider.findProvider(
176 Cloudflare.Snippets.SnippetRules,
177 );
178 const all = yield* provider.list();
179 expect(all.length).toBeGreaterThan(0);
180 const entry = all.find((r) => r.zoneId === zoneId);
181 expect(entry).toBeDefined();
182 expect(entry!.rules.some((r) => r.snippetName === NAME_RULES_A)).toBe(
183 true,
184 );
185
186 // Update: change the expression and add a second snippet + rule.
187 const updated = yield* stack.deploy(
188 Effect.gen(function* () {
189 const snippetA = yield* Cloudflare.Snippets.Snippet("RulesSnippetA", {
190 zoneId,
191 name: NAME_RULES_A,
192 code,
193 }).pipe(adopt(true));
194 const snippetB = yield* Cloudflare.Snippets.Snippet("RulesSnippetB", {
195 zoneId,
196 name: NAME_RULES_B,
197 code,
198 }).pipe(adopt(true));
199 const rules = yield* Cloudflare.Snippets.SnippetRules("Rules", {
200 zoneId,
201 rules: [
202 {
203 snippetName: snippetA.name,
204 expression: EXPRESSION_V2,
205 description: "alchemy snippet rules test v2",
206 },
207 {
208 snippetName: snippetB.name,
209 expression: EXPRESSION_B,
210 enabled: false,
211 },
212 ],
213 }).pipe(adopt(true));
214 return { rules };
215 }),
216 );
217
218 expect(updated.rules.rules).toHaveLength(2);
219 expect(updated.rules.rules[0].expression).toEqual(EXPRESSION_V2);
220 expect(updated.rules.rules[1].snippetName).toEqual(NAME_RULES_B);
221 expect(updated.rules.rules[1].enabled).toEqual(false);
222
223 const liveUpdated = yield* pollLiveRules(zoneId, 2);
224 expect(liveUpdated).toHaveLength(2);
225 expect(liveUpdated[0].expression).toEqual(EXPRESSION_V2);
226 expect(liveUpdated[1].snippetName).toEqual(NAME_RULES_B);
227
228 // Destroy — rules must be deleted before the snippets they
229 // reference (dependency ordering via the `snippetName` input). The
230 // snippet delete bounded-retries the typed `SnippetInUse` lag.
231 yield* stack.destroy();
232
233 const liveGone = yield* pollLiveRules(zoneId, 0);
234 expect(liveGone).toHaveLength(0);
235 expect(yield* findSnippet(zoneId, NAME_RULES_A)).toBeUndefined();
236 expect(yield* findSnippet(zoneId, NAME_RULES_B)).toBeUndefined();
237 }).pipe(logLevel),
238 {
239 tags: [
240 "provider:cloudflare",
241 "provider:cloudflare:snippets",
242 "provider:cloudflare:zone",
243 "live",
244 ],
245 timeout: 180_000,
246 },
247);