1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as iam from "@distilled.cloud/cloudflare/iam";
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 names — the same on every run (never Date.now()/random).
20const UG_NAME = "alchemy-iam-ug-crud";
21const UG_NAME_RENAMED = "alchemy-iam-ug-crud-renamed";
22const RG_NAME = "alchemy-iam-ug-crud-rg";
23
24// Built-in permission groups looked up by their stable catalog names.
25const PERMISSION_GROUP_NAME = "Page Rules Read";
26const PERMISSION_GROUP_NAME_2 = "Security Center Read";
27
28const findPermissionGroupId = (accountId: string, name: string) =>
29 iam.listPermissionGroups.items({ accountId, name }).pipe(
30 Stream.runHead,
31 Effect.flatMap((pg) =>
32 pg._tag === "Some"
33 ? Effect.succeed(pg.value.id)
34 : Effect.die(new Error(`permission group "${name}" not found`)),
35 ),
36 );
37
38// The scoped API token the test harness mints propagates eventually-
39// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
40// declared in the distilled error union) on out-of-band verification calls.
41const getUserGroup = (accountId: string, userGroupId: string) =>
42 iam.getUserGroup({ accountId, userGroupId }).pipe(
43 Effect.retry({
44 while: (e) => e._tag === "Forbidden",
45 schedule: Schedule.exponential("500 millis"),
46 times: 8,
47 }),
48 );
49
50// A destroyed user group answers GET with the typed `UserGroupNotFound`.
51const expectGone = (accountId: string, userGroupId: string) =>
52 getUserGroup(accountId, userGroupId).pipe(
53 Effect.asSome,
54 Effect.catchTag("UserGroupNotFound", () => Effect.succeedNone),
55 Effect.repeat({
56 schedule: Schedule.exponential("500 millis"),
57 until: (g) => g._tag === "None",
58 times: 8,
59 }),
60 Effect.map((g) => expect(g._tag).toEqual("None")),
61 );
62
63// One program deploying the resource group and the user group whose policy
64// references it. The policy props reference the resource group's output, so
65// the engine orders the user group after it on deploy (and before it on
66// destroy).
67const program = (opts: {
68 name: string;
69 permissionGroupId: string;
70 accountScopeKey: string;
71}) =>
72 Effect.gen(function* () {
73 const rg = yield* Cloudflare.Iam.ResourceGroup("Rg", {
74 name: RG_NAME,
75 scope: { key: opts.accountScopeKey, objects: [{ key: "*" }] },
76 });
77 const group = yield* Cloudflare.Iam.UserGroup("Group", {
78 name: opts.name,
79 policies: [
80 {
81 // Cloudflare rejects `deny` user-group policies ("Policy
82 // validation failed") — only `allow` is exercised here.
83 access: "allow",
84 permissionGroups: [opts.permissionGroupId],
85 resourceGroups: [rg.resourceGroupId],
86 },
87 ],
88 });
89 return { rg, group };
90 });
91
92test.provider(
93 "create with a policy, verify out-of-band, update policies in place, destroy",
94 (stack) =>
95 Effect.gen(function* () {
96 const { accountId } = yield* yield* CloudflareEnvironment;
97 const accountScopeKey = `com.cloudflare.api.account.${accountId}`;
98 const permissionGroupId = yield* findPermissionGroupId(
99 accountId,
100 PERMISSION_GROUP_NAME,
101 );
102 const permissionGroupId2 = yield* findPermissionGroupId(
103 accountId,
104 PERMISSION_GROUP_NAME_2,
105 );
106
107 yield* stack.destroy();
108
109 // Create — one allow policy joining the permission group and the
110 // resource group.
111 const v1 = yield* stack.deploy(
112 program({
113 name: UG_NAME,
114 permissionGroupId,
115 accountScopeKey,
116 }),
117 );
118
119 expect(v1.group.userGroupId).toBeTruthy();
120 expect(v1.group.accountId).toEqual(accountId);
121 expect(v1.group.name).toEqual(UG_NAME);
122 expect(v1.group.createdOn).toBeTruthy();
123 expect(v1.group.policies).toHaveLength(1);
124 expect(v1.group.policies[0]!.access).toEqual("allow");
125 expect(v1.group.policies[0]!.permissionGroups).toEqual([
126 permissionGroupId,
127 ]);
128 expect(v1.group.policies[0]!.resourceGroups).toEqual([
129 v1.rg.resourceGroupId,
130 ]);
131
132 // Out-of-band verification via the distilled API.
133 const live = yield* getUserGroup(accountId, v1.group.userGroupId);
134 expect(live.name).toEqual(UG_NAME);
135 expect(live.policies ?? []).toHaveLength(1);
136 expect(live.policies?.[0]?.access).toEqual("allow");
137
138 // In-place update — rename and swap the policy's permission group.
139 // Same user group (no replacement).
140 const v2 = yield* stack.deploy(
141 program({
142 name: UG_NAME_RENAMED,
143 permissionGroupId: permissionGroupId2,
144 accountScopeKey,
145 }),
146 );
147
148 expect(v2.group.userGroupId).toEqual(v1.group.userGroupId);
149 expect(v2.group.name).toEqual(UG_NAME_RENAMED);
150 expect(v2.group.policies[0]!.permissionGroups).toEqual([
151 permissionGroupId2,
152 ]);
153
154 const updated = yield* getUserGroup(accountId, v2.group.userGroupId);
155 expect(updated.name).toEqual(UG_NAME_RENAMED);
156 expect(updated.policies?.[0]?.permissionGroups?.[0]?.id).toEqual(
157 permissionGroupId2,
158 );
159
160 // No-op deploy — same desired state, same identity, reconcile
161 // observes the in-sync state and applies nothing.
162 const v3 = yield* stack.deploy(
163 program({
164 name: UG_NAME_RENAMED,
165 permissionGroupId: permissionGroupId2,
166 accountScopeKey,
167 }),
168 );
169 expect(v3.group.userGroupId).toEqual(v1.group.userGroupId);
170
171 yield* stack.destroy();
172
173 yield* expectGone(accountId, v1.group.userGroupId);
174 }).pipe(logLevel),
175 {
176 tags: ["provider:cloudflare", "provider:cloudflare:iam", "live"],
177 timeout: 120_000,
178 },
179);
180
181const UG_LIST_NAME = "alchemy-iam-ug-list";
182
183test.provider(
184 "list enumerates the deployed user group",
185 (stack) =>
186 Effect.gen(function* () {
187 yield* stack.destroy();
188
189 const deployed = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* Cloudflare.Iam.UserGroup("ListGroup", {
192 name: UG_LIST_NAME,
193 });
194 }),
195 );
196
197 const provider = yield* Provider.findProvider(Cloudflare.Iam.UserGroup);
198 const all = yield* provider.list();
199
200 // Exhaustively-paginated account collection contains the deployed
201 // group, hydrated into the exact `read` Attributes shape.
202 const found = all.find((g) => g.userGroupId === deployed.userGroupId);
203 expect(found).toBeTruthy();
204 expect(found!.name).toEqual(UG_LIST_NAME);
205 expect(found!.accountId).toEqual(deployed.accountId);
206
207 yield* stack.destroy();
208 }).pipe(logLevel),
209 {
210 tags: ["provider:cloudflare", "provider:cloudflare:iam", "live"],
211 timeout: 120_000,
212 },
213);