| 1 | import { adopt, OwnedBySomeoneElse } 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 * as workers from "@distilled.cloud/cloudflare/workers"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as pathe from "pathe"; |
| 15 | |
| 16 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 17 | |
| 18 | const logLevel = Effect.provideService( |
| 19 | MinimumLogLevel, |
| 20 | process.env.DEBUG ? "Debug" : "Info", |
| 21 | ); |
| 22 | |
| 23 | const zoneName = |
| 24 | process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us"; |
| 25 | |
| 26 | const main = pathe.resolve(import.meta.dirname, "fixtures", "route-worker.ts"); |
| 27 | |
| 28 | // Deterministic per-test route patterns. Each test owns a disjoint |
| 29 | // subdomain so reruns and parallel runs never collide, and the same |
| 30 | // pattern is reused on every run (never derive physical names from |
| 31 | // Date.now()/random). |
| 32 | const PATTERN_DEFAULT = `alchemy-route-default.${zoneName}/*`; |
| 33 | const PATTERN_UPDATE_V1 = `alchemy-route-update.${zoneName}/*`; |
| 34 | const PATTERN_UPDATE_V2 = `alchemy-route-update.${zoneName}/api/*`; |
| 35 | const PATTERN_ADOPT = `alchemy-route-adopt.${zoneName}/*`; |
| 36 | |
| 37 | const resolveZoneId = Effect.gen(function* () { |
| 38 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 39 | const zone = yield* findZoneByName({ accountId, name: zoneName }); |
| 40 | if (!zone) { |
| 41 | return yield* Effect.die( |
| 42 | new Error(`zone "${zoneName}" not found in account`), |
| 43 | ); |
| 44 | } |
| 45 | return zone.id; |
| 46 | }); |
| 47 | |
| 48 | // The scoped API token the test harness mints propagates eventually- |
| 49 | // consistently across Cloudflare's edge — a fresh token intermittently |
| 50 | // 403s with "Unable to authenticate request". Ride out the blips on the |
| 51 | // test's own out-of-band verification calls by retrying the typed |
| 52 | // `Forbidden` error (part of each route operation's error union via |
| 53 | // distilled patches). |
| 54 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 55 | |
| 56 | const listByPattern = (zoneId: string, pattern: string) => |
| 57 | workers.listRoutes.items({ zoneId }).pipe( |
| 58 | Stream.filter((r) => r.pattern === pattern), |
| 59 | Stream.runCollect, |
| 60 | Effect.map((chunk) => Array.from(chunk)), |
| 61 | Effect.retry({ |
| 62 | while: (e) => e._tag === "Forbidden", |
| 63 | schedule: forbiddenRetrySchedule, |
| 64 | times: 8, |
| 65 | }), |
| 66 | ); |
| 67 | |
| 68 | const findRoute = (zoneId: string, pattern: string) => |
| 69 | listByPattern(zoneId, pattern).pipe(Effect.map((rs) => rs[0])); |
| 70 | |
| 71 | const getRoute = (zoneId: string, routeId: string) => |
| 72 | workers.getRoute({ zoneId, routeId }).pipe( |
| 73 | Effect.retry({ |
| 74 | while: (e) => e._tag === "Forbidden", |
| 75 | schedule: forbiddenRetrySchedule, |
| 76 | times: 8, |
| 77 | }), |
| 78 | ); |
| 79 | |
| 80 | // Delete every route matching the pattern — used to purge leftovers from |
| 81 | // interrupted runs so tests start from a clean slate (Cloudflare enforces |
| 82 | // one route per pattern per zone, so a leaked route would conflict). |
| 83 | const purgeRoutes = (zoneId: string, pattern: string) => |
| 84 | listByPattern(zoneId, pattern).pipe( |
| 85 | Effect.flatMap( |
| 86 | Effect.forEach((r) => |
| 87 | workers |
| 88 | .deleteRoute({ zoneId, routeId: r.id }) |
| 89 | .pipe(Effect.catch(() => Effect.void)), |
| 90 | ), |
| 91 | ), |
| 92 | ); |
| 93 | |
| 94 | test.provider( |
| 95 | "create and delete an opt-out route (no script)", |
| 96 | (stack) => |
| 97 | Effect.gen(function* () { |
| 98 | const zoneId = yield* resolveZoneId; |
| 99 | |
| 100 | yield* stack.destroy(); |
| 101 | yield* purgeRoutes(zoneId, PATTERN_DEFAULT); |
| 102 | |
| 103 | const route = yield* stack.deploy( |
| 104 | Effect.gen(function* () { |
| 105 | return yield* Cloudflare.Workers.WorkerRoute("DefaultRoute", { |
| 106 | zoneId, |
| 107 | pattern: PATTERN_DEFAULT, |
| 108 | }).pipe(adopt(true)); |
| 109 | }), |
| 110 | ); |
| 111 | |
| 112 | expect(route.routeId).toBeDefined(); |
| 113 | expect(route.zoneId).toEqual(zoneId); |
| 114 | expect(route.pattern).toEqual(PATTERN_DEFAULT); |
| 115 | // No script — the route opts matching requests out of Workers. |
| 116 | expect(route.script).toBeUndefined(); |
| 117 | |
| 118 | const live = yield* getRoute(zoneId, route.routeId); |
| 119 | expect(live.id).toEqual(route.routeId); |
| 120 | expect(live.pattern).toEqual(PATTERN_DEFAULT); |
| 121 | |
| 122 | yield* stack.destroy(); |
| 123 | |
| 124 | const gone = yield* findRoute(zoneId, PATTERN_DEFAULT); |
| 125 | expect(gone).toBeUndefined(); |
| 126 | }).pipe(logLevel), |
| 127 | { |
| 128 | tags: [ |
| 129 | "provider:cloudflare", |
| 130 | "provider:cloudflare:worker", |
| 131 | "provider:cloudflare:zone", |
| 132 | "live", |
| 133 | ], |
| 134 | timeout: 300_000, |
| 135 | }, |
| 136 | ); |
| 137 | |
| 138 | test.provider( |
| 139 | "route to a Worker, then update pattern and script in place", |
| 140 | (stack) => |
| 141 | Effect.gen(function* () { |
| 142 | const zoneId = yield* resolveZoneId; |
| 143 | |
| 144 | yield* stack.destroy(); |
| 145 | yield* purgeRoutes(zoneId, PATTERN_UPDATE_V1); |
| 146 | yield* purgeRoutes(zoneId, PATTERN_UPDATE_V2); |
| 147 | |
| 148 | const initial = yield* stack.deploy( |
| 149 | Effect.gen(function* () { |
| 150 | const worker = yield* Cloudflare.Worker("RouteWorker", { |
| 151 | main, |
| 152 | compatibility: { date: "2024-01-01" }, |
| 153 | }); |
| 154 | const route = yield* Cloudflare.Workers.WorkerRoute("Route", { |
| 155 | zoneId, |
| 156 | pattern: PATTERN_UPDATE_V1, |
| 157 | script: worker.workerName, |
| 158 | }).pipe(adopt(true)); |
| 159 | return { worker, route }; |
| 160 | }), |
| 161 | ); |
| 162 | |
| 163 | expect(initial.route.pattern).toEqual(PATTERN_UPDATE_V1); |
| 164 | expect(initial.route.script).toEqual(initial.worker.workerName); |
| 165 | |
| 166 | const liveInitial = yield* getRoute(zoneId, initial.route.routeId); |
| 167 | expect(liveInitial.pattern).toEqual(PATTERN_UPDATE_V1); |
| 168 | expect(liveInitial.script).toEqual(initial.worker.workerName); |
| 169 | |
| 170 | // Pattern and script are both mutable — the same physical route is |
| 171 | // updated in place (PUT), not replaced. |
| 172 | const updated = yield* stack.deploy( |
| 173 | Effect.gen(function* () { |
| 174 | const worker = yield* Cloudflare.Worker("RouteWorker", { |
| 175 | main, |
| 176 | compatibility: { date: "2024-01-01" }, |
| 177 | }); |
| 178 | const route = yield* Cloudflare.Workers.WorkerRoute("Route", { |
| 179 | zoneId, |
| 180 | pattern: PATTERN_UPDATE_V2, |
| 181 | // Drop the script — the route becomes an opt-out route. |
| 182 | }).pipe(adopt(true)); |
| 183 | return { worker, route }; |
| 184 | }), |
| 185 | ); |
| 186 | |
| 187 | expect(updated.route.routeId).toEqual(initial.route.routeId); |
| 188 | expect(updated.route.pattern).toEqual(PATTERN_UPDATE_V2); |
| 189 | expect(updated.route.script).toBeUndefined(); |
| 190 | |
| 191 | const liveUpdated = yield* getRoute(zoneId, updated.route.routeId); |
| 192 | expect(liveUpdated.pattern).toEqual(PATTERN_UPDATE_V2); |
| 193 | |
| 194 | yield* stack.destroy(); |
| 195 | |
| 196 | const gone = yield* findRoute(zoneId, PATTERN_UPDATE_V2); |
| 197 | expect(gone).toBeUndefined(); |
| 198 | }).pipe(logLevel), |
| 199 | { |
| 200 | tags: [ |
| 201 | "provider:cloudflare", |
| 202 | "provider:cloudflare:worker", |
| 203 | "provider:cloudflare:zone", |
| 204 | "live", |
| 205 | ], |
| 206 | timeout: 300_000, |
| 207 | }, |
| 208 | ); |
| 209 | |
| 210 | test.provider( |
| 211 | "adoption — existing route errors without adopt, takes over with adopt(true)", |
| 212 | (stack) => |
| 213 | Effect.gen(function* () { |
| 214 | const zoneId = yield* resolveZoneId; |
| 215 | |
| 216 | yield* stack.destroy(); |
| 217 | yield* purgeRoutes(zoneId, PATTERN_ADOPT); |
| 218 | |
| 219 | // Create the route out-of-band so the stack has no state of its own |
| 220 | // for it — exactly the "the route already exists" scenario. |
| 221 | const pre = yield* workers |
| 222 | .createRoute({ zoneId, pattern: PATTERN_ADOPT }) |
| 223 | .pipe( |
| 224 | Effect.retry({ |
| 225 | while: (e) => e._tag === "Forbidden", |
| 226 | schedule: forbiddenRetrySchedule, |
| 227 | times: 8, |
| 228 | }), |
| 229 | ); |
| 230 | expect(pre.id).toBeDefined(); |
| 231 | |
| 232 | // Without `adopt`: routes carry no ownership markers, so the engine |
| 233 | // cannot prove we created it and refuses to take it over. |
| 234 | const error = yield* stack |
| 235 | .deploy( |
| 236 | Effect.gen(function* () { |
| 237 | return yield* Cloudflare.Workers.WorkerRoute("AdoptedRoute", { |
| 238 | zoneId, |
| 239 | pattern: PATTERN_ADOPT, |
| 240 | }); |
| 241 | }), |
| 242 | ) |
| 243 | .pipe( |
| 244 | Effect.as(undefined), |
| 245 | Effect.catchCause((cause) => Effect.succeed(findOwnedError(cause))), |
| 246 | ); |
| 247 | expect(error).toBeInstanceOf(OwnedBySomeoneElse); |
| 248 | |
| 249 | // With `adopt(true)`: the engine takes over the pre-existing route |
| 250 | // (same physical id). |
| 251 | const adopted = yield* stack.deploy( |
| 252 | Effect.gen(function* () { |
| 253 | return yield* Cloudflare.Workers.WorkerRoute("AdoptedRoute", { |
| 254 | zoneId, |
| 255 | pattern: PATTERN_ADOPT, |
| 256 | }).pipe(adopt(true)); |
| 257 | }), |
| 258 | ); |
| 259 | |
| 260 | expect(adopted.routeId).toEqual(pre.id); |
| 261 | expect(adopted.pattern).toEqual(PATTERN_ADOPT); |
| 262 | |
| 263 | yield* stack.destroy(); |
| 264 | |
| 265 | const gone = yield* findRoute(zoneId, PATTERN_ADOPT); |
| 266 | expect(gone).toBeUndefined(); |
| 267 | }).pipe(logLevel), |
| 268 | { |
| 269 | tags: [ |
| 270 | "provider:cloudflare", |
| 271 | "provider:cloudflare:worker", |
| 272 | "provider:cloudflare:zone", |
| 273 | "live", |
| 274 | ], |
| 275 | timeout: 300_000, |
| 276 | }, |
| 277 | ); |
| 278 | |
| 279 | const PATTERN_LIST = `alchemy-route-list.${zoneName}/*`; |
| 280 | |
| 281 | // Canonical `list()` test (zone-scoped collection): routes have no account- |
| 282 | // wide enumeration API, so `list()` fans out over every zone via |
| 283 | // `listAllZones`, paginates `listRoutes` per zone, and hydrates each into the |
| 284 | // `read` Attributes shape. Deploy a real route and assert it appears in the |
| 285 | // exhaustively-enumerated result. |
| 286 | test.provider( |
| 287 | "list enumerates the deployed route across all zones", |
| 288 | (stack) => |
| 289 | Effect.gen(function* () { |
| 290 | const zoneId = yield* resolveZoneId; |
| 291 | |
| 292 | yield* stack.destroy(); |
| 293 | yield* purgeRoutes(zoneId, PATTERN_LIST); |
| 294 | |
| 295 | const deployed = yield* stack.deploy( |
| 296 | Effect.gen(function* () { |
| 297 | const worker = yield* Cloudflare.Worker("ListRouteWorker", { |
| 298 | main, |
| 299 | compatibility: { date: "2024-01-01" }, |
| 300 | }); |
| 301 | const route = yield* Cloudflare.Workers.WorkerRoute("ListRoute", { |
| 302 | zoneId, |
| 303 | pattern: PATTERN_LIST, |
| 304 | script: worker.workerName, |
| 305 | }).pipe(adopt(true)); |
| 306 | return { worker, route }; |
| 307 | }), |
| 308 | ); |
| 309 | |
| 310 | const provider = yield* Provider.findProvider( |
| 311 | Cloudflare.Workers.WorkerRoute, |
| 312 | ); |
| 313 | const all = yield* provider.list(); |
| 314 | |
| 315 | expect( |
| 316 | all.some( |
| 317 | (r) => |
| 318 | r.routeId === deployed.route.routeId && |
| 319 | r.zoneId === zoneId && |
| 320 | r.pattern === PATTERN_LIST, |
| 321 | ), |
| 322 | ).toBe(true); |
| 323 | |
| 324 | yield* stack.destroy(); |
| 325 | |
| 326 | const gone = yield* findRoute(zoneId, PATTERN_LIST); |
| 327 | expect(gone).toBeUndefined(); |
| 328 | }).pipe(logLevel), |
| 329 | { |
| 330 | tags: [ |
| 331 | "provider:cloudflare", |
| 332 | "provider:cloudflare:worker", |
| 333 | "provider:cloudflare:zone", |
| 334 | "live", |
| 335 | ], |
| 336 | timeout: 300_000, |
| 337 | }, |
| 338 | ); |
| 339 | |
| 340 | /** |
| 341 | * Pull the {@link OwnedBySomeoneElse} value out of a Cause regardless of |
| 342 | * whether the engine raised it as a typed failure or a defect. |
| 343 | */ |
| 344 | const findOwnedError = ( |
| 345 | cause: Cause.Cause<unknown>, |
| 346 | ): OwnedBySomeoneElse | undefined => |
| 347 | cause.reasons |
| 348 | .map((reason) => |
| 349 | Cause.isFailReason(reason) |
| 350 | ? reason.error |
| 351 | : Cause.isDieReason(reason) |
| 352 | ? reason.defect |
| 353 | : undefined, |
| 354 | ) |
| 355 | .find( |
| 356 | (value): value is OwnedBySomeoneElse => |
| 357 | value instanceof OwnedBySomeoneElse, |
| 358 | ); |