1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { LOCAL_ACCOUNT_ID } from "@/Cloudflare/LocalAccount.ts";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as ConfigProvider from "effect/ConfigProvider";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Result from "effect/Result";
11import * as HttpClient from "effect/http/HttpClient";
12
13const noCredentials = Layer.effect(
14 ConfigProvider.ConfigProvider,
15 Effect.gen(function* () {
16 const base = yield* ConfigProvider.ConfigProvider;
17 return ConfigProvider.make((path) => {
18 const key = path[0];
19 if (path.length === 1 && typeof key === "string") {
20 if (key.startsWith("CLOUDFLARE_")) return Effect.succeed(undefined);
21 if (key === "CI")
22 return Effect.succeed(ConfigProvider.makeValue("false"));
23 if (key === "ALCHEMY_PROFILE")
24 return Effect.succeed(
25 ConfigProvider.makeValue("cloudflare-credential-free-test"),
26 );
27 }
28 return base.load(path);
29 });
30 }),
31);
32
33const { test } = Test.make({
34 providers: Cloudflare.providers().pipe(Layer.provide(noCredentials)),
35 dev: true,
36 profile: "cloudflare-credential-free-test",
37});
38
39test.provider(
40 "local Worker and Action storage clients work without Cloudflare credentials",
41 (stack) =>
42 Effect.gen(function* () {
43 yield* stack.destroy();
44 const program = Effect.gen(function* () {
45 const kv = yield* Cloudflare.KV.Namespace("KV");
46 const bucket = yield* Cloudflare.R2.Bucket("Bucket");
47 const db = yield* Cloudflare.D1.Database("Database");
48 const queue = yield* Cloudflare.Queues.Queue("Queue");
49 const store = yield* Cloudflare.SecretsStore.Store("Store");
50 const Seed = Action(
51 "Seed",
52 Effect.gen(function* () {
53 const namespace = yield* Cloudflare.KV.ReadWriteNamespace(kv);
54 const objects = yield* Cloudflare.R2.ReadWriteBucket(bucket);
55 const database = yield* Cloudflare.D1.QueryDatabase(db);
56 return Effect.fn(function* () {
57 yield* namespace.put("seed", "credential-free");
58 yield* objects.put("seed", "credential-free");
59 yield* database.exec(
60 "CREATE TABLE IF NOT EXISTS seed (value TEXT)",
61 );
62 yield* database.exec("DELETE FROM seed");
63 yield* database
64 .prepare("INSERT INTO seed VALUES (?)")
65 .bind("credential-free")
66 .run();
67 return { value: yield* namespace.get("seed") };
68 });
69 }).pipe(
70 Effect.provide(
71 Layer.mergeAll(
72 Cloudflare.KV.ReadWriteNamespaceLocal,
73 Cloudflare.R2.ReadWriteBucketLocal,
74 Cloudflare.D1.QueryDatabaseLocal,
75 ),
76 ),
77 ),
78 );
79 const seeded = yield* Seed({});
80 const worker = yield* Cloudflare.Worker("Worker", {
81 main: `${import.meta.dirname}/fixtures/credential-free-worker.ts`,
82 env: { KV: kv, BUCKET: bucket, DB: db },
83 });
84 const consumer = yield* Cloudflare.Queues.Consumer("Consumer", {
85 queueId: queue.queueId,
86 scriptName: worker.workerName,
87 });
88 return { kv, bucket, db, queue, store, worker, consumer, seeded };
89 });
90 const deployed = yield* stack.deploy(program);
91 for (const resource of [
92 deployed.kv,
93 deployed.bucket,
94 deployed.db,
95 deployed.queue,
96 deployed.store,
97 deployed.worker,
98 deployed.consumer,
99 ]) {
100 expect(resource.accountId).toBe(LOCAL_ACCOUNT_ID);
101 }
102 expect(deployed.seeded.value).toBe("credential-free");
103 const http = yield* HttpClient.HttpClient;
104 const response = yield* http.get(deployed.worker.url!);
105 expect(response.status).toBe(200);
106 expect(yield* response.json).toEqual({
107 kv: "credential-free",
108 r2: "credential-free",
109 d1: "credential-free",
110 });
111 const again = yield* stack.deploy(program);
112 expect(again.kv.namespaceId).toBe(deployed.kv.namespaceId);
113 expect(again.queue.queueId).toBe(deployed.queue.queueId);
114 yield* stack.destroy();
115 }).pipe(Effect.provide(noCredentials)),
116 { tags: ["local", "provider:cloudflare"], timeout: 120_000 },
117);
118
119test.provider(
120 "remote Cloudflare resources still require credentials",
121 (stack) =>
122 Effect.gen(function* () {
123 yield* stack.destroy();
124 const result = yield* Effect.result(
125 stack.deploy(
126 Cloudflare.KV.Namespace("RemoteNamespace").pipe(Alchemy.remote()),
127 ),
128 );
129 expect(Result.isFailure(result)).toBe(true);
130 if (Result.isFailure(result)) {
131 expect(result.failure).toMatchObject({
132 _tag: "CredentialsRequired",
133 provider: "Cloudflare",
134 reason: "remote",
135 });
136 }
137 yield* stack.destroy();
138 }).pipe(Effect.provide(noCredentials)),
139 { tags: ["local", "provider:cloudflare"], timeout: 60_000 },
140);
141
142test.provider(
143 "remote bindings still require credentials before starting a local Worker",
144 (stack) =>
145 Effect.gen(function* () {
146 yield* stack.destroy();
147 const result = yield* Effect.result(
148 stack.deploy(
149 Cloudflare.Worker("RemoteBindingWorker", {
150 main: `${import.meta.dirname}/fixtures/credential-free-worker.ts`,
151 env: {
152 BROWSER: Cloudflare.Browser("BROWSER").pipe(Alchemy.remote()),
153 },
154 }),
155 ),
156 );
157 expect(Result.isFailure(result)).toBe(true);
158 if (Result.isFailure(result)) {
159 expect(result.failure).toMatchObject({
160 _tag: "CredentialsRequired",
161 provider: "Cloudflare",
162 reason: "remote-binding",
163 });
164 }
165 yield* stack.destroy();
166 }).pipe(Effect.provide(noCredentials)),
167 { tags: ["local", "provider:cloudflare"], timeout: 60_000 },
168);
169
170test.provider(
171 "Vite child starts without Cloudflare credentials",
172 (stack) =>
173 Effect.gen(function* () {
174 yield* stack.destroy();
175 const site = yield* stack.deploy(
176 Cloudflare.Website.Vite("Site", {
177 rootDir: `${import.meta.dirname}/../Website/vite-queue-fixture`,
178 main: "worker.ts",
179 compatibility: { flags: ["nodejs_compat"] },
180 assets: { runWorkerFirst: true },
181 dev: { port: 0 },
182 }),
183 );
184 expect(site.accountId).toBe(LOCAL_ACCOUNT_ID);
185 const http = yield* HttpClient.HttpClient;
186 const response = yield* http.get(`${site.url}/api/received`);
187 expect(response.status).toBe(200);
188 expect(yield* response.json).toEqual({ received: [] });
189 yield* stack.destroy();
190 }).pipe(Effect.provide(noCredentials)),
191 { tags: ["local", "provider:cloudflare"], timeout: 120_000 },
192);
193
194const configuredAccount = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
195const accountOnly = ConfigProvider.layer(
196 ConfigProvider.fromUnknown({
197 CI: true,
198 CLOUDFLARE_ACCOUNT_ID: configuredAccount,
199 }),
200);
201const configured = Test.make({
202 providers: Cloudflare.providers().pipe(Layer.provide(accountOnly)),
203 dev: true,
204 sidecar: false,
205});
206
207configured.test.provider(
208 "configured account propagates to local resources without credentials",
209 (stack) =>
210 Effect.gen(function* () {
211 yield* stack.destroy();
212 const outputs = yield* stack.deploy(
213 Effect.gen(function* () {
214 const kv = yield* Cloudflare.KV.Namespace("ConfiguredKV");
215 const worker = yield* Cloudflare.Worker("ConfiguredWorker", {
216 dev: { mode: "external", url: "http://localhost:4321" },
217 });
218 return { kv, worker };
219 }),
220 );
221 expect(outputs.kv.accountId).toBe(configuredAccount);
222 expect(outputs.worker.accountId).toBe(configuredAccount);
223 yield* stack.destroy();
224 }).pipe(Effect.provide(accountOnly)),
225 { tags: ["local", "provider:cloudflare"], timeout: 60_000 },
226);