| 1 | import { adopt } from "@/AdoptPolicy"; |
| 2 | import * as Cloudflare from "@/Cloudflare"; |
| 3 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 4 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import { poll } from "@/Util/poll"; |
| 8 | import * as snippets from "@distilled.cloud/cloudflare/snippets"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | |
| 14 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 15 | |
| 16 | const logLevel = Effect.provideService( |
| 17 | MinimumLogLevel, |
| 18 | process.env.DEBUG ? "Debug" : "Info", |
| 19 | ); |
| 20 | |
| 21 | const zoneName = |
| 22 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 23 | |
| 24 | // Deterministic snippet names — same value on every run. |
| 25 | const NAME_RULES_A = "alchemy_snippet_rules_a"; |
| 26 | const NAME_RULES_B = "alchemy_snippet_rules_b"; |
| 27 | |
| 28 | const EXPRESSION_V1 = 'http.request.uri.path wildcard "/alchemy-snippet/*"'; |
| 29 | const EXPRESSION_V2 = 'http.request.uri.path wildcard "/alchemy-snippet-v2/*"'; |
| 30 | const EXPRESSION_B = 'http.request.uri.path wildcard "/alchemy-snippet-b/*"'; |
| 31 | |
| 32 | const code = ` |
| 33 | export 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 | |
| 43 | const 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 | |
| 54 | interface 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. |
| 64 | const forbiddenRetryPolicy = { |
| 65 | schedule: Schedule.exponential("500 millis"), |
| 66 | times: 8, |
| 67 | } as const; |
| 68 | |
| 69 | const 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. |
| 82 | const 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 | |
| 93 | const 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. |
| 106 | const 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. |
| 130 | test.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 | ); |