1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as accounts from "@distilled.cloud/cloudflare/accounts";
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// Deterministic invite addresses we own — invites stay `pending`, which is
20// fine; deleting the member cancels the invite. Each test owns a distinct
21// address: the suites run concurrently against one shared account, so a
22// shared email would let one test's destroy/cleanup cancel another's invite
23// mid-flight (a `MemberNotFound` race).
24const crudEmail = "sam+alchemy-test-member-crud@alchemy.run";
25const replaceEmail = "sam+alchemy-test-member-replace@alchemy.run";
26const replacedEmail = "sam+alchemy-test-member-replaced@alchemy.run";
27const healEmail = "sam+alchemy-test-member-heal@alchemy.run";
28
29// The scoped API token the test harness mints propagates eventually-
30// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
31// declared in the distilled error union) on out-of-band verification calls.
32const forbiddenRetry = {
33 while: (e: { _tag: string }) => e._tag === "Forbidden",
34 schedule: Schedule.exponential("500 millis"),
35 times: 8,
36} as const;
37
38const getMember = (accountId: string, memberId: string) =>
39 accounts
40 .getMember({ accountId, memberId })
41 .pipe(Effect.retry(forbiddenRetry));
42
43const expectGone = (accountId: string, memberId: string) =>
44 getMember(accountId, memberId).pipe(
45 Effect.flatMap(() => Effect.fail({ _tag: "MemberNotDeleted" } as const)),
46 // A missing member surfaces as `MemberNotFound` (Cloudflare error
47 // code 1003) — that's the success condition here.
48 Effect.catchTag("MemberNotFound", () => Effect.void),
49 Effect.retry({
50 while: (e) => e._tag === "MemberNotDeleted",
51 schedule: Schedule.max([
52 Schedule.exponential("500 millis"),
53 Schedule.recurs(10),
54 ]),
55 }),
56 );
57
58// Remove any membership left behind by a previous crashed run so the test
59// account stays clean and create paths start from a known baseline.
60const cleanupEmail = (accountId: string, email: string) =>
61 accounts.listMembers.items({ accountId }).pipe(
62 Stream.runCollect,
63 Effect.retry(forbiddenRetry),
64 Effect.flatMap((chunk) => {
65 const match = Array.from(chunk).find(
66 (member) => member.email?.toLowerCase() === email.toLowerCase(),
67 );
68 return match?.id != null
69 ? accounts
70 .deleteMember({ accountId, memberId: match.id })
71 .pipe(Effect.catchTag("MemberNotFound", () => Effect.void))
72 : Effect.void;
73 }),
74 );
75
76// Pick two deterministic non-super-admin roles for the account: sorted by
77// name so every run selects the same pair.
78const pickTwoRoles = (accountId: string) =>
79 accounts.listRoles.items({ accountId }).pipe(
80 Stream.runCollect,
81 Effect.retry(forbiddenRetry),
82 Effect.map((chunk) => {
83 const roles = Array.from(chunk)
84 .filter((role) => !role.name.startsWith("Super Administrator"))
85 .sort((a, b) => a.name.localeCompare(b.name));
86 expect(roles.length).toBeGreaterThanOrEqual(2);
87 return [roles[0]!, roles[1]!] as const;
88 }),
89 );
90
91// Read-only: enumerating account members sends no invites. The account
92// owner is always an accepted member, so `list()` must return a non-empty,
93// well-typed `Attributes[]` containing at least one accepted membership.
94test.provider(
95 "list enumerates the account members",
96 (_stack) =>
97 Effect.gen(function* () {
98 const { accountId } = yield* yield* CloudflareEnvironment;
99
100 const provider = yield* Provider.findProvider(Cloudflare.Account.Member);
101 const all = yield* provider.list().pipe(Effect.retry(forbiddenRetry));
102
103 expect(all.length).toBeGreaterThan(0);
104 // Every element is the exact `read` shape, scoped to this account.
105 for (const member of all) {
106 expect(member.memberId).toBeTruthy();
107 expect(member.accountId).toEqual(accountId);
108 expect(typeof member.email).toBe("string");
109 expect(Array.isArray(member.roles)).toBe(true);
110 }
111 // The account owner is an accepted member.
112 expect(all.some((member) => member.status === "accepted")).toBe(true);
113 }).pipe(logLevel),
114 { tags: ["provider:cloudflare", "provider:cloudflare:account", "live"] },
115);
116
117test.provider(
118 "create member, update roles in place, delete",
119 (stack) =>
120 Effect.gen(function* () {
121 const { accountId } = yield* yield* CloudflareEnvironment;
122
123 yield* stack.destroy();
124 yield* cleanupEmail(accountId, crudEmail);
125
126 const [roleA, roleB] = yield* pickTwoRoles(accountId);
127
128 const member = yield* stack.deploy(
129 Cloudflare.Account.Member("TestMember", {
130 email: crudEmail,
131 roles: [roleA.id],
132 }),
133 );
134
135 expect(member.memberId).toBeTruthy();
136 expect(member.accountId).toEqual(accountId);
137 expect(member.email.toLowerCase()).toEqual(crudEmail);
138 expect(member.status).toEqual("pending");
139 expect(member.roles.map((r) => r.id)).toEqual([roleA.id]);
140
141 // Out-of-band verify the invite exists with the assigned role.
142 const live = yield* getMember(accountId, member.memberId);
143 expect(live.email?.toLowerCase()).toEqual(crudEmail);
144 expect((live.roles ?? []).map((r) => r.id)).toEqual([roleA.id]);
145
146 // Swap the role — same email, so the membership is updated in place.
147 const updated = yield* stack.deploy(
148 Cloudflare.Account.Member("TestMember", {
149 email: crudEmail,
150 roles: [roleB.id],
151 }),
152 );
153 expect(updated.memberId).toEqual(member.memberId);
154 expect(updated.roles.map((r) => r.id)).toEqual([roleB.id]);
155
156 const liveUpdated = yield* getMember(accountId, member.memberId);
157 expect((liveUpdated.roles ?? []).map((r) => r.id)).toEqual([roleB.id]);
158
159 // Redeploying identical props is a no-op (same membership).
160 const noop = yield* stack.deploy(
161 Cloudflare.Account.Member("TestMember", {
162 email: crudEmail,
163 roles: [roleB.id],
164 }),
165 );
166 expect(noop.memberId).toEqual(member.memberId);
167
168 yield* stack.destroy();
169 yield* expectGone(accountId, member.memberId);
170 }).pipe(logLevel),
171 { tags: ["provider:cloudflare", "provider:cloudflare:account", "live"] },
172);
173
174test.provider(
175 "replaces the member when the email changes",
176 (stack) =>
177 Effect.gen(function* () {
178 const { accountId } = yield* yield* CloudflareEnvironment;
179
180 yield* stack.destroy();
181 yield* cleanupEmail(accountId, replaceEmail);
182 yield* cleanupEmail(accountId, replacedEmail);
183
184 const [roleA] = yield* pickTwoRoles(accountId);
185
186 const original = yield* stack.deploy(
187 Cloudflare.Account.Member("ReplaceMember", {
188 email: replaceEmail,
189 roles: [roleA.id],
190 }),
191 );
192
193 // Changing the email re-invites: a fresh membership id, and the old
194 // invite is cancelled by the replacement's delete phase.
195 const replaced = yield* stack.deploy(
196 Cloudflare.Account.Member("ReplaceMember", {
197 email: replacedEmail,
198 roles: [roleA.id],
199 }),
200 );
201
202 expect(replaced.memberId).not.toEqual(original.memberId);
203 expect(replaced.email.toLowerCase()).toEqual(replacedEmail);
204 yield* expectGone(accountId, original.memberId);
205
206 yield* stack.destroy();
207 yield* expectGone(accountId, replaced.memberId);
208 }).pipe(logLevel),
209 { tags: ["provider:cloudflare", "provider:cloudflare:account", "live"] },
210);
211
212test.provider(
213 "recreates after out-of-band delete",
214 (stack) =>
215 Effect.gen(function* () {
216 const { accountId } = yield* yield* CloudflareEnvironment;
217
218 yield* stack.destroy();
219 yield* cleanupEmail(accountId, healEmail);
220
221 const [roleA, roleB] = yield* pickTwoRoles(accountId);
222
223 const member = yield* stack.deploy(
224 Cloudflare.Account.Member("HealMember", {
225 email: healEmail,
226 roles: [roleA.id],
227 }),
228 );
229
230 // Cancel the invite out-of-band. A redeploy with identical props is a
231 // planner no-op, so change the role to force reconcile — it must
232 // observe the member as missing and re-invite instead of failing.
233 yield* accounts
234 .deleteMember({ accountId, memberId: member.memberId })
235 .pipe(Effect.retry(forbiddenRetry));
236
237 const healed = yield* stack.deploy(
238 Cloudflare.Account.Member("HealMember", {
239 email: healEmail,
240 roles: [roleB.id],
241 }),
242 );
243
244 expect(healed.memberId).not.toEqual(member.memberId);
245 expect(healed.roles.map((r) => r.id)).toEqual([roleB.id]);
246
247 const live = yield* getMember(accountId, healed.memberId);
248 expect((live.roles ?? []).map((r) => r.id)).toEqual([roleB.id]);
249
250 yield* stack.destroy();
251 yield* expectGone(accountId, healed.memberId);
252 }).pipe(logLevel),
253 { tags: ["provider:cloudflare", "provider:cloudflare:account", "live"] },
254);