1import * as AdoptPolicy from "@/AdoptPolicy";
2import * as Cloudflare from "@/Cloudflare";
3import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as State from "@/State/State";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21// Self-hosted Access Applications require a domain that belongs to an
22// *active* zone in the account (pending zones are rejected with "domain does
23// not belong to zone"). Tests can't activate a fresh zone (that requires
24// nameserver delegation), so we adopt the shared pre-existing active zone.
25// It must stay on the default `retain` removal policy: it's registered via
26// Cloudflare Registrar, and the API refuses to delete registrar zones.
27const zoneName =
28 process.env.CLOUDFLARE_TEST_ACCESS_ZONE_NAME ?? "alchemy-test-2.us";
29
30test.provider(
31 "create and delete a self_hosted application gated by a reusable policy",
32 (stack) =>
33 Effect.gen(function* () {
34 const { accountId } = yield* yield* CloudflareEnvironment;
35
36 yield* stack.destroy();
37
38 const domain = `alchemy-test-app.${zoneName}`;
39 const { app, policy } = yield* stack.deploy(
40 Effect.gen(function* () {
41 yield* Cloudflare.Zone.Zone("TestZone", {
42 name: zoneName,
43 }).pipe(AdoptPolicy.adopt(true));
44 const policy = yield* Cloudflare.Access.Policy("AllowExampleDomain", {
45 name: "Allow example.com",
46 decision: "allow",
47 include: [{ emailDomain: { domain: "example.com" } }],
48 });
49 const app = yield* Cloudflare.Access.Application("SelfHostedApp", {
50 type: "self_hosted",
51 domain,
52 sessionDuration: "24h",
53 oauthConfiguration: {
54 enabled: true,
55 grant: {
56 sessionDuration: "24h",
57 accessTokenLifetime: "15m",
58 },
59 dynamicClientRegistration: {
60 enabled: true,
61 allowedUris: [],
62 allowAnyOnLocalhost: true,
63 allowAnyOnLoopback: true,
64 },
65 },
66 policies: [policy.policyId],
67 });
68 return { app, policy };
69 }),
70 );
71
72 expect(app.applicationId).toBeDefined();
73 expect(app.type).toEqual("self_hosted");
74 expect(app.domain).toEqual(domain);
75 expect(app.aud.length).toBeGreaterThan(0);
76 expect(app.oauthConfiguration?.enabled).toBe(true);
77 expect(app.oauthConfiguration?.grant?.sessionDuration).toBe("24h");
78 expect(app.oauthConfiguration?.grant?.accessTokenLifetime).toBe("15m");
79 expect(app.oauthConfiguration?.dynamicClientRegistration?.enabled).toBe(
80 true,
81 );
82 expect(
83 app.oauthConfiguration?.dynamicClientRegistration?.allowedUris ?? [],
84 ).toEqual([]);
85 expect(policy.policyId.length).toBeGreaterThan(0);
86
87 const live = yield* zeroTrust.getAccessApplicationForAccount({
88 accountId,
89 appId: app.applicationId,
90 });
91 const liveRecord = live as unknown as {
92 id?: string | null;
93 type?: string | null;
94 policies?: ReadonlyArray<{ id?: string | null }> | null;
95 oauthConfiguration?: {
96 enabled?: boolean | null;
97 grant?: {
98 sessionDuration?: string | null;
99 accessTokenLifetime?: string | null;
100 } | null;
101 dynamicClientRegistration?: {
102 enabled?: boolean | null;
103 allowedUris?: ReadonlyArray<string | null> | null;
104 allowAnyOnLocalhost?: boolean | null;
105 allowAnyOnLoopback?: boolean | null;
106 } | null;
107 } | null;
108 };
109 expect(liveRecord.id).toEqual(app.applicationId);
110 expect(liveRecord.type).toEqual("self_hosted");
111 expect(liveRecord.policies?.length ?? 0).toBeGreaterThanOrEqual(1);
112 const liveIds = (liveRecord.policies ?? []).map((p) => p.id);
113 expect(liveIds).toContain(policy.policyId);
114 expect(liveRecord.oauthConfiguration?.enabled).toBe(true);
115 expect(liveRecord.oauthConfiguration?.grant?.sessionDuration).toBe("24h");
116 expect(liveRecord.oauthConfiguration?.grant?.accessTokenLifetime).toBe(
117 "15m",
118 );
119 expect(
120 liveRecord.oauthConfiguration?.dynamicClientRegistration?.enabled,
121 ).toBe(true);
122 expect(
123 liveRecord.oauthConfiguration?.dynamicClientRegistration?.allowedUris ??
124 [],
125 ).toEqual([]);
126 expect(
127 liveRecord.oauthConfiguration?.dynamicClientRegistration
128 ?.allowAnyOnLocalhost,
129 ).toBe(true);
130 expect(
131 liveRecord.oauthConfiguration?.dynamicClientRegistration
132 ?.allowAnyOnLoopback,
133 ).toBe(true);
134
135 yield* stack.destroy();
136 }).pipe(logLevel),
137 {
138 tags: [
139 "provider:cloudflare",
140 "provider:cloudflare:access",
141 "provider:cloudflare:zone",
142 "live",
143 ],
144 },
145);
146
147test.provider(
148 "create and delete a warp device-enrollment application",
149 (stack) =>
150 Effect.gen(function* () {
151 const idp = process.env.CLOUDFLARE_TEST_GOOGLE_IDP_ID;
152 if (!idp) {
153 // Skip when no Google IdP is configured in the test account.
154 return;
155 }
156
157 yield* stack.destroy();
158
159 const app = yield* stack.deploy(
160 Effect.gen(function* () {
161 // Warp apps derive their domain from the auth domain — no zone needed.
162 const policy = yield* Cloudflare.Access.Policy("WarpAllowDomain", {
163 name: "Allow example.com",
164 decision: "allow",
165 include: [{ emailDomain: { domain: "example.com" } }],
166 });
167 return yield* Cloudflare.Access.Application("WarpEnroll", {
168 type: "warp",
169 name: "Alchemy Warp Test",
170 sessionDuration: "720h",
171 allowedIdps: [idp],
172 autoRedirectToIdentity: true,
173 policies: [policy.policyId],
174 });
175 }),
176 );
177
178 expect(app.type).toEqual("warp");
179 // Cloudflare derives the warp domain as `${authDomain}/warp`.
180 expect(app.domain.endsWith("/warp")).toBe(true);
181
182 yield* stack.destroy();
183 }).pipe(logLevel),
184 { tags: ["provider:cloudflare", "provider:cloudflare:access", "live"] },
185);
186
187test.provider(
188 "list enumerates the deployed access application",
189 (stack) =>
190 Effect.gen(function* () {
191 yield* stack.destroy();
192
193 const domain = `alchemy-test-list-app.${zoneName}`;
194 const app = yield* stack.deploy(
195 Effect.gen(function* () {
196 yield* Cloudflare.Zone.Zone("TestZone", {
197 name: zoneName,
198 }).pipe(AdoptPolicy.adopt(true));
199 const policy = yield* Cloudflare.Access.Policy("ListAllowDomain", {
200 name: "Allow example.com",
201 decision: "allow",
202 include: [{ emailDomain: { domain: "example.com" } }],
203 });
204 return yield* Cloudflare.Access.Application("ListApp", {
205 type: "self_hosted",
206 domain,
207 sessionDuration: "24h",
208 policies: [policy.policyId],
209 });
210 }),
211 );
212
213 const provider = yield* Provider.findProvider(
214 Cloudflare.Access.Application,
215 );
216
217 // `list()` enumerates every Access application in the account. The
218 // provider already rides out the transient enumeration failures internally
219 // (the typed `AccessReferenceNotFound` from a sibling app mid-teardown
220 // still referencing a deleted policy, plus throttling 403s), so here we
221 // only poll until our own freshly created app becomes visible.
222 const all = yield* provider.list().pipe(
223 Effect.flatMap((rows) =>
224 rows.some((a) => a.applicationId === app.applicationId)
225 ? Effect.succeed(rows)
226 : Effect.fail({ _tag: "AppNotListed" as const }),
227 ),
228 Effect.retry({
229 while: (e) => e._tag === "AppNotListed",
230 schedule: Schedule.spaced("2 seconds"),
231 times: 15,
232 }),
233 );
234
235 const match = all.find((a) => a.applicationId === app.applicationId);
236 expect(match).toBeDefined();
237 expect(match?.type).toEqual("self_hosted");
238 expect(match?.aud.length).toBeGreaterThan(0);
239
240 yield* stack.destroy();
241 }).pipe(logLevel),
242 {
243 tags: [
244 "provider:cloudflare",
245 "provider:cloudflare:access",
246 "provider:cloudflare:zone",
247 "live",
248 ],
249 },
250);
251
252test.provider(
253 "update policies in place keeps the applicationId stable",
254 (stack) =>
255 Effect.gen(function* () {
256 const { accountId } = yield* yield* CloudflareEnvironment;
257
258 yield* stack.destroy();
259
260 const domain = `alchemy-test-update-policies.${zoneName}`;
261
262 const initial = yield* stack.deploy(
263 Effect.gen(function* () {
264 yield* Cloudflare.Zone.Zone("TestZone", {
265 name: zoneName,
266 }).pipe(AdoptPolicy.adopt(true));
267 const allow = yield* Cloudflare.Access.Policy("UpdateAllow", {
268 name: "Allow example.com",
269 decision: "allow",
270 include: [{ emailDomain: { domain: "example.com" } }],
271 });
272 return yield* Cloudflare.Access.Application("UpdatePolicies", {
273 type: "self_hosted",
274 domain,
275 oauthConfiguration: {
276 enabled: true,
277 grant: {
278 sessionDuration: "24h",
279 accessTokenLifetime: "15m",
280 },
281 dynamicClientRegistration: {
282 enabled: true,
283 allowedUris: ["https://client.example.com/callback"],
284 allowAnyOnLocalhost: true,
285 },
286 },
287 policies: [allow.policyId],
288 });
289 }),
290 );
291
292 const updated = yield* stack.deploy(
293 Effect.gen(function* () {
294 yield* Cloudflare.Zone.Zone("TestZone", {
295 name: zoneName,
296 }).pipe(AdoptPolicy.adopt(true));
297 const allow = yield* Cloudflare.Access.Policy("UpdateAllow", {
298 name: "Allow example.com",
299 decision: "allow",
300 include: [{ emailDomain: { domain: "example.com" } }],
301 });
302 const deny = yield* Cloudflare.Access.Policy("UpdateDeny", {
303 name: "Deny everyone else",
304 decision: "deny",
305 include: [{ everyone: {} }],
306 });
307 return yield* Cloudflare.Access.Application("UpdatePolicies", {
308 type: "self_hosted",
309 domain,
310 // Update one managed OAuth leaf while preserving omitted fields.
311 oauthConfiguration: {
312 dynamicClientRegistration: {
313 allowAnyOnLoopback: true,
314 },
315 },
316 policies: [allow.policyId, deny.policyId],
317 });
318 }),
319 );
320
321 expect(updated.applicationId).toEqual(initial.applicationId);
322
323 const live = yield* zeroTrust.getAccessApplicationForAccount({
324 accountId,
325 appId: updated.applicationId,
326 });
327 const liveRecord = live as unknown as {
328 policies?: ReadonlyArray<unknown> | null;
329 oauthConfiguration?: {
330 enabled?: boolean | null;
331 grant?: {
332 sessionDuration?: string | null;
333 accessTokenLifetime?: string | null;
334 } | null;
335 dynamicClientRegistration?: {
336 enabled?: boolean | null;
337 allowedUris?: ReadonlyArray<string | null> | null;
338 allowAnyOnLocalhost?: boolean | null;
339 allowAnyOnLoopback?: boolean | null;
340 } | null;
341 } | null;
342 };
343 expect(liveRecord.policies?.length ?? 0).toEqual(2);
344 // The partial desired body must merge over the live managed OAuth
345 // configuration before the PUT-style application update.
346 expect(liveRecord.oauthConfiguration?.enabled).toBe(true);
347 expect(liveRecord.oauthConfiguration?.grant?.sessionDuration).toBe("24h");
348 expect(liveRecord.oauthConfiguration?.grant?.accessTokenLifetime).toBe(
349 "15m",
350 );
351 expect(
352 liveRecord.oauthConfiguration?.dynamicClientRegistration?.enabled,
353 ).toBe(true);
354 expect(
355 liveRecord.oauthConfiguration?.dynamicClientRegistration?.allowedUris,
356 ).toEqual(["https://client.example.com/callback"]);
357 expect(
358 liveRecord.oauthConfiguration?.dynamicClientRegistration
359 ?.allowAnyOnLocalhost,
360 ).toBe(true);
361 expect(
362 liveRecord.oauthConfiguration?.dynamicClientRegistration
363 ?.allowAnyOnLoopback,
364 ).toBe(true);
365
366 yield* stack.destroy();
367 }).pipe(logLevel),
368 {
369 tags: [
370 "provider:cloudflare",
371 "provider:cloudflare:access",
372 "provider:cloudflare:zone",
373 "live",
374 ],
375 },
376);
377
378// Regression test for the cold-recovery `read` fallback: after state loss
379// (or a stage migration rebuilding state via the adoption probe) there is
380// no persisted `applicationId`. Without the domain-match fallback the
381// engine plans a blind `create` and Cloudflare accepts a DUPLICATE
382// application on the same domain with a fresh `aud`. With it, the app is
383// found by domain, surfaces as `Unowned`, and adopts cleanly under
384// `adopt(true)` — same applicationId/aud, no duplicate.
385test.provider(
386 "cold-recovery: read matches an existing app by domain after state loss",
387 (stack) =>
388 Effect.gen(function* () {
389 const { accountId } = yield* yield* CloudflareEnvironment;
390
391 yield* stack.destroy();
392
393 const domain = `alchemy-test-cold-read.${zoneName}`;
394 const program = Effect.gen(function* () {
395 yield* Cloudflare.Zone.Zone("TestZone", {
396 name: zoneName,
397 }).pipe(AdoptPolicy.adopt(true));
398 const policy = yield* Cloudflare.Access.Policy("ColdReadAllow", {
399 name: "Allow example.com (cold read)",
400 decision: "allow",
401 include: [{ emailDomain: { domain: "example.com" } }],
402 });
403 return yield* Cloudflare.Access.Application("ColdReadApp", {
404 type: "self_hosted",
405 domain,
406 sessionDuration: "24h",
407 policies: [policy.policyId],
408 });
409 });
410
411 const first = yield* stack.deploy(program);
412 expect(first.applicationId).toBeDefined();
413 expect(first.aud.length).toBeGreaterThan(0);
414
415 // Simulate state loss for the application only: the app still
416 // exists in Cloudflare, but the engine has no applicationId.
417 const state = yield* yield* State.State;
418 yield* state.delete({
419 stack: stack.name,
420 stage: stack.stage,
421 fqn: "ColdReadApp",
422 });
423
424 // Without adopt, the domain-matched app is Unowned — the engine
425 // must refuse the takeover rather than create a duplicate.
426 const refused = yield* stack.deploy(program).pipe(Effect.flip);
427 expect(refused).toBeInstanceOf(AdoptPolicy.OwnedBySomeoneElse);
428
429 // With adopt, the SAME app is adopted: identity is preserved and
430 // no duplicate application appears on the domain.
431 const readopted = yield* stack.deploy(
432 program.pipe(AdoptPolicy.adopt(true)),
433 );
434 expect(readopted.applicationId).toEqual(first.applicationId);
435 expect(readopted.aud).toEqual(first.aud);
436
437 const all = yield* zeroTrust.listAccessApplicationsForAccount
438 .items({ accountId })
439 .pipe(Stream.runCollect);
440 const onDomain = Array.from(all).filter(
441 (a) => (a as { domain?: string | null }).domain === domain,
442 );
443 expect(onDomain).toHaveLength(1);
444
445 yield* stack.destroy();
446 }).pipe(logLevel),
447 {
448 tags: [
449 "provider:cloudflare",
450 "provider:cloudflare:access",
451 "provider:cloudflare:zone",
452 "live",
453 ],
454 },
455);
456
457/** Structural view of live application policies for inline-policy asserts. */
458interface LiveInlineApp {
459 policies?: ReadonlyArray<{
460 id?: string | null;
461 decision?: string | null;
462 name?: string | null;
463 reusable?: boolean | null;
464 sessionDuration?: string | null;
465 include?: ReadonlyArray<unknown> | null;
466 exclude?: ReadonlyArray<unknown> | null;
467 require?: ReadonlyArray<unknown> | null;
468 }> | null;
469}
470
471test.provider(
472 "inline policies: scalar shorthand, update in place, switch to reusable, no mixing",
473 (stack) =>
474 Effect.gen(function* () {
475 const { accountId } = yield* yield* CloudflareEnvironment;
476
477 yield* stack.destroy();
478
479 const domain = `alchemy-test-inline-policies.${zoneName}`;
480 const makeApp = (
481 policies: Cloudflare.Access.ApplicationProps["policies"],
482 ) =>
483 Effect.gen(function* () {
484 yield* Cloudflare.Zone.Zone("TestZone", { name: zoneName }).pipe(
485 AdoptPolicy.adopt(true),
486 );
487 return yield* Cloudflare.Access.Application("InlinePolicyApp", {
488 type: "self_hosted",
489 domain,
490 policies,
491 });
492 });
493
494 // v1 — one inline policy via scalar shorthand.
495 const first = yield* stack.deploy(
496 makeApp([
497 {
498 decision: "allow",
499 name: "primary",
500 include: [{ emailDomain: "example.com" }],
501 },
502 ]),
503 );
504 const live1 = (yield* zeroTrust.getAccessApplicationForAccount({
505 accountId,
506 appId: first.applicationId,
507 })) as unknown as LiveInlineApp;
508 expect(live1.policies?.length).toBe(1);
509 expect(live1.policies![0].reusable).toBe(false);
510 // Shorthand expanded to the wire shape on the way out.
511 expect(live1.policies![0].include).toEqual([
512 { emailDomain: { domain: "example.com" } },
513 ]);
514 const inlineId = live1.policies![0].id;
515 expect(inlineId).toBeDefined();
516
517 // v2 — mutate the same inline policy: more shorthand kinds, exclude,
518 // require, session duration. The policy must update IN PLACE (same
519 // id) — an id-less inline item in the update PUT would mint a fresh
520 // policy.
521 yield* stack.deploy(
522 makeApp([
523 {
524 decision: "allow",
525 name: "primary",
526 include: [{ emailDomain: "example.com" }, "everyone"],
527 exclude: [{ email: "intern@example.com" }],
528 require: [{ geo: "US" }],
529 sessionDuration: "12h",
530 },
531 ]),
532 );
533 const live2 = (yield* zeroTrust.getAccessApplicationForAccount({
534 accountId,
535 appId: first.applicationId,
536 })) as unknown as LiveInlineApp;
537 expect(live2.policies?.length).toBe(1);
538 expect(live2.policies![0].id).toBe(inlineId);
539 expect(live2.policies![0].include).toEqual([
540 { emailDomain: { domain: "example.com" } },
541 { everyone: {} },
542 ]);
543 expect(live2.policies![0].exclude).toEqual([
544 { email: { email: "intern@example.com" } },
545 ]);
546 expect(live2.policies![0].require).toEqual([
547 { geo: { countryCode: "US" } },
548 ]);
549 expect(live2.policies![0].sessionDuration).toBe("12h");
550
551 // v3 — switch the application from inline to a reusable Policy
552 // resource (passed directly).
553 const reusableProgram = Effect.gen(function* () {
554 yield* Cloudflare.Zone.Zone("TestZone", { name: zoneName }).pipe(
555 AdoptPolicy.adopt(true),
556 );
557 const reusable = yield* Cloudflare.Access.Policy("InlineSwapPolicy", {
558 name: "Reusable for inline-swap test",
559 decision: "allow",
560 include: [{ emailDomain: "example.com" }],
561 });
562 const app = yield* Cloudflare.Access.Application("InlinePolicyApp", {
563 type: "self_hosted",
564 domain,
565 policies: [reusable],
566 });
567 return { app, reusable };
568 });
569 const { reusable } = yield* stack.deploy(reusableProgram);
570 const live3 = (yield* zeroTrust.getAccessApplicationForAccount({
571 accountId,
572 appId: first.applicationId,
573 })) as unknown as LiveInlineApp;
574 expect(live3.policies?.length).toBe(1);
575 expect(live3.policies![0].id).toBe(reusable.policyId);
576 expect(live3.policies![0].reusable).toBe(true);
577
578 // Mixing inline and reusable forms on one application fails fast.
579 const mixed = yield* stack
580 .deploy(
581 makeApp([
582 reusable.policyId,
583 {
584 decision: "allow",
585 include: [{ emailDomain: "example.com" }],
586 },
587 ]),
588 )
589 .pipe(Effect.flip);
590 expect(String(mixed)).toMatch(/mix/i);
591
592 yield* stack.destroy();
593 }).pipe(logLevel),
594 {
595 tags: [
596 "provider:cloudflare",
597 "provider:cloudflare:access",
598 "provider:cloudflare:zone",
599 "live",
600 ],
601 timeout: 300_000,
602 },
603);