1import * as Cloudflare from "@/Cloudflare";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as organizations from "@distilled.cloud/cloudflare/organizations";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: Cloudflare.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// Organizations are entitlement-gated (closed rollout for tenant /
18// enterprise customers). On the standard testing account every
19// `/organizations` call fails with the typed `Forbidden` error
20// (HTTP 403, Cloudflare code 10000 "Authentication error"). The full
21// lifecycle test below is gated behind an explicit opt-in env flag for
22// entitled accounts; the probe test always runs and pins the typed tag.
23const entitled = !!process.env.CLOUDFLARE_TEST_ORGANIZATIONS;
24
25// Probe once per suite: either the account can list organizations
26// (entitled) or the call fails with the typed `Forbidden` tag.
27const probeEntitlement = organizations.listOrganizations({ pageSize: 1 }).pipe(
28 Effect.as(true),
29 Effect.catchTag("Forbidden", () => Effect.succeed(false)),
30);
31
32// Deterministic names — the same on every run (never Date.now()/random).
33const ORG_NAME_CRUD = "alchemy-org-crud";
34const ORG_NAME_CRUD_RENAMED = "alchemy-org-crud-renamed";
35
36const PROFILE = {
37 businessName: "Alchemy Test Co",
38 businessEmail: "test@alchemy.run",
39 businessPhone: "+1-555-0100",
40 businessAddress: "1 Test Way, Springfield",
41 externalMetadata: "alchemy:test",
42} as const;
43
44test.provider(
45 "unentitled accounts surface the typed Forbidden error",
46 (stack) =>
47 Effect.gen(function* () {
48 yield* stack.destroy();
49
50 const canList = yield* probeEntitlement;
51 if (canList) {
52 // Entitled account — nothing to assert here; the lifecycle test
53 // covers the real behavior.
54 yield* Effect.logInfo(
55 "account is organizations-entitled; probe test is a no-op",
56 );
57 return;
58 }
59
60 // The typed tag — not UnknownCloudflareError, not a status check.
61 const error = yield* organizations
62 .listOrganizations({ pageSize: 1 })
63 .pipe(Effect.flip);
64 expect(error._tag).toEqual("Forbidden");
65
66 const createError = yield* organizations
67 .createOrganization({ name: ORG_NAME_CRUD })
68 .pipe(Effect.flip);
69 expect(createError._tag).toEqual("Forbidden");
70
71 yield* stack.destroy();
72 }).pipe(logLevel),
73 { tags: ["provider:cloudflare", "provider:cloudflare:organization", "live"] },
74);
75
76// Canonical `list()` test — ungated probe. `list()` enumerates every
77// organization reachable by the credentials. On an unentitled account the
78// `/organizations` collection rejects with the typed `Forbidden` tag, which
79// the provider's `list()` deliberately tolerates (returning `[]`) so that
80// account-wide enumeration / `nuke` never blows up on a non-tenant account;
81// the raw-op probe test above already pins the typed tag. On an entitled
82// account it returns a well-typed `Attributes[]`.
83test.provider(
84 "list either enumerates organizations or tolerates the unentitled account",
85 (stack) =>
86 Effect.gen(function* () {
87 yield* stack.destroy();
88
89 const provider = yield* Provider.findProvider(
90 Cloudflare.Organization.Organization,
91 );
92
93 const canList = yield* probeEntitlement;
94 if (canList) {
95 const all = yield* provider.list();
96 // Well-typed Attributes array; entitled accounts may have zero or
97 // more organizations, so only assert the shape.
98 expect(Array.isArray(all)).toBe(true);
99 for (const org of all) {
100 expect(typeof org.organizationId).toBe("string");
101 expect(typeof org.name).toBe("string");
102 }
103 } else {
104 // Unentitled: list() swallows the typed Forbidden and reports an
105 // empty collection (nuke-safe), rather than propagating.
106 const all = yield* provider.list();
107 expect(all).toEqual([]);
108 }
109
110 yield* stack.destroy();
111 }).pipe(logLevel),
112 { tags: ["provider:cloudflare", "provider:cloudflare:organization", "live"] },
113);
114
115// Gated live enumeration: on an entitled account, a deployed organization
116// must appear in the exhaustively-paginated `list()` result.
117test.provider.skipIf(!entitled)(
118 "list enumerates the deployed organization",
119 (stack) =>
120 Effect.gen(function* () {
121 yield* stack.destroy();
122
123 const deployed = yield* stack.deploy(
124 Effect.gen(function* () {
125 return yield* Cloudflare.Organization.Organization("ListOrg", {
126 name: ORG_NAME_CRUD,
127 });
128 }),
129 );
130
131 const provider = yield* Provider.findProvider(
132 Cloudflare.Organization.Organization,
133 );
134 const all = yield* provider.list();
135
136 expect(
137 all.some((o) => o.organizationId === deployed.organizationId),
138 ).toBe(true);
139
140 yield* stack.destroy();
141 }).pipe(logLevel),
142 {
143 tags: ["provider:cloudflare", "provider:cloudflare:organization", "live"],
144 timeout: 120_000,
145 },
146);
147
148// Poll until the organization is gone after destroy. Cloudflare answers
149// GET for a missing org with the typed `OrganizationNotFound` (404).
150const expectGone = (organizationId: string) =>
151 organizations.getOrganization({ organizationId }).pipe(
152 Effect.asSome,
153 Effect.catchTag("OrganizationNotFound", () => Effect.succeedNone),
154 Effect.repeat({
155 schedule: Schedule.exponential("500 millis"),
156 until: (org) => org._tag === "None",
157 times: 8,
158 }),
159 Effect.map((org) => expect(org._tag).toEqual("None")),
160 );
161
162test.provider.skipIf(!entitled)(
163 "create, verify out-of-band, update in place, destroy",
164 (stack) =>
165 Effect.gen(function* () {
166 yield* stack.destroy();
167
168 // Create with a business profile.
169 const v1 = yield* stack.deploy(
170 Effect.gen(function* () {
171 return yield* Cloudflare.Organization.Organization("Org", {
172 name: ORG_NAME_CRUD,
173 profile: PROFILE,
174 });
175 }),
176 );
177
178 expect(v1.organizationId).toBeTruthy();
179 expect(v1.name).toEqual(ORG_NAME_CRUD);
180 expect(v1.createTime).toBeTruthy();
181 expect(v1.profile).toEqual(PROFILE);
182
183 // Out-of-band verification via the distilled API.
184 const live = yield* organizations.getOrganization({
185 organizationId: v1.organizationId,
186 });
187 expect(live.name).toEqual(ORG_NAME_CRUD);
188 expect(live.profile).toEqual(PROFILE);
189
190 // In-place update — rename and change the profile email. Same
191 // organization (no replacement).
192 const v2 = yield* stack.deploy(
193 Effect.gen(function* () {
194 return yield* Cloudflare.Organization.Organization("Org", {
195 name: ORG_NAME_CRUD_RENAMED,
196 profile: { ...PROFILE, businessEmail: "ops@alchemy.run" },
197 });
198 }),
199 );
200
201 expect(v2.organizationId).toEqual(v1.organizationId);
202 expect(v2.createTime).toEqual(v1.createTime);
203 expect(v2.name).toEqual(ORG_NAME_CRUD_RENAMED);
204 expect(v2.profile?.businessEmail).toEqual("ops@alchemy.run");
205
206 const updated = yield* organizations.getOrganization({
207 organizationId: v2.organizationId,
208 });
209 expect(updated.name).toEqual(ORG_NAME_CRUD_RENAMED);
210 expect(updated.profile?.businessEmail).toEqual("ops@alchemy.run");
211
212 // No-op deploy — same desired state, same identity.
213 const v3 = yield* stack.deploy(
214 Effect.gen(function* () {
215 return yield* Cloudflare.Organization.Organization("Org", {
216 name: ORG_NAME_CRUD_RENAMED,
217 profile: { ...PROFILE, businessEmail: "ops@alchemy.run" },
218 });
219 }),
220 );
221 expect(v3.organizationId).toEqual(v1.organizationId);
222
223 yield* stack.destroy();
224
225 yield* expectGone(v1.organizationId);
226 }).pipe(logLevel),
227 {
228 tags: ["provider:cloudflare", "provider:cloudflare:organization", "live"],
229 timeout: 120_000,
230 },
231);