1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: Cloudflare.providers() });
12
13const 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.
21const entitled = !!process.env.CLOUDFLARE_TEST_MAGIC_TRANSIT;
22
23const cfEndpoint = process.env.CLOUDFLARE_TEST_MT_CF_ENDPOINT ?? "203.0.113.1";
24
25const 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).
30const 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
43test.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 `[]`).
93test.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
114test.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
160test.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);