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 Stream from "effect/Stream";
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 identifiers — the portal id is the API identity, so reruns
19// converge on the same portal instead of leaking. The two cases run
20// concurrently, so they MUST use distinct portal ids AND distinct hostnames:
21// a hostname is a per-account unique constraint, so a shared hostname makes the
22// two deploys race on "This hostname is already in use in a different MCP
23// Portal".
24const PORTAL_ID = "alchemy-test-mcp-portal";
25const HOSTNAME = "alchemy-test-mcp.alchemy-test-2.us";
26const LIST_PORTAL_ID = "alchemy-test-mcp-portal-list";
27const LIST_HOSTNAME = "alchemy-test-mcp-list.alchemy-test-2.us";
28
29// Read a portal out-of-band, mapping "gone" to undefined.
30const getLivePortal = (accountId: string, id: string) =>
31 zeroTrust
32 .readAccessAiControlMcpPortal({ accountId, id })
33 .pipe(
34 Effect.catchTag("McpPortalNotFound", () => Effect.succeed(undefined)),
35 );
36
37// Delete every portal currently occupying `hostname`, regardless of its portal
38// id. A per-stack `destroy()` only removes the portal tracked under this
39// stack's logical id, so an orphan left by a previously crashed run keeps the
40// hostname (a per-account unique constraint) reserved and the next create
41// fails with `McpPortalHostnameInUse`. Sweeping by hostname makes the suite
42// self-healing and leaves no dangling resources behind.
43const cleanupPortalsByHostname = (accountId: string, hostname: string) =>
44 zeroTrust.listAccessAiControlMcpPortals.pages({ accountId }).pipe(
45 Stream.runCollect,
46 Effect.map((chunk) =>
47 Array.from(chunk).flatMap((page) =>
48 (page.result ?? []).filter((p) => p.hostname === hostname),
49 ),
50 ),
51 Effect.flatMap((portals) =>
52 Effect.forEach(portals, (p) =>
53 zeroTrust
54 .deleteAccessAiControlMcpPortal({ accountId, id: p.id })
55 .pipe(Effect.catchTag("McpPortalNotFound", () => Effect.void)),
56 ),
57 ),
58 Effect.catchTag("Forbidden", () => Effect.void),
59 );
60
61test.provider(
62 "create, update in place, and destroy an MCP portal",
63 (stack) =>
64 Effect.gen(function* () {
65 const { accountId } = yield* yield* CloudflareEnvironment;
66
67 yield* stack.destroy();
68 yield* cleanupPortalsByHostname(accountId, HOSTNAME);
69
70 const portal = yield* stack.deploy(
71 Effect.gen(function* () {
72 return yield* Cloudflare.Access.McpPortal("AiPortal", {
73 portalId: PORTAL_ID,
74 hostname: HOSTNAME,
75 description: "alchemy mcp portal v1",
76 });
77 }),
78 );
79
80 expect(portal.portalId).toEqual(PORTAL_ID);
81 expect(portal.accountId).toEqual(accountId);
82 expect(portal.hostname).toEqual(HOSTNAME);
83 expect(portal.name).toEqual(PORTAL_ID);
84 expect(portal.description).toEqual("alchemy mcp portal v1");
85 // Observed server-side default (no allowCodeMode requested).
86 expect(portal.allowCodeMode).toEqual(true);
87 expect(portal.secureWebGateway).toEqual(false);
88
89 const live = yield* getLivePortal(accountId, PORTAL_ID);
90 expect(live?.id).toEqual(PORTAL_ID);
91 expect(live?.hostname).toEqual(HOSTNAME);
92
93 // Name + description update converges in place — same portal id.
94 const updated = yield* stack.deploy(
95 Effect.gen(function* () {
96 return yield* Cloudflare.Access.McpPortal("AiPortal", {
97 portalId: PORTAL_ID,
98 name: "Alchemy AI Portal",
99 hostname: HOSTNAME,
100 description: "alchemy mcp portal v2",
101 secureWebGateway: true,
102 });
103 }),
104 );
105 expect(updated.portalId).toEqual(PORTAL_ID);
106 expect(updated.name).toEqual("Alchemy AI Portal");
107 expect(updated.description).toEqual("alchemy mcp portal v2");
108 expect(updated.secureWebGateway).toEqual(true);
109
110 const liveUpdated = yield* getLivePortal(accountId, PORTAL_ID);
111 expect(liveUpdated?.name).toEqual("Alchemy AI Portal");
112 expect(liveUpdated?.secureWebGateway).toEqual(true);
113
114 // No-op redeploy keeps the same portal without drift.
115 const noop = yield* stack.deploy(
116 Effect.gen(function* () {
117 return yield* Cloudflare.Access.McpPortal("AiPortal", {
118 portalId: PORTAL_ID,
119 name: "Alchemy AI Portal",
120 hostname: HOSTNAME,
121 description: "alchemy mcp portal v2",
122 secureWebGateway: true,
123 });
124 }),
125 );
126 expect(noop.portalId).toEqual(PORTAL_ID);
127
128 yield* stack.destroy();
129
130 const afterDestroy = yield* getLivePortal(accountId, PORTAL_ID);
131 expect(afterDestroy).toBeUndefined();
132 }).pipe(logLevel),
133 {
134 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
135 timeout: 90_000,
136 },
137);
138
139// Canonical `list()` test (account collection): deploy a portal, then resolve
140// the provider via the typed helper and assert the deployed portal appears in
141// the exhaustively-paginated result.
142test.provider(
143 "list enumerates the deployed MCP portal",
144 (stack) =>
145 Effect.gen(function* () {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147
148 yield* stack.destroy();
149 yield* cleanupPortalsByHostname(accountId, LIST_HOSTNAME);
150
151 const deployed = yield* stack.deploy(
152 Effect.gen(function* () {
153 return yield* Cloudflare.Access.McpPortal("ListPortal", {
154 portalId: LIST_PORTAL_ID,
155 hostname: LIST_HOSTNAME,
156 });
157 }),
158 );
159
160 const provider = yield* Provider.findProvider(
161 Cloudflare.Access.McpPortal,
162 );
163 const all = yield* provider.list();
164
165 expect(all.some((p) => p.portalId === deployed.portalId)).toBe(true);
166
167 yield* stack.destroy();
168
169 const afterDestroy = yield* getLivePortal(accountId, LIST_PORTAL_ID);
170 expect(afterDestroy).toBeUndefined();
171 }).pipe(logLevel),
172 {
173 tags: ["provider:cloudflare", "provider:cloudflare:access", "live"],
174 timeout: 90_000,
175 },
176);