1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Test from "@/Test/Alchemy";
4import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import OwnedAccessWorker from "./fixtures/access-owned-worker.ts";
12import SecondAccessWorker from "./fixtures/access-worker-b.ts";
13import AccessProtectedWorker, { App } from "./fixtures/access-worker.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22class NotYetProtected extends Data.TaggedError("NotYetProtected")<{
23 status: number;
24 location: string | null;
25 bodyExcerpt: string;
26}> {}
27
28/**
29 * Probe `url` without following redirects until Cloudflare Access
30 * intercepts it — a 302 to the team's `cloudflareaccess.com` login page.
31 * Freshly-deployed workers.dev URLs 404/530 briefly and then serve the
32 * open worker until the Access policy propagates, so every non-intercepted
33 * response is retried.
34 */
35const expectAccessLoginRedirect = (url: string) =>
36 Effect.gen(function* () {
37 const probe = Effect.tryPromise({
38 try: async (signal) => {
39 const res = await fetch(url, {
40 signal,
41 redirect: "manual",
42 cache: "no-store",
43 });
44 const location = res.headers.get("location");
45 if (
46 res.status === 302 &&
47 location !== null &&
48 location.includes("cloudflareaccess.com")
49 ) {
50 return location;
51 }
52 const body = await res.text();
53 throw new NotYetProtected({
54 status: res.status,
55 location,
56 bodyExcerpt: body.slice(0, 200),
57 });
58 },
59 catch: (e) =>
60 e instanceof NotYetProtected
61 ? e
62 : new NotYetProtected({
63 status: 0,
64 location: null,
65 bodyExcerpt: e instanceof Error ? e.message : String(e),
66 }),
67 });
68 return yield* probe.pipe(
69 Effect.retry({
70 while: (e) => e._tag === "NotYetProtected",
71 schedule: Schedule.max([
72 Schedule.min([
73 Schedule.exponential("1 second", 1.5),
74 Schedule.spaced("5 seconds"),
75 ]),
76 Schedule.recurs(24),
77 ]),
78 }),
79 );
80 });
81
82/** Structural view of the live application for out-of-band assertions. */
83interface LiveApp {
84 destinations?: ReadonlyArray<{
85 type?: string | null;
86 workerId?: string | null;
87 }> | null;
88 policies?: ReadonlyArray<{
89 id?: string | null;
90 decision?: string | null;
91 reusable?: boolean | null;
92 }> | null;
93}
94
95test.provider(
96 "the access prop enrolls a Worker into an Access application with inline policies",
97 (stack) =>
98 Effect.gen(function* () {
99 const { accountId } = yield* yield* CloudflareEnvironment;
100
101 yield* stack.destroy();
102
103 const deployStack = Effect.gen(function* () {
104 const worker = yield* AccessProtectedWorker;
105 const app = yield* App;
106 return { worker, app };
107 });
108
109 const { worker, app } = yield* stack.deploy(deployStack);
110
111 // The application carries the inline (application-owned) policy.
112 expect(app.applicationId).toBeDefined();
113 expect(app.aud.length).toBeGreaterThan(0);
114 const live = (yield* zeroTrust.getAccessApplicationForAccount({
115 accountId,
116 appId: app.applicationId,
117 })) as unknown as LiveApp;
118 expect(live.policies?.length).toBe(1);
119 expect(live.policies![0].decision).toBe("allow");
120 expect(live.policies![0].reusable).toBe(false);
121 const inlinePolicyId = live.policies![0].id;
122 expect(inlinePolicyId).toBeDefined();
123
124 // The Worker enrolled itself: worker + preview destinations keyed by
125 // its immutable script id, contributed through the application's
126 // binding contract.
127 expect(worker.workerId).toBeDefined();
128 expect(app.destinations).toEqual(
129 expect.arrayContaining([
130 expect.objectContaining({
131 type: "worker",
132 workerId: worker.workerId,
133 }),
134 ]),
135 );
136 expect(live.destinations).toEqual(
137 expect.arrayContaining([
138 expect.objectContaining({
139 type: "worker",
140 workerId: worker.workerId,
141 }),
142 expect.objectContaining({
143 type: "preview_worker",
144 workerId: worker.workerId,
145 }),
146 ]),
147 );
148
149 // Unauthenticated requests are redirected to the Access login page
150 // instead of reaching the worker.
151 const location = yield* expectAccessLoginRedirect(worker.url!);
152 expect(location).toContain("cloudflareaccess.com");
153
154 // Idempotence: a second deploy must not churn the inline policy (an
155 // id-less inline item in an update PUT would mint a fresh policy) or
156 // duplicate the destinations.
157 yield* stack.deploy(deployStack);
158 const liveAfter = (yield* zeroTrust.getAccessApplicationForAccount({
159 accountId,
160 appId: app.applicationId,
161 })) as unknown as LiveApp;
162 expect(liveAfter.policies?.length).toBe(1);
163 expect(liveAfter.policies![0].id).toBe(inlinePolicyId);
164 expect(
165 (liveAfter.destinations ?? []).filter(
166 (d) => d.workerId === worker.workerId,
167 ),
168 ).toHaveLength(2);
169
170 yield* stack.destroy();
171
172 // The application (and its inline policy with it) is gone.
173 const gone = yield* zeroTrust
174 .getAccessApplicationForAccount({ accountId, appId: app.applicationId })
175 .pipe(
176 Effect.map(() => "still-exists" as const),
177 Effect.catchTag("AccessApplicationNotFound", () =>
178 Effect.succeed("gone" as const),
179 ),
180 );
181 expect(gone).toBe("gone");
182 }).pipe(logLevel),
183 {
184 tags: [
185 "provider:cloudflare",
186 "provider:cloudflare:access",
187 "provider:cloudflare:worker",
188 "live",
189 ],
190 timeout: 300_000,
191 },
192);
193
194/** Find a live application by its exact display name, or undefined. */
195const findAppByName = (accountId: string, name: string) =>
196 zeroTrust.listAccessApplicationsForAccount.pages({ accountId }).pipe(
197 Stream.runCollect,
198 Effect.map((chunk) =>
199 Array.from(chunk)
200 .flatMap((page) => page.result ?? [])
201 .map(
202 (raw) => raw as unknown as LiveApp & { id?: string; name?: string },
203 )
204 .find((app) => app.name === name),
205 ),
206 );
207
208test.provider(
209 "access.policies declares a dedicated application owned by the Worker",
210 (stack) =>
211 Effect.gen(function* () {
212 const { accountId } = yield* yield* CloudflareEnvironment;
213
214 yield* stack.destroy();
215
216 const { worker } = yield* stack.deploy(
217 Effect.gen(function* () {
218 const worker = yield* OwnedAccessWorker;
219 return { worker };
220 }),
221 );
222
223 // The dedicated application exists with the inline policy and this
224 // Worker's destinations — declared entirely from the `access` prop.
225 const app = yield* findAppByName(
226 accountId,
227 "Access for alchemy owned-app test",
228 );
229 expect(app).toBeDefined();
230 expect(app!.policies?.length).toBe(1);
231 expect(app!.policies![0].decision).toBe("allow");
232 expect(app!.policies![0].reusable).toBe(false);
233 // `previews: false` — production destination only.
234 expect(app!.destinations).toEqual([
235 expect.objectContaining({ type: "worker", workerId: worker.workerId }),
236 ]);
237
238 // Unauthenticated requests are intercepted.
239 const location = yield* expectAccessLoginRedirect(worker.url!);
240 expect(location).toContain("cloudflareaccess.com");
241
242 yield* stack.destroy();
243
244 // The dedicated application is deleted with the Worker's stack.
245 const appAfter = yield* findAppByName(
246 accountId,
247 "Access for alchemy owned-app test",
248 );
249 expect(appAfter).toBeUndefined();
250 }).pipe(logLevel),
251 {
252 tags: [
253 "provider:cloudflare",
254 "provider:cloudflare:access",
255 "provider:cloudflare:worker",
256 "live",
257 ],
258 timeout: 300_000,
259 },
260);
261
262test.provider(
263 "a shared application merges enrollments from multiple Workers",
264 (stack) =>
265 Effect.gen(function* () {
266 const { accountId } = yield* yield* CloudflareEnvironment;
267
268 yield* stack.destroy();
269
270 // Two Workers enroll into the SAME application.
271 const both = Effect.gen(function* () {
272 const a = yield* AccessProtectedWorker;
273 const b = yield* SecondAccessWorker;
274 const app = yield* App;
275 return { a, b, app };
276 });
277 const { a, b, app } = yield* stack.deploy(both);
278
279 const live = (yield* zeroTrust.getAccessApplicationForAccount({
280 accountId,
281 appId: app.applicationId,
282 })) as unknown as LiveApp;
283 const workerDestinations = (live.destinations ?? []).filter(
284 (d) => d.workerId === a.workerId || d.workerId === b.workerId,
285 );
286 expect(workerDestinations).toEqual(
287 expect.arrayContaining([
288 expect.objectContaining({ type: "worker", workerId: a.workerId }),
289 expect.objectContaining({
290 type: "preview_worker",
291 workerId: a.workerId,
292 }),
293 expect.objectContaining({ type: "worker", workerId: b.workerId }),
294 expect.objectContaining({
295 type: "preview_worker",
296 workerId: b.workerId,
297 }),
298 ]),
299 );
300 expect(workerDestinations).toHaveLength(4);
301
302 // Un-enrollment without destroy: remove worker B from the stack —
303 // its binding contribution disappears and the application converges
304 // to worker A's destinations only.
305 const onlyA = Effect.gen(function* () {
306 const a = yield* AccessProtectedWorker;
307 const app = yield* App;
308 return { a, app };
309 });
310 yield* stack.deploy(onlyA);
311 const liveAfter = (yield* zeroTrust.getAccessApplicationForAccount({
312 accountId,
313 appId: app.applicationId,
314 })) as unknown as LiveApp;
315 const remaining = (liveAfter.destinations ?? []).filter(
316 (d) => d.workerId === a.workerId || d.workerId === b.workerId,
317 );
318 expect(remaining).toEqual(
319 expect.arrayContaining([
320 expect.objectContaining({ type: "worker", workerId: a.workerId }),
321 expect.objectContaining({
322 type: "preview_worker",
323 workerId: a.workerId,
324 }),
325 ]),
326 );
327 expect(remaining).toHaveLength(2);
328
329 yield* stack.destroy();
330 }).pipe(logLevel),
331 {
332 tags: [
333 "provider:cloudflare",
334 "provider:cloudflare:access",
335 "provider:cloudflare:worker",
336 "live",
337 ],
338 timeout: 300_000,
339 },
340);