| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as resourceSharing from "@distilled.cloud/cloudflare/resource-sharing"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Result from "effect/Result"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 13 | |
| 14 | const 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. |
| 27 | const recipientAccountId = |
| 28 | process.env.CLOUDFLARE_TEST_SHARE_RECIPIENT_ACCOUNT_ID; |
| 29 | |
| 30 | const 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). |
| 42 | const 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 | |
| 59 | test.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 | |
| 141 | test.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 | |
| 210 | test.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 | |
| 309 | test.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 | ); |