1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
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";
11
12const { test } = Test.make({ providers: Cloudflare.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19const zoneName =
20 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
21
22// Deterministic names — the same on every run (never Date.now()/random).
23const RG_NAME = "alchemy-iam-rg-crud";
24const RG_NAME_RENAMED = "alchemy-iam-rg-crud-renamed";
25
26// The scoped API token the test harness mints propagates eventually-
27// consistently across Cloudflare's edge — ride out 403 blips (`Forbidden`,
28// declared in the distilled error union) on out-of-band verification calls.
29const getResourceGroup = (accountId: string, resourceGroupId: string) =>
30 iam.getResourceGroup({ accountId, resourceGroupId }).pipe(
31 Effect.retry({
32 while: (e) => e._tag === "Forbidden",
33 schedule: Schedule.exponential("500 millis"),
34 times: 8,
35 }),
36 );
37
38// A destroyed resource group answers GET with the typed
39// `ResourceGroupNotFound` (404).
40const expectGone = (accountId: string, resourceGroupId: string) =>
41 getResourceGroup(accountId, resourceGroupId).pipe(
42 Effect.asSome,
43 Effect.catchTag("ResourceGroupNotFound", () => Effect.succeedNone),
44 Effect.repeat({
45 schedule: Schedule.exponential("500 millis"),
46 until: (g) => g._tag === "None",
47 times: 8,
48 }),
49 Effect.map((g) => expect(g._tag).toEqual("None")),
50 );
51
52test.provider(
53 "create, verify out-of-band, update name+scope in place, destroy",
54 (stack) =>
55 Effect.gen(function* () {
56 const { accountId } = yield* yield* CloudflareEnvironment;
57 const accountScopeKey = `com.cloudflare.api.account.${accountId}`;
58 const zone = yield* findZoneByName({ accountId, name: zoneName });
59 if (!zone) {
60 return yield* Effect.die(
61 new Error(`zone "${zoneName}" not found in account`),
62 );
63 }
64 const zoneObjectKey = `com.cloudflare.api.account.zone.${zone.id}`;
65
66 yield* stack.destroy();
67
68 // Create — scoped to the whole account.
69 const v1 = yield* stack.deploy(
70 Effect.gen(function* () {
71 return yield* Cloudflare.Iam.ResourceGroup("Rg", {
72 name: RG_NAME,
73 scope: {
74 key: accountScopeKey,
75 objects: [{ key: "*" }],
76 },
77 });
78 }),
79 );
80
81 expect(v1.resourceGroupId).toBeTruthy();
82 expect(v1.accountId).toEqual(accountId);
83 expect(v1.name).toEqual(RG_NAME);
84 expect(v1.scope.key).toEqual(accountScopeKey);
85 expect(v1.scope.objects).toEqual([{ key: "*" }]);
86
87 // Out-of-band verification via the distilled API.
88 const live = yield* getResourceGroup(accountId, v1.resourceGroupId);
89 expect(live.name).toEqual(RG_NAME);
90
91 // In-place update — rename and narrow the scope to a single zone.
92 // Same resource group (no replacement).
93 const v2 = yield* stack.deploy(
94 Effect.gen(function* () {
95 return yield* Cloudflare.Iam.ResourceGroup("Rg", {
96 name: RG_NAME_RENAMED,
97 scope: {
98 key: accountScopeKey,
99 objects: [{ key: zoneObjectKey }],
100 },
101 });
102 }),
103 );
104
105 expect(v2.resourceGroupId).toEqual(v1.resourceGroupId);
106 expect(v2.name).toEqual(RG_NAME_RENAMED);
107 expect(v2.scope.objects).toEqual([{ key: zoneObjectKey }]);
108
109 const updated = yield* getResourceGroup(accountId, v2.resourceGroupId);
110 expect(updated.name).toEqual(RG_NAME_RENAMED);
111
112 // No-op deploy — same desired state, same identity, reconcile
113 // observes the in-sync state and applies nothing.
114 const v3 = yield* stack.deploy(
115 Effect.gen(function* () {
116 return yield* Cloudflare.Iam.ResourceGroup("Rg", {
117 name: RG_NAME_RENAMED,
118 scope: {
119 key: accountScopeKey,
120 objects: [{ key: zoneObjectKey }],
121 },
122 });
123 }),
124 );
125 expect(v3.resourceGroupId).toEqual(v1.resourceGroupId);
126
127 yield* stack.destroy();
128
129 yield* expectGone(accountId, v1.resourceGroupId);
130 }).pipe(logLevel),
131 {
132 tags: [
133 "provider:cloudflare",
134 "provider:cloudflare:iam",
135 "provider:cloudflare:zone",
136 "live",
137 ],
138 timeout: 120_000,
139 },
140);
141
142test.provider(
143 "list enumerates the deployed resource group",
144 (stack) =>
145 Effect.gen(function* () {
146 const { accountId } = yield* yield* CloudflareEnvironment;
147 const accountScopeKey = `com.cloudflare.api.account.${accountId}`;
148
149 yield* stack.destroy();
150
151 const deployed = yield* stack.deploy(
152 Effect.gen(function* () {
153 return yield* Cloudflare.Iam.ResourceGroup("ListRg", {
154 name: "alchemy-iam-rg-list",
155 scope: {
156 key: accountScopeKey,
157 objects: [{ key: "*" }],
158 },
159 });
160 }),
161 );
162
163 const provider = yield* Provider.findProvider(
164 Cloudflare.Iam.ResourceGroup,
165 );
166 const all = yield* provider.list();
167
168 // Each element is the full `read` Attributes shape, usable by delete.
169 expect(
170 all.some((g) => g.resourceGroupId === deployed.resourceGroupId),
171 ).toBe(true);
172
173 yield* stack.destroy();
174
175 yield* expectGone(accountId, deployed.resourceGroupId);
176 }).pipe(logLevel),
177 {
178 tags: ["provider:cloudflare", "provider:cloudflare:iam", "live"],
179 timeout: 120_000,
180 },
181);