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 * as Redacted from "effect/Redacted";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Magic Transit is an entitlement-gated product — see GreTunnel.test.ts.
20// The probe test always runs and pins the typed gate tag; the lifecycle
21// test is opt-in for entitled accounts.
22const entitled = !!process.env.CLOUDFLARE_TEST_MAGIC_TRANSIT;
23
24const cfEndpoint = process.env.CLOUDFLARE_TEST_MT_CF_ENDPOINT ?? "203.0.113.1";
25
26const getTunnel = (accountId: string, ipsecTunnelId: string) =>
27 magicTransit.getIpsecTunnel({
28 accountId,
29 ipsecTunnelId,
30 xMagicNewHcTarget: true,
31 });
32
33// Poll until the tunnel is gone after destroy. Cloudflare answers GET for
34// a missing tunnel with the typed `IpsecTunnelNotFound` (code 1032).
35const expectGone = (accountId: string, ipsecTunnelId: string) =>
36 getTunnel(accountId, ipsecTunnelId).pipe(
37 Effect.flatMap(() => Effect.fail({ _tag: "TunnelNotDeleted" } as const)),
38 Effect.catchTag("IpsecTunnelNotFound", () => Effect.void),
39 Effect.retry({
40 while: (e) => e._tag === "TunnelNotDeleted",
41 schedule: Schedule.max([
42 Schedule.exponential("500 millis"),
43 Schedule.recurs(10),
44 ]),
45 }),
46 );
47
48test.provider(
49 "unentitled accounts surface the typed MagicTransitNotOnboarded error",
50 (stack) =>
51 Effect.gen(function* () {
52 const { accountId } = yield* yield* CloudflareEnvironment;
53
54 yield* stack.destroy();
55
56 const canList = yield* magicTransit
57 .listIpsecTunnels({ accountId, xMagicNewHcTarget: true })
58 .pipe(
59 Effect.as(true),
60 Effect.catchTag(["MagicTransitNotOnboarded", "Forbidden"], () =>
61 Effect.succeed(false),
62 ),
63 );
64 if (canList) {
65 yield* Effect.logInfo(
66 "account is Magic Transit-entitled; probe test is a no-op",
67 );
68 return;
69 }
70
71 // The typed tag — not UnknownCloudflareError, not a status check.
72 const error = yield* magicTransit
73 .listIpsecTunnels({ accountId, xMagicNewHcTarget: true })
74 .pipe(Effect.flip);
75 expect(["MagicTransitNotOnboarded", "Forbidden"]).toContain(error._tag);
76
77 const createError = yield* magicTransit
78 .createIpsecTunnel({
79 accountId,
80 xMagicNewHcTarget: true,
81 name: "alch-ipsec-probe",
82 cloudflareEndpoint: cfEndpoint,
83 interfaceAddress: "10.213.11.10/31",
84 })
85 .pipe(Effect.flip);
86 expect(["MagicTransitNotOnboarded", "Forbidden"]).toContain(
87 createError._tag,
88 );
89
90 yield* stack.destroy();
91 }).pipe(logLevel),
92 {
93 tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"],
94 timeout: 120_000,
95 },
96);
97
98test.provider(
99 "list enumerates account IPsec tunnels (read-only [] when unentitled)",
100 (stack) =>
101 Effect.gen(function* () {
102 yield* stack.destroy();
103
104 const provider = yield* Provider.findProvider(
105 Cloudflare.MagicTransit.IpsecTunnel,
106 );
107
108 if (!entitled) {
109 // Unentitled accounts can't enumerate tunnels — list() swallows the
110 // typed MagicTransitNotOnboarded/Forbidden gate and returns [].
111 const all = yield* provider.list();
112 expect(Array.isArray(all)).toBe(true);
113 return;
114 }
115
116 const tunnel = yield* stack.deploy(
117 Cloudflare.MagicTransit.IpsecTunnel("ListIpsec", {
118 name: "alch-ipsec-list1",
119 cloudflareEndpoint: cfEndpoint,
120 interfaceAddress: "10.213.12.10/31",
121 psk: Redacted.make("alchemy-test-psk-list"),
122 }),
123 );
124
125 const all = yield* provider.list();
126 expect(all.some((t) => t.tunnelId === tunnel.tunnelId)).toBe(true);
127 const found = all.find((t) => t.tunnelId === tunnel.tunnelId)!;
128 expect(found.name).toEqual("alch-ipsec-list1");
129 expect(found.accountId).toEqual(tunnel.accountId);
130 // PSK is write-only — list never reads it back.
131 expect(found.psk).toBeUndefined();
132
133 yield* stack.destroy();
134 yield* expectGone(tunnel.accountId, tunnel.tunnelId);
135 }).pipe(logLevel),
136 {
137 tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"],
138 timeout: 120_000,
139 },
140);
141
142test.provider.skipIf(!entitled)(
143 "creates an IPsec tunnel, updates mutable props in place, and destroys it",
144 (stack) =>
145 Effect.gen(function* () {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147
148 yield* stack.destroy();
149
150 const tunnel = yield* stack.deploy(
151 Cloudflare.MagicTransit.IpsecTunnel("Ipsec", {
152 name: "alch-ipsec-test1",
153 cloudflareEndpoint: cfEndpoint,
154 customerEndpoint: "198.51.100.20",
155 interfaceAddress: "10.213.11.10/31",
156 description: "alchemy ipsec tunnel test",
157 psk: Redacted.make("alchemy-test-psk-1"),
158 }),
159 );
160
161 expect(tunnel.tunnelId).toBeTruthy();
162 expect(tunnel.accountId).toEqual(accountId);
163 expect(tunnel.name).toEqual("alch-ipsec-test1");
164 expect(tunnel.cloudflareEndpoint).toEqual(cfEndpoint);
165 // The PSK is write-only — it is carried in state, never read back.
166 expect(tunnel.psk && Redacted.value(tunnel.psk)).toEqual(
167 "alchemy-test-psk-1",
168 );
169
170 // Out-of-band verification via the distilled API.
171 const live = yield* getTunnel(accountId, tunnel.tunnelId);
172 expect(live.ipsecTunnel?.name).toEqual("alch-ipsec-test1");
173 expect(live.ipsecTunnel?.description).toEqual(
174 "alchemy ipsec tunnel test",
175 );
176
177 // Update mutable props in place — same tunnelId.
178 const updated = yield* stack.deploy(
179 Cloudflare.MagicTransit.IpsecTunnel("Ipsec", {
180 name: "alch-ipsec-test1",
181 cloudflareEndpoint: cfEndpoint,
182 customerEndpoint: "198.51.100.20",
183 interfaceAddress: "10.213.11.10/31",
184 description: "alchemy ipsec tunnel test v2",
185 replayProtection: true,
186 psk: Redacted.make("alchemy-test-psk-1"),
187 }),
188 );
189
190 expect(updated.tunnelId).toEqual(tunnel.tunnelId);
191 expect(updated.description).toEqual("alchemy ipsec tunnel test v2");
192 expect(updated.replayProtection).toEqual(true);
193
194 // The tunnel name is unique routing identity — changing it replaces.
195 const replaced = yield* stack.deploy(
196 Cloudflare.MagicTransit.IpsecTunnel("Ipsec", {
197 name: "alch-ipsec-test2",
198 cloudflareEndpoint: cfEndpoint,
199 interfaceAddress: "10.213.11.10/31",
200 psk: Redacted.make("alchemy-test-psk-1"),
201 }),
202 );
203
204 expect(replaced.tunnelId).not.toEqual(tunnel.tunnelId);
205 expect(replaced.name).toEqual("alch-ipsec-test2");
206 yield* expectGone(accountId, tunnel.tunnelId);
207
208 yield* stack.destroy();
209
210 yield* expectGone(accountId, replaced.tunnelId);
211 }).pipe(logLevel),
212 {
213 tags: ["provider:cloudflare", "provider:cloudflare:magictransit", "live"],
214 timeout: 120_000,
215 },
216);