1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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// Deterministic hostnames on the testing zone — hostnames are unique per
19// account, so reruns converge on the same route instead of leaking. The two
20// cases run CONCURRENTLY, so each owns a DISTINCT hostname (and tunnel):
21// sharing one hostname makes the two deploys race to route it to their own
22// tunnel and the loser fails with `HostnameRouteAlreadyRouted`.
23const HOSTNAME = "alchemy-test-hr.alchemy-test-2.us";
24const LIST_HOSTNAME = "alchemy-test-hr-list.alchemy-test-2.us";
25
26// Read a hostname route out-of-band, mapping "gone" (404 or a tombstoned
27// deletedAt) to undefined.
28const getLiveRoute = (accountId: string, hostnameRouteId: string) =>
29 zeroTrust.getNetworkHostnameRoute({ accountId, hostnameRouteId }).pipe(
30 Effect.map((r) => (r.deletedAt != null ? undefined : r)),
31 Effect.catchTag("HostnameRouteNotFound", () => Effect.succeed(undefined)),
32 );
33
34// Delete every live route currently routing `hostname`, regardless of which
35// tunnel it points to. A per-stack `destroy()` only removes the route tracked
36// under this stack's logical id, so a leftover from a previously crashed run
37// (possibly pointing at a now-deleted tunnel) keeps the hostname reserved and
38// the next create fails with `HostnameRouteAlreadyRouted`. Sweeping by hostname
39// makes the suite self-healing and leaves no dangling routes behind.
40const cleanupRoutesByHostname = (accountId: string, hostname: string) =>
41 zeroTrust.listNetworkHostnameRoutes({ accountId }).pipe(
42 Effect.map((list) =>
43 (list.result ?? []).filter(
44 (r) => r.hostname === hostname && r.id != null && r.deletedAt == null,
45 ),
46 ),
47 Effect.flatMap((routes) =>
48 Effect.forEach(routes, (r) =>
49 zeroTrust
50 .deleteNetworkHostnameRoute({ accountId, hostnameRouteId: r.id! })
51 .pipe(Effect.catchTag("HostnameRouteNotFound", () => Effect.void)),
52 ),
53 ),
54 Effect.catchTag("Forbidden", () => Effect.void),
55 );
56
57test.provider(
58 "create, update comment in place, and destroy a hostname route",
59 (stack) =>
60 Effect.gen(function* () {
61 const { accountId } = yield* yield* CloudflareEnvironment;
62
63 yield* stack.destroy();
64 yield* cleanupRoutesByHostname(accountId, HOSTNAME);
65
66 const initial = yield* stack.deploy(
67 Effect.gen(function* () {
68 const tunnel = yield* Cloudflare.Tunnel.Tunnel("HrTunnel", {
69 adopt: true,
70 });
71 const route = yield* Cloudflare.Tunnel.HostnameRoute("AppRoute", {
72 hostname: HOSTNAME,
73 tunnelId: tunnel.tunnelId,
74 comment: "v1",
75 });
76 return { tunnel, route };
77 }),
78 );
79
80 expect(initial.route.hostnameRouteId).toBeDefined();
81 expect(initial.route.accountId).toEqual(accountId);
82 expect(initial.route.hostname).toEqual(HOSTNAME);
83 expect(initial.route.tunnelId).toEqual(initial.tunnel.tunnelId);
84 expect(initial.route.comment).toEqual("v1");
85
86 const live = yield* getLiveRoute(
87 accountId,
88 initial.route.hostnameRouteId,
89 );
90 expect(live?.hostname).toEqual(HOSTNAME);
91 expect(live?.comment).toEqual("v1");
92
93 // Comment update converges in place — same route id.
94 const updated = yield* stack.deploy(
95 Effect.gen(function* () {
96 const tunnel = yield* Cloudflare.Tunnel.Tunnel("HrTunnel", {
97 adopt: true,
98 });
99 const route = yield* Cloudflare.Tunnel.HostnameRoute("AppRoute", {
100 hostname: HOSTNAME,
101 tunnelId: tunnel.tunnelId,
102 comment: "v2",
103 });
104 return { route };
105 }),
106 );
107 expect(updated.route.hostnameRouteId).toEqual(
108 initial.route.hostnameRouteId,
109 );
110 expect(updated.route.comment).toEqual("v2");
111
112 const liveUpdated = yield* getLiveRoute(
113 accountId,
114 initial.route.hostnameRouteId,
115 );
116 expect(liveUpdated?.comment).toEqual("v2");
117
118 yield* stack.destroy();
119
120 // Cloudflare tombstones hostname routes; gone = 404 or deletedAt set.
121 const afterDestroy = yield* getLiveRoute(
122 accountId,
123 initial.route.hostnameRouteId,
124 );
125 expect(afterDestroy).toBeUndefined();
126 }).pipe(logLevel),
127 {
128 tags: ["provider:cloudflare", "provider:cloudflare:tunnel", "live"],
129 timeout: 90_000,
130 },
131);
132
133test.provider(
134 "list enumerates the deployed hostname route",
135 (stack) =>
136 Effect.gen(function* () {
137 const { accountId } = yield* yield* CloudflareEnvironment;
138
139 yield* stack.destroy();
140 yield* cleanupRoutesByHostname(accountId, LIST_HOSTNAME);
141
142 const deployed = yield* stack.deploy(
143 Effect.gen(function* () {
144 const tunnel = yield* Cloudflare.Tunnel.Tunnel("HrListTunnel", {
145 adopt: true,
146 });
147 const route = yield* Cloudflare.Tunnel.HostnameRoute("ListRoute", {
148 hostname: LIST_HOSTNAME,
149 tunnelId: tunnel.tunnelId,
150 comment: "list",
151 });
152 return { route };
153 }),
154 );
155
156 const provider = yield* Provider.findProvider(
157 Cloudflare.Tunnel.HostnameRoute,
158 );
159
160 // The account-wide list is eventually consistent right after a create —
161 // poll until the freshly-deployed route is enumerated.
162 const all = yield* provider.list().pipe(
163 Effect.repeat({
164 schedule: Schedule.spaced("1 second"),
165 until: (routes) =>
166 routes.some(
167 (r) => r.hostnameRouteId === deployed.route.hostnameRouteId,
168 ),
169 times: 10,
170 }),
171 );
172
173 expect(
174 all.some((r) => r.hostnameRouteId === deployed.route.hostnameRouteId),
175 ).toBe(true);
176
177 yield* stack.destroy();
178
179 const afterDestroy = yield* getLiveRoute(
180 accountId,
181 deployed.route.hostnameRouteId,
182 );
183 expect(afterDestroy).toBeUndefined();
184 }).pipe(logLevel),
185 {
186 tags: ["provider:cloudflare", "provider:cloudflare:tunnel", "live"],
187 timeout: 90_000,
188 },
189);