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";
10import * as Stream from "effect/Stream";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Ride out 403 blips (`Forbidden`) while the harness-minted token
20// propagates across Cloudflare's edge.
21const getEndpoint = (accountId: string, proxyEndpointId: string) =>
22 zeroTrust.getGatewayProxyEndpoint({ accountId, proxyEndpointId }).pipe(
23 Effect.retry({
24 while: (e) => e._tag === "Forbidden",
25 schedule: Schedule.exponential("500 millis"),
26 times: 8,
27 }),
28 );
29
30// A deleted endpoint surfaces as `ProxyEndpointNotFound` (code 2002).
31const expectGone = (accountId: string, proxyEndpointId: string) =>
32 getEndpoint(accountId, proxyEndpointId).pipe(
33 Effect.flatMap(() => Effect.fail({ _tag: "EndpointNotDeleted" } as const)),
34 Effect.catchTag("ProxyEndpointNotFound", () => Effect.void),
35 Effect.retry({
36 while: (e) => e._tag === "EndpointNotDeleted",
37 schedule: Schedule.max([
38 Schedule.exponential("500 millis"),
39 Schedule.recurs(10),
40 ]),
41 }),
42 );
43
44test.provider(
45 "create, update, and destroy an identity-kind proxy endpoint",
46 (stack) =>
47 Effect.gen(function* () {
48 const { accountId } = yield* yield* CloudflareEnvironment;
49
50 yield* stack.destroy();
51
52 const endpoint = yield* stack.deploy(
53 Cloudflare.Gateway.ProxyEndpoint("IdentityProxy", {
54 name: "alchemy-zt-proxy-identity",
55 kind: "identity",
56 }),
57 );
58
59 expect(endpoint.proxyEndpointId).toBeTruthy();
60 expect(endpoint.accountId).toEqual(accountId);
61 expect(endpoint.kind).toEqual("identity");
62 // Cloudflare assigns a stable PAC/proxy subdomain.
63 expect(endpoint.subdomain).toBeTruthy();
64
65 const live = yield* getEndpoint(accountId, endpoint.proxyEndpointId);
66 expect(live.name).toEqual("alchemy-zt-proxy-identity");
67 expect(live.subdomain).toEqual(endpoint.subdomain);
68
69 // Rename in place — same id, same subdomain.
70 const updated = yield* stack.deploy(
71 Cloudflare.Gateway.ProxyEndpoint("IdentityProxy", {
72 name: "alchemy-zt-proxy-identity-v2",
73 kind: "identity",
74 }),
75 );
76 expect(updated.proxyEndpointId).toEqual(endpoint.proxyEndpointId);
77 expect(updated.subdomain).toEqual(endpoint.subdomain);
78 expect(updated.name).toEqual("alchemy-zt-proxy-identity-v2");
79
80 const liveAfter = yield* getEndpoint(accountId, endpoint.proxyEndpointId);
81 expect(liveAfter.name).toEqual("alchemy-zt-proxy-identity-v2");
82
83 yield* stack.destroy();
84 yield* expectGone(accountId, endpoint.proxyEndpointId);
85 }).pipe(logLevel),
86 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
87);
88
89// IP-based proxy endpoints are an Enterprise entitlement. On the testing
90// account, creation fails with the typed `IpProxyEndpointsRequireEnterprise`
91// error (Cloudflare code 2009: "IP based proxy endpoints are limited to
92// enterprise accounts."). Exercise the typed error surface directly; if the
93// account ever becomes Enterprise the create succeeds and we clean up.
94test.provider(
95 "ip-kind endpoints surface the typed entitlement error",
96 () =>
97 Effect.gen(function* () {
98 const { accountId } = yield* yield* CloudflareEnvironment;
99
100 const outcome = yield* zeroTrust
101 .createGatewayProxyEndpoint({
102 accountId,
103 name: "alchemy-zt-proxy-ip",
104 kind: "ip",
105 // RFC 5737 documentation range.
106 ips: ["203.0.113.1/32"],
107 })
108 .pipe(
109 Effect.retry({
110 while: (e) => e._tag === "Forbidden",
111 schedule: Schedule.exponential("500 millis"),
112 times: 8,
113 }),
114 Effect.map((created) => ({ _tag: "Created" as const, created })),
115 Effect.catchTag("IpProxyEndpointsRequireEnterprise", (e) =>
116 Effect.succeed({ _tag: "Entitlement" as const, error: e }),
117 ),
118 );
119
120 if (outcome._tag === "Created") {
121 // Enterprise account — clean up the endpoint we just made.
122 yield* zeroTrust
123 .deleteGatewayProxyEndpoint({
124 accountId,
125 proxyEndpointId: outcome.created.id ?? "",
126 })
127 .pipe(Effect.catchTag("ProxyEndpointNotFound", () => Effect.void));
128 expect(outcome.created.name).toEqual("alchemy-zt-proxy-ip");
129 } else {
130 expect(outcome.error._tag).toEqual("IpProxyEndpointsRequireEnterprise");
131 }
132 }).pipe(logLevel),
133 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
134);
135
136test.provider(
137 "list enumerates the deployed proxy endpoint",
138 (stack) =>
139 Effect.gen(function* () {
140 const { accountId } = yield* yield* CloudflareEnvironment;
141
142 yield* stack.destroy();
143
144 // Self-heal: remove any same-named endpoint left orphaned in the cloud by a
145 // previously-interrupted run so the deploy below doesn't trip the
146 // "OwnedBySomeoneElse" adoption guard.
147 const orphans = yield* zeroTrust.listGatewayProxyEndpoints
148 .items({ accountId })
149 .pipe(
150 Stream.filter((e) => e.name === "alchemy-zt-proxy-list"),
151 Stream.runCollect,
152 Effect.map((chunk) => Array.from(chunk)),
153 );
154 yield* Effect.forEach(orphans, (o) =>
155 zeroTrust
156 .deleteGatewayProxyEndpoint({
157 accountId,
158 proxyEndpointId: o.id ?? "",
159 })
160 .pipe(Effect.catchTag("ProxyEndpointNotFound", () => Effect.void)),
161 );
162
163 // identity-kind endpoints work on all Zero Trust plans, so this deploy is
164 // not entitlement-gated (unlike ip-kind, which needs Enterprise).
165 const endpoint = yield* stack.deploy(
166 Cloudflare.Gateway.ProxyEndpoint("ListProxy", {
167 name: "alchemy-zt-proxy-list",
168 kind: "identity",
169 }),
170 );
171
172 const provider = yield* Provider.findProvider(
173 Cloudflare.Gateway.ProxyEndpoint,
174 );
175 const all = yield* provider.list();
176
177 const match = all.find(
178 (x) => x.proxyEndpointId === endpoint.proxyEndpointId,
179 );
180 expect(match).toBeDefined();
181 expect(match?.accountId).toEqual(accountId);
182 expect(match?.name).toEqual("alchemy-zt-proxy-list");
183 expect(match?.kind).toEqual("identity");
184
185 yield* stack.destroy();
186 }).pipe(logLevel),
187 { tags: ["provider:cloudflare", "provider:cloudflare:gateway", "live"] },
188);