| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as rules from "@distilled.cloud/cloudflare/rules"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 13 | |
| 14 | const logLevel = Effect.provideService( |
| 15 | MinimumLogLevel, |
| 16 | process.env.DEBUG ? "Debug" : "Info", |
| 17 | ); |
| 18 | |
| 19 | // The scoped API token the test harness mints propagates eventually- |
| 20 | // consistently across Cloudflare's edge — ride out 403 blips |
| 21 | // (`Forbidden`, declared in the distilled error union) on the test's |
| 22 | // own out-of-band verification calls. |
| 23 | const getList = (accountId: string, listId: string) => |
| 24 | rules.getList({ accountId, listId }).pipe( |
| 25 | Effect.retry({ |
| 26 | while: (e) => e._tag === "Forbidden", |
| 27 | schedule: Schedule.exponential("500 millis"), |
| 28 | times: 8, |
| 29 | }), |
| 30 | ); |
| 31 | |
| 32 | const getItems = (accountId: string, listId: string) => |
| 33 | rules.listListItems.items({ accountId, listId }).pipe( |
| 34 | Stream.runCollect, |
| 35 | Effect.map((chunk) => Array.from(chunk)), |
| 36 | Effect.retry({ |
| 37 | while: (e) => e._tag === "Forbidden", |
| 38 | schedule: Schedule.exponential("500 millis"), |
| 39 | times: 8, |
| 40 | }), |
| 41 | ); |
| 42 | |
| 43 | const expectGone = (accountId: string, listId: string) => |
| 44 | getList(accountId, listId).pipe( |
| 45 | Effect.flatMap(() => Effect.fail({ _tag: "ListNotDeleted" } as const)), |
| 46 | // A missing list surfaces as `ListNotFound` (Cloudflare error code |
| 47 | // 10001) — that's the success condition here. |
| 48 | Effect.catchTag("ListNotFound", () => Effect.void), |
| 49 | Effect.retry({ |
| 50 | while: (e) => e._tag === "ListNotDeleted", |
| 51 | schedule: Schedule.max([ |
| 52 | Schedule.exponential("500 millis"), |
| 53 | Schedule.recurs(10), |
| 54 | ]), |
| 55 | }), |
| 56 | ); |
| 57 | |
| 58 | test.provider( |
| 59 | "create and delete an ip list with default name", |
| 60 | (stack) => |
| 61 | Effect.gen(function* () { |
| 62 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 63 | |
| 64 | yield* stack.destroy(); |
| 65 | |
| 66 | const list = yield* stack.deploy( |
| 67 | Cloudflare.Rules.List("DefaultList", { |
| 68 | kind: "ip", |
| 69 | description: "alchemy rules list create test", |
| 70 | items: [ |
| 71 | { ip: "203.0.113.7", comment: "scanner" }, |
| 72 | { ip: "198.51.100.0/24" }, |
| 73 | ], |
| 74 | }), |
| 75 | ); |
| 76 | |
| 77 | expect(list.listId).toBeDefined(); |
| 78 | expect(list.accountId).toEqual(accountId); |
| 79 | expect(list.kind).toEqual("ip"); |
| 80 | expect(list.description).toEqual("alchemy rules list create test"); |
| 81 | // List names only allow letters, numbers, and underscores. |
| 82 | expect(list.name).toMatch(/^[a-zA-Z0-9_]+$/); |
| 83 | expect(list.numItems).toEqual(2); |
| 84 | |
| 85 | const live = yield* getList(accountId, list.listId); |
| 86 | expect(live.name).toEqual(list.name); |
| 87 | expect(live.kind).toEqual("ip"); |
| 88 | |
| 89 | const items = yield* getItems(accountId, list.listId); |
| 90 | const ips = items |
| 91 | .map((item) => ("ip" in item ? item.ip : undefined)) |
| 92 | .sort(); |
| 93 | expect(ips).toEqual(["198.51.100.0/24", "203.0.113.7"]); |
| 94 | |
| 95 | yield* stack.destroy(); |
| 96 | |
| 97 | yield* expectGone(accountId, list.listId); |
| 98 | }).pipe(logLevel), |
| 99 | { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] }, |
| 100 | ); |
| 101 | |
| 102 | test.provider( |
| 103 | "update description and items in place (same listId)", |
| 104 | (stack) => |
| 105 | Effect.gen(function* () { |
| 106 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 107 | |
| 108 | yield* stack.destroy(); |
| 109 | |
| 110 | const initial = yield* stack.deploy( |
| 111 | Cloudflare.Rules.List("UpdateList", { |
| 112 | name: "alchemy_rules_list_update", |
| 113 | kind: "ip", |
| 114 | description: "v1", |
| 115 | items: [{ ip: "203.0.113.1" }, { ip: "203.0.113.2" }], |
| 116 | }), |
| 117 | ); |
| 118 | |
| 119 | expect(initial.name).toEqual("alchemy_rules_list_update"); |
| 120 | expect(initial.description).toEqual("v1"); |
| 121 | expect(initial.numItems).toEqual(2); |
| 122 | |
| 123 | const updated = yield* stack.deploy( |
| 124 | Cloudflare.Rules.List("UpdateList", { |
| 125 | name: "alchemy_rules_list_update", |
| 126 | kind: "ip", |
| 127 | description: "v2", |
| 128 | items: [ |
| 129 | { ip: "203.0.113.2", comment: "kept" }, |
| 130 | { ip: "192.0.2.0/24" }, |
| 131 | ], |
| 132 | }), |
| 133 | ); |
| 134 | |
| 135 | // Same list mutated in place — not a replacement. |
| 136 | expect(updated.listId).toEqual(initial.listId); |
| 137 | expect(updated.description).toEqual("v2"); |
| 138 | expect(updated.numItems).toEqual(2); |
| 139 | |
| 140 | const items = yield* getItems(accountId, updated.listId); |
| 141 | const ips = items |
| 142 | .map((item) => ("ip" in item ? item.ip : undefined)) |
| 143 | .sort(); |
| 144 | expect(ips).toEqual(["192.0.2.0/24", "203.0.113.2"]); |
| 145 | const kept = items.find( |
| 146 | (item) => "ip" in item && item.ip === "203.0.113.2", |
| 147 | ); |
| 148 | expect(kept && "comment" in kept ? kept.comment : undefined).toEqual( |
| 149 | "kept", |
| 150 | ); |
| 151 | |
| 152 | // Redeploying identical props is a no-op (still the same list). |
| 153 | const noop = yield* stack.deploy( |
| 154 | Cloudflare.Rules.List("UpdateList", { |
| 155 | name: "alchemy_rules_list_update", |
| 156 | kind: "ip", |
| 157 | description: "v2", |
| 158 | items: [ |
| 159 | { ip: "203.0.113.2", comment: "kept" }, |
| 160 | { ip: "192.0.2.0/24" }, |
| 161 | ], |
| 162 | }), |
| 163 | ); |
| 164 | expect(noop.listId).toEqual(initial.listId); |
| 165 | |
| 166 | yield* stack.destroy(); |
| 167 | |
| 168 | yield* expectGone(accountId, initial.listId); |
| 169 | }).pipe(logLevel), |
| 170 | { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] }, |
| 171 | ); |
| 172 | |
| 173 | test.provider( |
| 174 | "changing kind replaces the list", |
| 175 | (stack) => |
| 176 | Effect.gen(function* () { |
| 177 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 178 | |
| 179 | yield* stack.destroy(); |
| 180 | |
| 181 | const ipList = yield* stack.deploy( |
| 182 | Cloudflare.Rules.List("ReplaceList", { |
| 183 | name: "alchemy_rules_list_replace", |
| 184 | kind: "ip", |
| 185 | items: [{ ip: "203.0.113.9" }], |
| 186 | }), |
| 187 | ); |
| 188 | expect(ipList.kind).toEqual("ip"); |
| 189 | |
| 190 | const hostnameList = yield* stack.deploy( |
| 191 | Cloudflare.Rules.List("ReplaceList", { |
| 192 | name: "alchemy_rules_list_replace", |
| 193 | kind: "hostname", |
| 194 | items: [{ hostname: { urlHostname: "example.com" } }], |
| 195 | }), |
| 196 | ); |
| 197 | |
| 198 | // Kind is immutable — the engine must replace the list. |
| 199 | expect(hostnameList.listId).not.toEqual(ipList.listId); |
| 200 | expect(hostnameList.kind).toEqual("hostname"); |
| 201 | |
| 202 | // The old list is gone, the new one is live. |
| 203 | yield* expectGone(accountId, ipList.listId); |
| 204 | const live = yield* getList(accountId, hostnameList.listId); |
| 205 | expect(live.kind).toEqual("hostname"); |
| 206 | |
| 207 | yield* stack.destroy(); |
| 208 | |
| 209 | yield* expectGone(accountId, hostnameList.listId); |
| 210 | }).pipe(logLevel), |
| 211 | { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] }, |
| 212 | ); |
| 213 | |
| 214 | test.provider( |
| 215 | "recreates after out-of-band delete", |
| 216 | (stack) => |
| 217 | Effect.gen(function* () { |
| 218 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 219 | |
| 220 | yield* stack.destroy(); |
| 221 | |
| 222 | const list = yield* stack.deploy( |
| 223 | Cloudflare.Rules.List("HealList", { |
| 224 | name: "alchemy_rules_list_heal", |
| 225 | kind: "ip", |
| 226 | items: [{ ip: "203.0.113.11" }], |
| 227 | }), |
| 228 | ); |
| 229 | |
| 230 | // Delete the list out-of-band. A redeploy with identical props is a |
| 231 | // planner no-op, so change a prop to force reconcile — it must observe |
| 232 | // the list as missing and recreate it instead of failing on a 404. |
| 233 | yield* rules.deleteList({ accountId, listId: list.listId }).pipe( |
| 234 | Effect.retry({ |
| 235 | while: (e) => e._tag === "Forbidden", |
| 236 | schedule: Schedule.exponential("500 millis"), |
| 237 | times: 8, |
| 238 | }), |
| 239 | ); |
| 240 | |
| 241 | const healed = yield* stack.deploy( |
| 242 | Cloudflare.Rules.List("HealList", { |
| 243 | name: "alchemy_rules_list_heal", |
| 244 | kind: "ip", |
| 245 | description: "healed", |
| 246 | items: [{ ip: "203.0.113.11" }], |
| 247 | }), |
| 248 | ); |
| 249 | |
| 250 | expect(healed.listId).not.toEqual(list.listId); |
| 251 | expect(healed.description).toEqual("healed"); |
| 252 | const live = yield* getList(accountId, healed.listId); |
| 253 | expect(live.name).toEqual("alchemy_rules_list_heal"); |
| 254 | |
| 255 | yield* stack.destroy(); |
| 256 | |
| 257 | yield* expectGone(accountId, healed.listId); |
| 258 | }).pipe(logLevel), |
| 259 | { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] }, |
| 260 | ); |
| 261 | |
| 262 | test.provider( |
| 263 | "adopts an existing list with the same name", |
| 264 | (stack) => |
| 265 | Effect.gen(function* () { |
| 266 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 267 | |
| 268 | yield* stack.destroy(); |
| 269 | |
| 270 | // Create a list out-of-band with the exact name the resource will use — |
| 271 | // the create conflict (`ListAlreadyExists`, code 10021) must resolve by |
| 272 | // adopting the existing list. |
| 273 | const preexisting = yield* rules |
| 274 | .createList({ |
| 275 | accountId, |
| 276 | name: "alchemy_rules_list_adopt", |
| 277 | kind: "ip", |
| 278 | }) |
| 279 | .pipe( |
| 280 | Effect.retry({ |
| 281 | while: (e) => e._tag === "Forbidden", |
| 282 | schedule: Schedule.exponential("500 millis"), |
| 283 | times: 8, |
| 284 | }), |
| 285 | Effect.catchTag("ListAlreadyExists", () => |
| 286 | rules.listLists.items({ accountId }).pipe( |
| 287 | Stream.filter((l) => l.name === "alchemy_rules_list_adopt"), |
| 288 | Stream.runHead, |
| 289 | Effect.map(Option.getOrThrow), |
| 290 | ), |
| 291 | ), |
| 292 | ); |
| 293 | |
| 294 | const adopted = yield* stack.deploy( |
| 295 | Cloudflare.Rules.List("AdoptList", { |
| 296 | name: "alchemy_rules_list_adopt", |
| 297 | kind: "ip", |
| 298 | description: "adopted", |
| 299 | items: [{ ip: "203.0.113.21" }], |
| 300 | }), |
| 301 | ); |
| 302 | |
| 303 | expect(adopted.listId).toEqual(preexisting.id); |
| 304 | expect(adopted.description).toEqual("adopted"); |
| 305 | expect(adopted.numItems).toEqual(1); |
| 306 | |
| 307 | yield* stack.destroy(); |
| 308 | |
| 309 | yield* expectGone(accountId, preexisting.id); |
| 310 | }).pipe(logLevel), |
| 311 | { tags: ["provider:cloudflare", "provider:cloudflare:rules", "live"] }, |
| 312 | ); |