1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as user from "@distilled.cloud/cloudflare/user";
6import { describe, expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import { MinimumLogLevel } from "effect/References";
11import * as Result from "effect/Result";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: Cloudflare.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20describe.skip(
21 "UserApiToken",
22 { tags: ["provider:cloudflare", "provider:cloudflare:apitoken", "live"] },
23 () => {
24 test.provider("create and delete user token with default props", (stack) =>
25 Effect.gen(function* () {
26 const { accountId } = yield* yield* CloudflareEnvironment;
27
28 yield* stack.destroy();
29
30 const token = yield* stack.deploy(
31 Effect.gen(function* () {
32 return yield* Cloudflare.ApiToken.UserApiToken("DefaultUserToken", {
33 policies: [
34 {
35 effect: "allow",
36 permissionGroups: ["Workers Scripts Read"],
37 resources: {
38 [`com.cloudflare.api.account.${accountId}`]: "*",
39 },
40 },
41 ],
42 });
43 }),
44 );
45
46 expect(token.tokenId).toBeDefined();
47 expect(token.name).toBeDefined();
48 expect(token.status).toEqual("active");
49 expect(Redacted.value(token.value)).toMatch(/.+/);
50
51 const actualToken = yield* user.getToken({ tokenId: token.tokenId });
52 expect(actualToken.id).toEqual(token.tokenId);
53 expect(actualToken.name).toEqual(token.name);
54
55 yield* stack.destroy();
56
57 yield* waitForTokenToBeDeleted(token.tokenId);
58 }).pipe(logLevel),
59 );
60
61 test.provider("create, update, delete user token", (stack) =>
62 Effect.gen(function* () {
63 const { accountId } = yield* yield* CloudflareEnvironment;
64
65 yield* stack.destroy();
66
67 const token = yield* stack.deploy(
68 Effect.gen(function* () {
69 return yield* Cloudflare.ApiToken.UserApiToken("UpdateUserToken", {
70 name: "alchemy-test-user-update-initial",
71 policies: [
72 {
73 effect: "allow",
74 permissionGroups: ["Workers Scripts Read"],
75 resources: {
76 [`com.cloudflare.api.account.${accountId}`]: "*",
77 },
78 },
79 ],
80 });
81 }),
82 );
83
84 expect(token.name).toEqual("alchemy-test-user-update-initial");
85 const initialValue = Redacted.value(token.value);
86
87 const updated = yield* stack.deploy(
88 Effect.gen(function* () {
89 return yield* Cloudflare.ApiToken.UserApiToken("UpdateUserToken", {
90 name: "alchemy-test-user-update-renamed",
91 policies: [
92 {
93 effect: "allow",
94 permissionGroups: [
95 "Workers Scripts Read",
96 "Workers KV Storage Read",
97 ],
98 resources: {
99 [`com.cloudflare.api.account.${accountId}`]: "*",
100 },
101 },
102 ],
103 });
104 }),
105 );
106
107 expect(updated.tokenId).toEqual(token.tokenId);
108 expect(updated.name).toEqual("alchemy-test-user-update-renamed");
109 expect(Redacted.value(updated.value)).toEqual(initialValue);
110
111 const actual = yield* user.getToken({ tokenId: updated.tokenId });
112 expect(actual.name).toEqual("alchemy-test-user-update-renamed");
113 expect(actual.policies?.[0]?.permissionGroups.length).toEqual(2);
114
115 yield* stack.destroy();
116
117 yield* waitForTokenToBeDeleted(token.tokenId);
118 }).pipe(logLevel),
119 );
120
121 const waitForTokenToBeDeleted = Effect.fn(function* (tokenId: string) {
122 yield* user.getToken({ tokenId }).pipe(
123 Effect.flatMap(() => Effect.fail(new TokenStillExists())),
124 Effect.retry({
125 while: (e): e is TokenStillExists => e instanceof TokenStillExists,
126 schedule: Schedule.max([
127 Schedule.exponential(200),
128 Schedule.recurs(8),
129 ]),
130 }),
131 Effect.catchTag("TokenStillExists", () =>
132 Effect.die(
133 `Cloudflare API token ${tokenId} was not deleted after retries`,
134 ),
135 ),
136 Effect.catchTag("TokenNotFound", () => Effect.void),
137 Effect.catchTag("InvalidRoute", () => Effect.void),
138 );
139 });
140 },
141);
142
143describe(
144 "UserApiToken list",
145 { tags: ["provider:cloudflare", "provider:cloudflare:apitoken", "live"] },
146 () => {
147 // Read-only: `GET /user/tokens` requires the authenticated *user's* identity.
148 // The standing testing profile authenticates with a scoped API token that
149 // lacks the `API Tokens > Read` user permission, so the live call rejects
150 // with the typed error:
151 // Unauthorized: Unauthorized to access requested resource (_tag: "Unauthorized")
152 // The `list()` impl is correct (it propagates the typed error rather than
153 // masking it as []), so we gate the live assertion behind an env var an
154 // entitled (user-API-key) credential can set. The test only asserts the
155 // provider exhaustively enumerates into the read-shaped Attributes.
156 test.provider.skipIf(!process.env.CLOUDFLARE_TEST_USER_TOKENS)(
157 "list enumerates user tokens",
158 () =>
159 Effect.gen(function* () {
160 const provider = yield* Provider.findProvider(
161 Cloudflare.ApiToken.UserApiToken,
162 );
163 const all = yield* provider.list();
164
165 expect(Array.isArray(all)).toBe(true);
166 for (const token of all) {
167 expect(typeof token.tokenId).toBe("string");
168 expect(typeof token.name).toBe("string");
169 expect(["active", "disabled", "expired"]).toContain(token.status);
170 // List never returns the plaintext value; read shape is preserved
171 // with an empty Redacted secret.
172 expect(typeof Redacted.value(token.value)).toBe("string");
173 }
174 }).pipe(logLevel),
175 );
176 },
177);
178
179// Ungated probe: the standing scoped-token testing profile must surface the
180// auth gap as the typed `Unauthorized` tag (not an UnknownCloudflareError),
181// proving the error is in the typed union and the gating above is correct.
182describe(
183 "UserApiToken list probe",
184 { tags: ["provider:cloudflare", "provider:cloudflare:apitoken", "live"] },
185 () => {
186 test.provider(
187 "list rejects with typed Unauthorized under scoped token",
188 () =>
189 Effect.gen(function* () {
190 const provider = yield* Provider.findProvider(
191 Cloudflare.ApiToken.UserApiToken,
192 );
193 const result = yield* Effect.result(provider.list());
194 if (Result.isSuccess(result)) {
195 // An entitled credential can list — that's fine, nothing to assert.
196 expect(Array.isArray(result.success)).toBe(true);
197 return;
198 }
199 expect(result.failure._tag).toBe("Unauthorized");
200 }).pipe(logLevel),
201 );
202 },
203);
204
205class TokenStillExists extends Data.TaggedError("TokenStillExists") {}