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 * as iam from "@distilled.cloud/cloudflare/iam";
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";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20// The membership identity is an account-member id. Look one up from the
21// account's member roster (the testing account always has at least its
22// owner as an accepted member); pick deterministically.
23const findMemberId = (accountId: string) =>
24 accounts.listMembers.items({ accountId, status: "accepted" }).pipe(
25 Stream.runCollect,
26 Effect.flatMap((chunk) => {
27 const ids = Array.from(chunk)
28 .flatMap((m) => (m.id ? [m.id] : []))
29 .sort();
30 return ids.length > 0
31 ? Effect.succeed(ids[0]!)
32 : Effect.die(new Error("no accepted account members found"));
33 }),
34 );
35
36// The scoped API token the test harness mints propagates eventually-
37// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
38// declared in the distilled error union) on out-of-band verification calls.
39const getMembership = (
40 accountId: string,
41 userGroupId: string,
42 memberId: string,
43) =>
44 iam.getUserGroupMember({ accountId, userGroupId, memberId }).pipe(
45 Effect.retry({
46 while: (e) => e._tag === "Forbidden",
47 schedule: Schedule.exponential("500 millis"),
48 times: 8,
49 }),
50 );
51
52// A removed member answers GET with the typed `UserGroupMemberNotFound`;
53// once the group itself is destroyed, with `UserGroupNotFound`. Both are
54// the success condition here.
55const expectGone = (accountId: string, userGroupId: string, memberId: string) =>
56 getMembership(accountId, userGroupId, memberId).pipe(
57 Effect.asSome,
58 Effect.catchTag(
59 ["UserGroupMemberNotFound", "UserGroupNotFound"],
60 () => Effect.succeedNone,
61 ),
62 Effect.repeat({
63 schedule: Schedule.exponential("500 millis"),
64 until: (m) => m._tag === "None",
65 times: 8,
66 }),
67 Effect.map((m) => expect(m._tag).toEqual("None")),
68 );
69
70// Both groups stay deployed across both steps; only the membership's target
71// group flips, so the replacement is isolated to the membership itself.
72//
73// Group names are provider-generated (unique per stack instance) rather than
74// hardcoded constants: each `test.provider` runs in its own scratch stack, so
75// a shared deterministic name would make the two cases in this file adopt the
76// SAME physical group (user-group names are unique per account) and stomp on
77// each other when their lifecycles overlap. Letting the engine name them
78// keeps every test's groups isolated and self-healing.
79const program = (opts: { memberId: string; target: "A" | "B" }) =>
80 Effect.gen(function* () {
81 const groupA = yield* Cloudflare.Iam.UserGroup("GroupA", {});
82 const groupB = yield* Cloudflare.Iam.UserGroup("GroupB", {});
83 const membership = yield* Cloudflare.Iam.UserGroupMembership("Membership", {
84 userGroup: opts.target === "A" ? groupA.userGroupId : groupB.userGroupId,
85 memberId: opts.memberId,
86 });
87 return { groupA, groupB, membership };
88 });
89
90test.provider(
91 "create, verify out-of-band, replace when the user group changes, destroy",
92 (stack) =>
93 Effect.gen(function* () {
94 const { accountId } = yield* yield* CloudflareEnvironment;
95 const memberId = yield* findMemberId(accountId);
96
97 yield* stack.destroy();
98
99 // Create — member joins group A.
100 const v1 = yield* stack.deploy(program({ memberId, target: "A" }));
101
102 expect(v1.membership.userGroupId).toEqual(v1.groupA.userGroupId);
103 expect(v1.membership.memberId).toEqual(memberId);
104 expect(v1.membership.accountId).toEqual(accountId);
105
106 // Out-of-band verification via the distilled API.
107 const live = yield* getMembership(
108 accountId,
109 v1.groupA.userGroupId,
110 memberId,
111 );
112 expect(live.id).toEqual(memberId);
113
114 // Changing the target group is an identity change — the membership is
115 // replaced: added to group B, removed from group A.
116 const v2 = yield* stack.deploy(program({ memberId, target: "B" }));
117
118 expect(v2.groupA.userGroupId).toEqual(v1.groupA.userGroupId);
119 expect(v2.groupB.userGroupId).toEqual(v1.groupB.userGroupId);
120 expect(v2.membership.userGroupId).toEqual(v2.groupB.userGroupId);
121 expect(v2.membership.memberId).toEqual(memberId);
122
123 const moved = yield* getMembership(
124 accountId,
125 v2.groupB.userGroupId,
126 memberId,
127 );
128 expect(moved.id).toEqual(memberId);
129
130 // The old membership in group A was deleted by the replacement.
131 yield* expectGone(accountId, v1.groupA.userGroupId, memberId);
132
133 yield* stack.destroy();
134
135 // Destroy removed the membership (and the groups themselves).
136 yield* expectGone(accountId, v2.groupB.userGroupId, memberId);
137 }).pipe(logLevel),
138 {
139 tags: [
140 "provider:cloudflare",
141 "provider:cloudflare:account",
142 "provider:cloudflare:iam",
143 "live",
144 ],
145 timeout: 120_000,
146 },
147);
148
149test.provider(
150 "list enumerates memberships across all user groups in the account",
151 (stack) =>
152 Effect.gen(function* () {
153 const { accountId } = yield* yield* CloudflareEnvironment;
154 const memberId = yield* findMemberId(accountId);
155
156 yield* stack.destroy();
157
158 // Deploy a membership so there is at least one to enumerate.
159 const deployed = yield* stack.deploy(program({ memberId, target: "A" }));
160
161 // Resolve the provider with the typed helper — element type is the
162 // resource's exact Attributes shape (no `any`).
163 const provider = yield* Provider.findProvider(
164 Cloudflare.Iam.UserGroupMembership,
165 );
166
167 // Parent fan-out + per-group pagination must surface our deployed
168 // membership somewhere in the exhaustively-collected result.
169 const all = yield* provider.list();
170 expect(
171 all.some(
172 (m) =>
173 m.userGroupId === deployed.membership.userGroupId &&
174 m.memberId === memberId &&
175 m.accountId === accountId,
176 ),
177 ).toBe(true);
178
179 yield* stack.destroy();
180 }).pipe(logLevel),
181 {
182 tags: [
183 "provider:cloudflare",
184 "provider:cloudflare:account",
185 "provider:cloudflare:iam",
186 "live",
187 ],
188 timeout: 120_000,
189 },
190);