| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | |
| 11 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 12 | |
| 13 | const logLevel = Effect.provideService( |
| 14 | MinimumLogLevel, |
| 15 | process.env.DEBUG ? "Debug" : "Info", |
| 16 | ); |
| 17 | |
| 18 | // Magic Transit is an entitlement-gated product — see GreTunnel.test.ts. |
| 19 | // The probe test always runs and pins the typed gate tag; the lifecycle |
| 20 | // test is opt-in for entitled accounts. |
| 21 | const entitled = !!process.env.CLOUDFLARE_TEST_MAGIC_TRANSIT; |
| 22 | |
| 23 | const cfEndpoint = process.env.CLOUDFLARE_TEST_MT_CF_ENDPOINT ?? "203.0.113.1"; |
| 24 | |
| 25 | const getRoute = (accountId: string, routeId: string) => |
| 26 | magicTransit.getRoute({ accountId, routeId }); |
| 27 | |
| 28 | // Poll until the route is gone after destroy. Cloudflare answers GET for |
| 29 | // a missing route with the typed `RouteNotFound` (code 1020). |
| 30 | const expectGone = (accountId: string, routeId: string) => |
| 31 | getRoute(accountId, routeId).pipe( |
| 32 | Effect.flatMap(() => Effect.fail({ _tag: "RouteNotDeleted" } as const)), |
| 33 | Effect.catchTag("RouteNotFound", () => Effect.void), |
| 34 | Effect.retry({ |
| 35 | while: (e) => e._tag === "RouteNotDeleted", |
| 36 | schedule: Schedule.max([ |
| 37 | Schedule.exponential("500 millis"), |
| 38 | Schedule.recurs(10), |
| 39 | ]), |
| 40 | }), |
| 41 | ); |
| 42 | |
| 43 | test.provider( |
| 44 | "unentitled accounts surface the typed MagicTransitNotOnboarded error", |
| 45 | (stack) => |
| 46 | Effect.gen(function* () { |
| 47 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 48 | |
| 49 | yield* stack.destroy(); |
| 50 | |
| 51 | const canList = yield* magicTransit.listRoutes({ accountId }).pipe( |
| 52 | Effect.as(true), |
| 53 | Effect.catchTag(["MagicTransitNotOnboarded", "Forbidden"], () => |
| 54 | Effect.succeed(false), |
| 55 | ), |
| 56 | ); |
| 57 | if (canList) { |
| 58 | yield* Effect.logInfo( |
| 59 | "account is Magic Transit-entitled; probe test is a no-op", |
| 60 | ); |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | // The typed tag — not UnknownCloudflareError, not a status check. |
| 65 | const error = yield* magicTransit |
| 66 | .listRoutes({ accountId }) |
| 67 | .pipe(Effect.flip); |
| 68 | expect(["MagicTransitNotOnboarded", "Forbidden"]).toContain(error._tag); |
| 69 | |
| 70 | const createError = yield* magicTransit |
| 71 | .createRoute({ |
| 72 | accountId, |
| 73 | prefix: "10.100.0.0/24", |
| 74 | nexthop: "10.213.10.11", |
| 75 | priority: 100, |
| 76 | }) |
| 77 | .pipe(Effect.flip); |
| 78 | expect(["MagicTransitNotOnboarded", "Forbidden"]).toContain( |
| 79 | createError._tag, |
| 80 | ); |
| 81 | |
| 82 | yield* stack.destroy(); |
| 83 | }).pipe(logLevel), |
| 84 | { |
| 85 | tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"], |
| 86 | timeout: 120_000, |
| 87 | }, |
| 88 | ); |
| 89 | |
| 90 | // Read-only: list() must resolve via the typed provider and return a |
| 91 | // well-typed array even on unentitled accounts (the account-scoped |
| 92 | // `MagicTransitNotOnboarded` gate is mapped to `[]`). |
| 93 | test.provider( |
| 94 | "list returns a well-typed array of routes", |
| 95 | (stack) => |
| 96 | Effect.gen(function* () { |
| 97 | yield* stack.destroy(); |
| 98 | |
| 99 | const provider = yield* Provider.findProvider( |
| 100 | Cloudflare.MagicTransit.MagicStaticRoute, |
| 101 | ); |
| 102 | const all = yield* provider.list(); |
| 103 | expect(Array.isArray(all)).toBe(true); |
| 104 | for (const route of all) { |
| 105 | expect(typeof route.routeId).toBe("string"); |
| 106 | expect(typeof route.accountId).toBe("string"); |
| 107 | } |
| 108 | |
| 109 | yield* stack.destroy(); |
| 110 | }).pipe(logLevel), |
| 111 | { tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"] }, |
| 112 | ); |
| 113 | |
| 114 | test.provider.skipIf(!entitled)( |
| 115 | "list enumerates the deployed static route", |
| 116 | (stack) => |
| 117 | Effect.gen(function* () { |
| 118 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 119 | |
| 120 | yield* stack.destroy(); |
| 121 | |
| 122 | yield* stack.deploy( |
| 123 | Cloudflare.MagicTransit.GreTunnel("ListRouteGre", { |
| 124 | name: "alch-gre-listroute1", |
| 125 | cloudflareGreEndpoint: cfEndpoint, |
| 126 | customerGreEndpoint: "198.51.100.31", |
| 127 | interfaceAddress: "10.213.14.10/31", |
| 128 | }), |
| 129 | ); |
| 130 | |
| 131 | const route = yield* stack.deploy( |
| 132 | Cloudflare.MagicTransit.MagicStaticRoute("ListRoute", { |
| 133 | prefix: "10.114.0.0/24", |
| 134 | nexthop: "10.213.14.11", |
| 135 | priority: 100, |
| 136 | }), |
| 137 | ); |
| 138 | |
| 139 | const provider = yield* Provider.findProvider( |
| 140 | Cloudflare.MagicTransit.MagicStaticRoute, |
| 141 | ); |
| 142 | const all = yield* provider.list(); |
| 143 | |
| 144 | const found = all.find((r) => r.routeId === route.routeId); |
| 145 | expect(found).toBeDefined(); |
| 146 | expect(found?.accountId).toEqual(accountId); |
| 147 | expect(found?.prefix).toEqual("10.114.0.0/24"); |
| 148 | expect(found?.nexthop).toEqual("10.213.14.11"); |
| 149 | |
| 150 | yield* stack.destroy(); |
| 151 | |
| 152 | yield* expectGone(accountId, route.routeId); |
| 153 | }).pipe(logLevel), |
| 154 | { |
| 155 | tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"], |
| 156 | timeout: 120_000, |
| 157 | }, |
| 158 | ); |
| 159 | |
| 160 | test.provider.skipIf(!entitled)( |
| 161 | "routes a prefix over a GRE tunnel, updates in place, and destroys", |
| 162 | (stack) => |
| 163 | Effect.gen(function* () { |
| 164 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 165 | |
| 166 | yield* stack.destroy(); |
| 167 | |
| 168 | // A static route's nexthop must live on a Magic tunnel interface. |
| 169 | const tunnel = yield* stack.deploy( |
| 170 | Cloudflare.MagicTransit.GreTunnel("RouteGre", { |
| 171 | name: "alch-gre-route1", |
| 172 | cloudflareGreEndpoint: cfEndpoint, |
| 173 | customerGreEndpoint: "198.51.100.30", |
| 174 | interfaceAddress: "10.213.12.10/31", |
| 175 | }), |
| 176 | ); |
| 177 | |
| 178 | const route = yield* stack.deploy( |
| 179 | Cloudflare.MagicTransit.MagicStaticRoute("Route", { |
| 180 | prefix: "10.112.0.0/24", |
| 181 | nexthop: "10.213.12.11", |
| 182 | priority: 100, |
| 183 | description: "alchemy static route test", |
| 184 | }), |
| 185 | ); |
| 186 | |
| 187 | expect(route.routeId).toBeTruthy(); |
| 188 | expect(route.accountId).toEqual(accountId); |
| 189 | expect(route.prefix).toEqual("10.112.0.0/24"); |
| 190 | expect(route.nexthop).toEqual("10.213.12.11"); |
| 191 | expect(route.priority).toEqual(100); |
| 192 | |
| 193 | // Out-of-band verification via the distilled API. |
| 194 | const live = yield* getRoute(accountId, route.routeId); |
| 195 | expect(live.route?.prefix).toEqual("10.112.0.0/24"); |
| 196 | expect(live.route?.description).toEqual("alchemy static route test"); |
| 197 | |
| 198 | // Update mutable props in place — same routeId. |
| 199 | const updated = yield* stack.deploy( |
| 200 | Cloudflare.MagicTransit.MagicStaticRoute("Route", { |
| 201 | prefix: "10.112.0.0/24", |
| 202 | nexthop: "10.213.12.11", |
| 203 | priority: 150, |
| 204 | description: "alchemy static route test v2", |
| 205 | }), |
| 206 | ); |
| 207 | |
| 208 | expect(updated.routeId).toEqual(route.routeId); |
| 209 | expect(updated.priority).toEqual(150); |
| 210 | expect(updated.description).toEqual("alchemy static route test v2"); |
| 211 | |
| 212 | yield* stack.destroy(); |
| 213 | |
| 214 | yield* expectGone(accountId, route.routeId); |
| 215 | // The tunnel is destroyed alongside the route. |
| 216 | const tunnelGone = yield* magicTransit |
| 217 | .getGreTunnel({ |
| 218 | accountId, |
| 219 | greTunnelId: tunnel.tunnelId, |
| 220 | xMagicNewHcTarget: true, |
| 221 | }) |
| 222 | .pipe( |
| 223 | Effect.as(false), |
| 224 | Effect.catchTag("GreTunnelNotFound", () => Effect.succeed(true)), |
| 225 | ); |
| 226 | expect(tunnelGone).toBe(true); |
| 227 | }).pipe(logLevel), |
| 228 | { |
| 229 | tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"], |
| 230 | timeout: 120_000, |
| 231 | }, |
| 232 | ); |