1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as resourceSharing from "@distilled.cloud/cloudflare/resource-sharing";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Result from "effect/Result";
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
19// Resource Sharing writes are permission-gated: on the standard testing
20// account every POST/PUT/DELETE under /accounts/{id}/shares fails with the
21// typed `Forbidden` error (HTTP 403, Cloudflare code 10000 "Authentication
22// error") because the token lacks the Resource Sharing Edit permission.
23// Reads (list/get) succeed. Sharing also requires a *second* account or
24// organization as recipient. The full lifecycle test below is gated behind
25// an explicit opt-in env var pointing at a recipient account; the probe
26// test always runs and pins the typed tags.
27const recipientAccountId =
28 process.env.CLOUDFLARE_TEST_SHARE_RECIPIENT_ACCOUNT_ID;
29
30const getShare = (accountId: string, shareId: string) =>
31 resourceSharing.getResourceSharing({ accountId, shareId }).pipe(
32 Effect.retry({
33 while: (e) => e._tag === "Forbidden",
34 schedule: Schedule.exponential("500 millis"),
35 times: 8,
36 }),
37 );
38
39// Poll until the share is gone after destroy. Deletion is asynchronous —
40// status walks `active → deleting → deleted`; a fully purged share answers
41// with the typed `ShareNotFound` (Cloudflare error code 1004).
42const expectGone = (accountId: string, shareId: string) =>
43 getShare(accountId, shareId).pipe(
44 Effect.flatMap((share) =>
45 share.status === "deleting" || share.status === "deleted"
46 ? Effect.void
47 : Effect.fail({ _tag: "ShareNotDeleted" } as const),
48 ),
49 Effect.catchTag("ShareNotFound", () => Effect.void),
50 Effect.retry({
51 while: (e) => e._tag === "ShareNotDeleted",
52 schedule: Schedule.max([
53 Schedule.exponential("500 millis"),
54 Schedule.recurs(10),
55 ]),
56 }),
57 );
58
59test.provider(
60 "reads succeed and write-blocked accounts surface the typed Forbidden error",
61 (stack) =>
62 Effect.gen(function* () {
63 const { accountId } = yield* yield* CloudflareEnvironment;
64
65 yield* stack.destroy();
66
67 // Read access works — list the account's sent shares.
68 const shares = yield* resourceSharing.listResourceSharings({
69 accountId,
70 kind: "sent",
71 });
72 expect(Array.isArray(shares.result)).toBe(true);
73
74 // A missing share surfaces as the typed `ShareNotFound`
75 // (Cloudflare error code 1004), not UnknownCloudflareError.
76 const notFound = yield* resourceSharing
77 .getResourceSharing({
78 accountId,
79 shareId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
80 })
81 .pipe(Effect.flip);
82 expect(notFound._tag).toEqual("ShareNotFound");
83
84 // Probe the write path. On the standard testing account this fails
85 // with the typed `Forbidden` — the token lacks Resource Sharing
86 // Edit. On an entitled token the dummy resource id fails validation
87 // instead (a non-Forbidden error) or, at worst, creates a share we
88 // immediately clean up.
89 const probe = yield* resourceSharing
90 .createResourceSharing({
91 accountId,
92 name: "alchemy-share-probe",
93 recipients: [{ accountId }],
94 resources: [
95 {
96 meta: {},
97 resourceAccountId: accountId,
98 resourceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
99 resourceType: "gateway-policy",
100 },
101 ],
102 })
103 .pipe(Effect.result);
104
105 if (Result.isSuccess(probe)) {
106 // Safety net: should the account ever become entitled, clean up
107 // the accidental share so the probe stays side-effect free.
108 yield* resourceSharing
109 .deleteResourceSharing({ accountId, shareId: probe.success.id })
110 .pipe(Effect.catchTag("ShareNotFound", () => Effect.void));
111 yield* Effect.logInfo(
112 "account can write shares; run the gated lifecycle tests via CLOUDFLARE_TEST_SHARE_RECIPIENT_ACCOUNT_ID",
113 );
114 return;
115 }
116
117 if (probe.failure._tag !== "Forbidden") {
118 // Entitled token — the dummy resource was rejected by validation
119 // rather than by the permission gate.
120 yield* Effect.logInfo(
121 `share write probe failed with ${probe.failure._tag}; writes are reachable`,
122 );
123 return;
124 }
125
126 // The typed tag — not UnknownCloudflareError, not a status check.
127 expect(probe.failure._tag).toEqual("Forbidden");
128
129 yield* stack.destroy();
130 }).pipe(logLevel),
131 {
132 tags: [
133 "provider:cloudflare",
134 "provider:cloudflare:resourcesharing",
135 "live",
136 ],
137 timeout: 120_000,
138 },
139);
140
141test.provider(
142 "list enumerates the account's sent shares (typed Attributes), and includes a deployed share when entitled",
143 (stack) =>
144 Effect.gen(function* () {
145 const { accountId } = yield* yield* CloudflareEnvironment;
146
147 yield* stack.destroy();
148
149 const provider = yield* Provider.findProvider(
150 Cloudflare.ResourceSharing.Share,
151 );
152
153 // Reads (list) succeed without the Resource Sharing Edit permission,
154 // so the read-only assertion always runs: list() returns the full,
155 // exhaustively-paginated set of sent shares in the read Attributes
156 // shape.
157 const before = yield* provider.list();
158 expect(Array.isArray(before)).toBe(true);
159 for (const share of before) {
160 expect(typeof share.shareId).toBe("string");
161 expect(share.accountId).toEqual(accountId);
162 expect(typeof share.name).toBe("string");
163 // Deleted shares are filtered out — never surfaced by list().
164 expect(share.status).not.toEqual("deleted");
165 }
166
167 // Writes are permission-gated (and need a second recipient account),
168 // so only assert presence of a freshly-deployed share when an entitled
169 // recipient account is provided. On the standard testing token the
170 // deploy would fail with the typed `Forbidden` (HTTP 403, code 10000).
171 if (recipientAccountId) {
172 const policy = yield* stack.deploy(
173 Cloudflare.Gateway.Rule("ListSharePolicy", {
174 action: "block",
175 traffic: 'dns.fqdn == "list-share.alchemy-test.example"',
176 filters: ["dns"],
177 enabled: false,
178 }),
179 );
180 const deployed = yield* stack.deploy(
181 Cloudflare.ResourceSharing.Share("ListShare", {
182 name: "alchemy-list-share",
183 recipients: [{ accountId: recipientAccountId }],
184 resources: [
185 { resourceType: "gateway-policy", resourceId: policy.ruleId },
186 ],
187 }),
188 );
189
190 const all = yield* provider.list();
191 expect(all.some((s) => s.shareId === deployed.shareId)).toBe(true);
192
193 yield* stack.destroy();
194 yield* expectGone(accountId, deployed.shareId);
195 }
196
197 yield* stack.destroy();
198 }).pipe(logLevel),
199 {
200 tags: [
201 "provider:cloudflare",
202 "provider:cloudflare:gateway",
203 "provider:cloudflare:resourcesharing",
204 "live",
205 ],
206 timeout: 180_000,
207 },
208);
209
210test.provider.skipIf(!recipientAccountId)(
211 "creates a share with a gateway policy, renames in place, adds and removes a resource, and destroys",
212 (stack) =>
213 Effect.gen(function* () {
214 const { accountId } = yield* yield* CloudflareEnvironment;
215 const recipient = recipientAccountId!;
216
217 yield* stack.destroy();
218
219 // A shareable resource: gateway policies are sharable on the test
220 // account's Zero Trust setup.
221 const policyA = yield* stack.deploy(
222 Cloudflare.Gateway.Rule("SharePolicyA", {
223 action: "block",
224 traffic: 'dns.fqdn == "share-a.alchemy-test.example"',
225 filters: ["dns"],
226 enabled: false,
227 }),
228 );
229 const policyB = yield* stack.deploy(
230 Cloudflare.Gateway.Rule("SharePolicyB", {
231 action: "block",
232 traffic: 'dns.fqdn == "share-b.alchemy-test.example"',
233 filters: ["dns"],
234 enabled: false,
235 }),
236 );
237
238 const share = yield* stack.deploy(
239 Cloudflare.ResourceSharing.Share("Share", {
240 name: "alchemy-resource-share",
241 recipients: [{ accountId: recipient }],
242 resources: [
243 { resourceType: "gateway-policy", resourceId: policyA.ruleId },
244 ],
245 }),
246 );
247 expect(share.shareId).toBeTruthy();
248 expect(share.accountId).toEqual(accountId);
249 expect(share.name).toEqual("alchemy-resource-share");
250 expect(share.status).toEqual("active");
251 expect(share.kind).toEqual("sent");
252
253 // Out-of-band verification via the distilled API.
254 const live = yield* getShare(accountId, share.shareId);
255 expect(live.name).toEqual("alchemy-resource-share");
256
257 // Rename in place — same shareId; also converge resources to
258 // include policy B (delta add through the resource sub-API).
259 const renamed = yield* stack.deploy(
260 Cloudflare.ResourceSharing.Share("Share", {
261 name: "alchemy-resource-share-v2",
262 recipients: [{ accountId: recipient }],
263 resources: [
264 { resourceType: "gateway-policy", resourceId: policyA.ruleId },
265 { resourceType: "gateway-policy", resourceId: policyB.ruleId },
266 ],
267 }),
268 );
269 expect(renamed.shareId).toEqual(share.shareId);
270 expect(renamed.name).toEqual("alchemy-resource-share-v2");
271
272 const resources = yield* resourceSharing.listResources({
273 accountId,
274 shareId: share.shareId,
275 });
276 const liveIds = resources.result
277 .filter((r) => r.status !== "deleted" && r.status !== "deleting")
278 .map((r) => r.resourceId)
279 .sort();
280 expect(liveIds).toEqual([policyA.ruleId, policyB.ruleId].sort());
281
282 // Drop policy B again — delta remove through the resource sub-API.
283 const reduced = yield* stack.deploy(
284 Cloudflare.ResourceSharing.Share("Share", {
285 name: "alchemy-resource-share-v2",
286 recipients: [{ accountId: recipient }],
287 resources: [
288 { resourceType: "gateway-policy", resourceId: policyA.ruleId },
289 ],
290 }),
291 );
292 expect(reduced.shareId).toEqual(share.shareId);
293
294 yield* stack.destroy();
295
296 yield* expectGone(accountId, share.shareId);
297 }).pipe(logLevel),
298 {
299 tags: [
300 "provider:cloudflare",
301 "provider:cloudflare:gateway",
302 "provider:cloudflare:resourcesharing",
303 "live",
304 ],
305 timeout: 180_000,
306 },
307);
308
309test.provider.skipIf(!recipientAccountId)(
310 "manages standalone ShareResource and ShareRecipient on an existing share",
311 (stack) =>
312 Effect.gen(function* () {
313 const { accountId } = yield* yield* CloudflareEnvironment;
314 const recipient = recipientAccountId!;
315
316 yield* stack.destroy();
317
318 const policyA = yield* stack.deploy(
319 Cloudflare.Gateway.Rule("ChildPolicyA", {
320 action: "block",
321 traffic: 'dns.fqdn == "child-a.alchemy-test.example"',
322 filters: ["dns"],
323 enabled: false,
324 }),
325 );
326 const policyB = yield* stack.deploy(
327 Cloudflare.Gateway.Rule("ChildPolicyB", {
328 action: "block",
329 traffic: 'dns.fqdn == "child-b.alchemy-test.example"',
330 filters: ["dns"],
331 enabled: false,
332 }),
333 );
334
335 const share = yield* stack.deploy(
336 Cloudflare.ResourceSharing.Share("ChildShare", {
337 name: "alchemy-child-share",
338 recipients: [{ accountId: recipient }],
339 resources: [
340 { resourceType: "gateway-policy", resourceId: policyA.ruleId },
341 ],
342 }),
343 );
344
345 // Standalone resource entry — adds policy B incrementally.
346 const entry = yield* stack.deploy(
347 Cloudflare.ResourceSharing.ShareResource("ChildEntry", {
348 shareId: share.shareId,
349 resourceType: "gateway-policy",
350 resourceId: policyB.ruleId,
351 }),
352 );
353 expect(entry.shareResourceId).toBeTruthy();
354 expect(entry.shareId).toEqual(share.shareId);
355 expect(entry.resourceId).toEqual(policyB.ruleId);
356
357 // Out-of-band verify the entry exists.
358 const liveEntry = yield* resourceSharing.getResource({
359 accountId,
360 shareId: share.shareId,
361 shareResourceId: entry.shareResourceId,
362 });
363 expect(liveEntry.resourceId).toEqual(policyB.ruleId);
364
365 yield* stack.destroy();
366
367 yield* expectGone(accountId, share.shareId);
368 }).pipe(logLevel),
369 {
370 tags: [
371 "provider:cloudflare",
372 "provider:cloudflare:gateway",
373 "provider:cloudflare:resourcesharing",
374 "live",
375 ],
376 timeout: 180_000,
377 },
378);