1import { AlchemyContext } from "@/AlchemyContext.ts";
2import { resolveStateStoreScope } from "@/Alchemist/routes/cloudflare.ts";
3import { CredentialsStoreLive } from "@/Auth/Credentials.ts";
4import { ProfileStore, ProfileStoreLive } from "@/Auth/Profile.ts";
5import { CredentialsUnavailable } from "@/Auth/Resolve.ts";
6import * as Interaction from "@/Interaction.ts";
7import { PlatformServices } from "@/Util/PlatformServices.ts";
8import { expect, it } from "alchemy-test";
9import * as Cause from "effect/Cause";
10import * as Exit from "effect/Exit";
11import * as ConfigProvider from "effect/ConfigProvider";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16
17const STAGING_ACCOUNT = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
18const DEFAULT_ACCOUNT = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
19
20const CLOUDFLARE_ENV_KEYS = [
21 "CI",
22 "ALCHEMY_PROFILE",
23 "CLOUDFLARE_ACCOUNT_ID",
24 "CLOUDFLARE_API_TOKEN",
25 "CLOUDFLARE_API_KEY",
26 "CLOUDFLARE_EMAIL",
27 "CLOUDFLARE_ACCOUNT_EMAIL",
28] as const;
29
30/**
31 * Isolate `ALCHEMY_HOME` and strip env credentials / `CI` so
32 * `resolveProviderConfig` actually reads the profile store. The alchemy-test
33 * runner sets `CI=true` and `--profile testing` exports `ALCHEMY_PROFILE`,
34 * both of which would otherwise skip the named-profile path under test.
35 *
36 * An empty `--env-file` in the temp home is passed through so
37 * `loadConfigProvider` never falls back to the checkout's cwd `.env`.
38 */
39const withIsolatedHome = <A, E, R>(
40 effect: (envFile: string) => Effect.Effect<A, E, R>,
41) =>
42 Effect.gen(function* () {
43 const fs = yield* FileSystem.FileSystem;
44 const path = yield* Path.Path;
45 const dir = yield* fs.makeTempDirectoryScoped({
46 prefix: "alchemy-cf-bootstrap-profile-",
47 });
48 const envFile = path.join(dir, "empty.env");
49 yield* fs.writeFileString(envFile, "");
50 const previous: Record<string, string | undefined> = {
51 ALCHEMY_HOME: process.env.ALCHEMY_HOME,
52 };
53 for (const key of CLOUDFLARE_ENV_KEYS) {
54 previous[key] = process.env[key];
55 }
56 yield* Effect.acquireRelease(
57 Effect.sync(() => {
58 process.env.ALCHEMY_HOME = dir;
59 for (const key of CLOUDFLARE_ENV_KEYS) {
60 delete process.env[key];
61 }
62 }),
63 () =>
64 Effect.sync(() => {
65 for (const [key, value] of Object.entries(previous)) {
66 if (value === undefined) delete process.env[key];
67 else process.env[key] = value;
68 }
69 }),
70 );
71 return yield* effect(envFile).pipe(
72 Effect.provide(
73 Layer.mergeAll(
74 Layer.provide(ProfileStoreLive, PlatformServices),
75 Layer.provide(CredentialsStoreLive, PlatformServices),
76 Layer.succeed(AlchemyContext, {
77 dotAlchemy: dir,
78 dev: false,
79 adopt: false,
80 }),
81 Interaction.layerNonInteractive(),
82 ConfigProvider.layer(ConfigProvider.fromUnknown({})),
83 ),
84 ),
85 );
86 }).pipe(Effect.scoped, Effect.provide(PlatformServices));
87
88const storedToken = (accountId: string, apiToken: string) => ({
89 method: "stored" as const,
90 credentialType: "apiToken" as const,
91 apiToken,
92 accountId,
93});
94
95it.live(
96 "cloudflare bootstrap --profile resolves that profile's credentials, not default (#252)",
97 () =>
98 withIsolatedHome((envFile) =>
99 Effect.gen(function* () {
100 const profiles = yield* ProfileStore;
101 yield* profiles.createProfile("staging");
102 yield* profiles.setProviderConfig(
103 "staging",
104 "Cloudflare",
105 storedToken(STAGING_ACCOUNT, "staging-token"),
106 );
107 yield* profiles.setProviderConfig(
108 "default",
109 "Cloudflare",
110 storedToken(DEFAULT_ACCOUNT, "default-token"),
111 );
112
113 const scoped = yield* resolveStateStoreScope({
114 profile: "staging",
115 envFile,
116 });
117 expect(scoped.profile).toBe("staging");
118 expect(scoped.accountId).toBe(STAGING_ACCOUNT);
119 }),
120 ),
121 { tags: ["unit", "local"], exclusive: true },
122);
123
124it.live(
125 "cloudflare bootstrap without --profile uses the default profile",
126 () =>
127 withIsolatedHome((envFile) =>
128 Effect.gen(function* () {
129 const profiles = yield* ProfileStore;
130 yield* profiles.setProviderConfig(
131 "default",
132 "Cloudflare",
133 storedToken(DEFAULT_ACCOUNT, "default-token"),
134 );
135
136 const scoped = yield* resolveStateStoreScope({ envFile });
137 expect(scoped.profile).toBe("default");
138 expect(scoped.accountId).toBe(DEFAULT_ACCOUNT);
139 }),
140 ),
141 { tags: ["unit", "local"], exclusive: true },
142);
143
144it.live(
145 "cloudflare bootstrap --profile does not fall back to an unconfigured default",
146 () =>
147 withIsolatedHome((envFile) =>
148 Effect.gen(function* () {
149 const profiles = yield* ProfileStore;
150 yield* profiles.createProfile("staging");
151 yield* profiles.setProviderConfig(
152 "staging",
153 "Cloudflare",
154 storedToken(STAGING_ACCOUNT, "staging-token"),
155 );
156
157 const scoped = yield* resolveStateStoreScope({
158 profile: "staging",
159 envFile,
160 });
161 expect(scoped.accountId).toBe(STAGING_ACCOUNT);
162
163 // Environment resolution is lazy: missing auth surfaces when the
164 // account is demanded, through the environment's defect channel.
165 const missing = yield* Effect.exit(resolveStateStoreScope({ envFile }));
166 expect(Exit.isFailure(missing)).toBe(true);
167 if (Exit.isFailure(missing)) {
168 const error = Cause.squash(missing.cause);
169 expect(error).toBeInstanceOf(CredentialsUnavailable);
170 expect(String(error)).toContain("profile 'default'");
171 }
172 }),
173 ),
174 { tags: ["unit", "local"], exclusive: true },
175);