1import {
2 AuthError,
3 AuthProvider,
4 AuthProviderLayer,
5 AuthProviders,
6 getAuthProvider,
7} from "@/Auth/AuthProvider.ts";
8import {
9 configFilePath,
10 makeProviderProfileSchema,
11 PROFILE_FORMAT,
12 profileDirPath,
13 profileProviderFilePath,
14 ProfileError,
15 ProfileStore,
16 ProfileStoreLive,
17 SuppressMissingProviderConfig,
18 validateProfileName,
19} from "@/Auth/Profile.ts";
20import { resolveProfileName, resolveProviderConfig } from "@/Auth/Resolve.ts";
21import * as NodeServices from "@effect/platform-node/NodeServices";
22import { expect, it } from "alchemy-test";
23import * as ConfigProvider from "effect/ConfigProvider";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Logger from "effect/Logger";
28import * as Option from "effect/Option";
29import * as Schema from "effect/Schema";
30import path from "pathe";
31import { messageForCapabilities } from "@/Util/interactive.ts";
32
33const FAKE_PROVIDER = "FakeAuthProvider";
34
35it.effect(
36 "selects guidance from injected interaction capabilities",
37 () =>
38 Effect.gen(function* () {
39 expect(
40 yield* messageForCapabilities(
41 Effect.succeed({ input: true }),
42 "interactive",
43 "plain",
44 ),
45 ).toBe("interactive");
46 expect(
47 yield* messageForCapabilities(
48 Effect.succeed({ input: false }),
49 "interactive",
50 "plain",
51 ),
52 ).toBe("plain");
53 }),
54 { tags: ["unit", "local"] },
55);
56
57// Records whether the lock-wrapped `configure` was ever entered. A missing
58// profile must short-circuit before provider configuration starts.
59const state = { configureCalls: 0 };
60
61const FakeAuth = AuthProviderLayer<{ method: "stored" }, undefined>()(
62 FAKE_PROVIDER,
63 {
64 configSchema: Schema.Struct({ method: Schema.Literal("stored") }),
65 configure: () =>
66 Effect.sync(() => {
67 state.configureCalls += 1;
68 return { method: "stored" as const };
69 }),
70 login: () => Effect.void,
71 logout: () => Effect.void,
72 details: () => Effect.succeed({ lines: [] }),
73 read: () => Effect.succeed(undefined),
74 },
75);
76
77const ENV_PROVIDER = "FakeEnvAuthProvider";
78
79/** A provider that supports both profile and environment credentials. */
80const FakeEnvAuth = AuthProviderLayer<{ method: "stored" }, string>()(
81 ENV_PROVIDER,
82 {
83 configSchema: Schema.Struct({ method: Schema.Literal("stored") }),
84 configure: () => Effect.succeed({ method: "stored" as const }),
85 login: () => Effect.void,
86 logout: () => Effect.void,
87 details: () => Effect.succeed({ lines: [] }),
88 read: () => Effect.succeed("profile-credentials"),
89 readEnvironment: Effect.succeed("environment-credentials"),
90 environment: [{ name: "FAKE_ENV_TOKEN", required: true, secret: true }],
91 },
92);
93
94const makeTestLayer = (config: Record<string, unknown> = {}) =>
95 Layer.mergeAll(ProfileStoreLive, FakeAuth, FakeEnvAuth).pipe(
96 Layer.provideMerge(
97 Layer.mergeAll(
98 Layer.succeed(AuthProviders, {}),
99 ConfigProvider.layer(ConfigProvider.fromUnknown(config)),
100 NodeServices.layer,
101 ),
102 ),
103 );
104
105/**
106 * Point `ALCHEMY_HOME` at a scoped temp directory for the duration of
107 * `effect`, so store operations never touch the developer's real
108 * `~/.alchemy`. Tests using this must be `exclusive` — the env var is
109 * process-global.
110 */
111const withTempHome = <A, E, R>(
112 effect: Effect.Effect<A, E, R>,
113 config: Record<string, unknown> = {},
114) =>
115 Effect.gen(function* () {
116 const fs = yield* FileSystem.FileSystem;
117 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-auth-" });
118 const previous = process.env.ALCHEMY_HOME;
119 yield* Effect.acquireRelease(
120 Effect.sync(() => {
121 process.env.ALCHEMY_HOME = dir;
122 }),
123 () =>
124 Effect.sync(() => {
125 if (previous === undefined) delete process.env.ALCHEMY_HOME;
126 else process.env.ALCHEMY_HOME = previous;
127 }),
128 );
129 return yield* effect.pipe(Effect.provide(makeTestLayer(config)));
130 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
131
132it.live(
133 "loadProviderConfig requires profiles to be explicitly created",
134 () =>
135 withTempHome(
136 Effect.gen(function* () {
137 state.configureCalls = 0;
138 const profile = yield* ProfileStore;
139 const auth = yield* getAuthProvider<{ method: "stored" }, undefined>(
140 FAKE_PROVIDER,
141 );
142
143 const error = yield* profile
144 .loadProviderConfig(auth, "non-existent")
145 .pipe(Effect.flip);
146
147 expect(error).toBeInstanceOf(ProfileError);
148 expect((error as ProfileError).message).toContain("profile create");
149 // The lock-wrapped `configure` must never run.
150 expect(state.configureCalls).toBe(0);
151 }),
152 ),
153 { tags: ["unit", "local"], exclusive: true },
154);
155
156it.live(
157 "loadProviderConfig never configures a missing provider implicitly",
158 () =>
159 withTempHome(
160 Effect.gen(function* () {
161 state.configureCalls = 0;
162 const profile = yield* ProfileStore;
163 const auth = yield* getAuthProvider<{ method: "stored" }, undefined>(
164 FAKE_PROVIDER,
165 );
166 yield* profile.createProfile("explicit-login");
167
168 const error = yield* profile
169 .loadProviderConfig(auth, "explicit-login")
170 .pipe(Effect.flip);
171
172 expect(error).toBeInstanceOf(AuthError);
173 expect((error as AuthError).message).toContain(
174 `alchemy profile edit --profile explicit-login --add ${FAKE_PROVIDER}`,
175 );
176 expect(state.configureCalls).toBe(0);
177 }),
178 ),
179 { tags: ["unit", "local"], exclusive: true },
180);
181
182it.live(
183 "the built-in default profile always exists",
184 () =>
185 withTempHome(
186 Effect.gen(function* () {
187 const profile = yield* ProfileStore;
188 const fs = yield* FileSystem.FileSystem;
189 // Never written to disk, yet the reader still presents `default`
190 // with its deterministic id.
191 const manifest = yield* profile.readManifest;
192 expect(Object.keys(manifest.profiles)).toEqual(["default"]);
193 expect(manifest.profiles.default!.id).toBe("default");
194 expect(yield* fs.exists(profileDirPath("default"))).toBe(true);
195 expect(yield* profile.ensureProfile("default")).toEqual({
196 id: "default",
197 providers: {},
198 });
199 // With no explicit selection every command lands on `default`.
200 const selection = yield* profile.current;
201 expect(selection).toEqual({ name: "default", source: "default" });
202 }),
203 ),
204 { tags: ["unit", "local"], exclusive: true },
205);
206
207it.live(
208 "the default profile cannot be deleted, renamed, or shadowed",
209 () =>
210 withTempHome(
211 Effect.gen(function* () {
212 const fs = yield* FileSystem.FileSystem;
213 const profile = yield* ProfileStore;
214 // Creating a profile only creates its directory. The built-in
215 // default remains synthesized until it has a provider file.
216 yield* profile.createProfile("work");
217
218 const renameError = yield* profile
219 .renameProfile("default", "other")
220 .pipe(Effect.flip);
221 expect(renameError).toBeInstanceOf(ProfileError);
222 expect((renameError as ProfileError).message).toContain(
223 "Cannot rename",
224 );
225
226 const deleteError = yield* profile
227 .deleteProfile("default")
228 .pipe(Effect.flip);
229 expect(deleteError).toBeInstanceOf(ProfileError);
230 expect((deleteError as ProfileError).message).toContain(
231 "Cannot delete",
232 );
233
234 const createError = yield* profile
235 .createProfile("default")
236 .pipe(Effect.flip);
237 expect((createError as ProfileError).message).toContain(
238 "already exists",
239 );
240
241 const shadowError = yield* profile
242 .renameProfile("work", "default")
243 .pipe(Effect.flip);
244 expect((shadowError as ProfileError).message).toContain(
245 "already exists",
246 );
247
248 expect(yield* fs.exists(profileDirPath("work"))).toBe(true);
249 expect(yield* fs.exists(configFilePath())).toBe(false);
250 }),
251 ),
252 { tags: ["unit", "local"], exclusive: true },
253);
254
255it.live(
256 "migrates centralized manifests into provider files",
257 () =>
258 withTempHome(
259 Effect.gen(function* () {
260 const fs = yield* FileSystem.FileSystem;
261 const profile = yield* ProfileStore;
262 yield* fs.makeDirectory(path.dirname(configFilePath()), {
263 recursive: true,
264 });
265 yield* fs.writeFileString(
266 configFilePath(),
267 JSON.stringify({
268 version: 0,
269 futureField: { anything: true },
270 profiles: {
271 legacy: {
272 Cloudflare: { method: "oauth", scopes: ["d1.write"] },
273 },
274 },
275 }),
276 );
277
278 const manifest = yield* profile.readManifest;
279 expect(manifest.profiles.legacy!.id).toBe("legacy");
280 expect(manifest.profiles.legacy!.providers.Cloudflare).toEqual({
281 method: "oauth",
282 });
283 expect(manifest.profiles.default!.id).toBe("default");
284
285 const providerFile = JSON.parse(
286 yield* fs.readFileString(
287 profileProviderFilePath("legacy", "Cloudflare"),
288 ),
289 );
290 expect(providerFile).toEqual({
291 format: PROFILE_FORMAT,
292 provider: "Cloudflare",
293 metadata: {},
294 values: { method: "oauth" },
295 });
296 expect(yield* fs.exists(configFilePath())).toBe(false);
297 expect(
298 (yield* fs.readDirectory(path.dirname(configFilePath()))).filter(
299 (entry) => entry.startsWith(".profiles-v0-"),
300 ),
301 ).toHaveLength(1);
302 }),
303 ),
304 { tags: ["unit", "local"], exclusive: true },
305);
306
307it.live(
308 "ignores unreleased centralized manifest versions",
309 () =>
310 withTempHome(
311 Effect.gen(function* () {
312 const fs = yield* FileSystem.FileSystem;
313 const profile = yield* ProfileStore;
314 yield* fs.makeDirectory(path.dirname(configFilePath()), {
315 recursive: true,
316 });
317 yield* fs.writeFileString(
318 configFilePath(),
319 JSON.stringify({
320 version: 1,
321 defaultProfile: "work",
322 profiles: { work: {} },
323 }),
324 );
325
326 // Only released v0 is migrated. The short-lived centralized v1/v2
327 // formats are left untouched and do not enter the directory store.
328 const manifest = yield* profile.readManifest;
329 expect(manifest.profiles.work).toBeUndefined();
330 expect((yield* profile.current).name).toBe("default");
331
332 yield* profile.createProfile("scratch");
333 expect(yield* fs.exists(profileDirPath("work"))).toBe(false);
334 expect(yield* fs.exists(profileDirPath("scratch"))).toBe(true);
335 expect(yield* fs.exists(configFilePath())).toBe(true);
336 }),
337 ),
338 { tags: ["unit", "local"], exclusive: true },
339);
340
341it.live(
342 "backs up invalid provider files and continues loading the profile",
343 () =>
344 withTempHome(
345 Effect.gen(function* () {
346 const fs = yield* FileSystem.FileSystem;
347 const profiles = yield* ProfileStore;
348 yield* fs.writeFileString(
349 profileProviderFilePath("default", "Cloudflare"),
350 "{not-json",
351 );
352 yield* fs.writeFileString(
353 profileProviderFilePath("default", "Other"),
354 JSON.stringify({
355 format: PROFILE_FORMAT,
356 provider: "Other",
357 metadata: {},
358 values: { method: "stored" },
359 }),
360 );
361
362 const manifest = yield* profiles.readManifest;
363 expect(manifest.profiles.default!.providers.Cloudflare).toBeUndefined();
364 expect(manifest.profiles.default!.providers.Other).toEqual({
365 method: "stored",
366 });
367 const files = yield* fs.readDirectory(profileDirPath("default"));
368 expect(files).toContain("other.json");
369 expect(
370 files.some((file) => file.startsWith("cloudflare.json.invalid-")),
371 ).toBe(true);
372 }),
373 ),
374 { tags: ["unit", "local"], exclusive: true },
375);
376
377it.live(
378 "backs up an invalid provider file before reconfiguration replaces it",
379 () =>
380 withTempHome(
381 Effect.gen(function* () {
382 const fs = yield* FileSystem.FileSystem;
383 const profiles = yield* ProfileStore;
384 const file = profileProviderFilePath("default", "Cloudflare");
385 yield* fs.writeFileString(file, "{not-json");
386
387 yield* profiles.setProviderConfig("default", "Cloudflare", {
388 method: "oauth",
389 });
390
391 expect(JSON.parse(yield* fs.readFileString(file))).toEqual({
392 format: PROFILE_FORMAT,
393 provider: "Cloudflare",
394 metadata: {},
395 values: { method: "oauth" },
396 });
397 expect(
398 (yield* fs.readDirectory(profileDirPath("default"))).some((entry) =>
399 entry.startsWith("cloudflare.json.invalid-"),
400 ),
401 ).toBe(true);
402 }),
403 ),
404 { tags: ["unit", "local"], exclusive: true },
405);
406
407it.live(
408 "legacy env-method entries are dropped on read",
409 () =>
410 withTempHome(
411 Effect.gen(function* () {
412 const fs = yield* FileSystem.FileSystem;
413 const profile = yield* ProfileStore;
414 const auth = yield* getAuthProvider<{ method: "stored" }, undefined>(
415 FAKE_PROVIDER,
416 );
417 yield* fs.makeDirectory(path.dirname(configFilePath()), {
418 recursive: true,
419 });
420 yield* fs.writeFileString(
421 configFilePath(),
422 JSON.stringify({
423 version: 0,
424 profiles: {
425 ci: {
426 [FAKE_PROVIDER]: { method: "env" },
427 Other: { method: "stored" },
428 },
429 },
430 }),
431 );
432
433 // The env-backed entry never surfaces: not in the read manifest...
434 const manifest = yield* profile.readManifest;
435 expect(manifest.profiles.ci!.providers[FAKE_PROVIDER]).toBeUndefined();
436 expect(manifest.profiles.ci!.providers.Other).toEqual({
437 method: "stored",
438 });
439
440 // ...and credential resolution reports "not configured" with the
441 // hint to connect the provider, not a legacy-env special case.
442 const error = yield* profile
443 .loadProviderConfig(auth, "ci")
444 .pipe(Effect.flip);
445 expect(error).toBeInstanceOf(AuthError);
446 expect((error as AuthError).message).toContain("--add");
447
448 expect(
449 yield* fs.exists(profileProviderFilePath("ci", FAKE_PROVIDER)),
450 ).toBe(false);
451 expect(yield* fs.exists(profileProviderFilePath("ci", "Other"))).toBe(
452 true,
453 );
454 }),
455 ),
456 { tags: ["unit", "local"], exclusive: true },
457);
458
459it.effect(
460 "accepts portable profile names",
461 () =>
462 Effect.gen(function* () {
463 expect(yield* validateProfileName("production-admin")).toBe(
464 "production-admin",
465 );
466 expect(yield* validateProfileName("team.prod_2")).toBe("team.prod_2");
467 }),
468 { tags: ["unit", "local"] },
469);
470
471it.effect(
472 "lets custom providers refine metadata and values",
473 () =>
474 Effect.gen(function* () {
475 const CustomProfile = makeProviderProfileSchema(
476 "Acme",
477 Schema.Struct({ team: Schema.String }),
478 Schema.Union([
479 Schema.Struct({
480 method: Schema.Literal("token"),
481 token: Schema.String,
482 }),
483 Schema.Struct({
484 method: Schema.Literal("oauth"),
485 access: Schema.String,
486 refresh: Schema.String,
487 scopes: Schema.Array(Schema.String),
488 }),
489 ]),
490 );
491 expect(
492 yield* Schema.decodeUnknownEffect(CustomProfile)({
493 format: PROFILE_FORMAT,
494 provider: "Acme",
495 metadata: { team: "platform" },
496 values: { method: "token", token: "secret" },
497 }),
498 ).toEqual({
499 format: PROFILE_FORMAT,
500 provider: "Acme",
501 metadata: { team: "platform" },
502 values: { method: "token", token: "secret" },
503 });
504 }),
505 { tags: ["unit", "local"] },
506);
507
508it.effect(
509 "rejects profile names that can escape the credential directory",
510 () =>
511 Effect.gen(function* () {
512 for (const name of ["..", "../..", "team/prod", "/tmp/profile", ""]) {
513 const error = yield* validateProfileName(name).pipe(Effect.flip);
514 expect(error).toBeInstanceOf(ProfileError);
515 }
516 }),
517 { tags: ["unit", "local"] },
518);
519
520it.effect(
521 "preserves CLI and explicit env-file precedence over the process profile",
522 () =>
523 Effect.gen(function* () {
524 yield* Effect.acquireRelease(
525 Effect.sync(() => {
526 const previous = process.env.ALCHEMY_PROFILE;
527 delete process.env.ALCHEMY_PROFILE;
528 return previous;
529 }),
530 (previous) =>
531 Effect.sync(() => {
532 if (previous === undefined) delete process.env.ALCHEMY_PROFILE;
533 else process.env.ALCHEMY_PROFILE = previous;
534 }),
535 );
536 const fs = yield* FileSystem.FileSystem;
537 const file = yield* fs.makeTempFileScoped();
538 yield* fs.writeFileString(file, "ALCHEMY_PROFILE=from-env-file\n");
539
540 expect(yield* resolveProfileName(Option.some(file), undefined)).toBe(
541 "from-env-file",
542 );
543 expect(yield* resolveProfileName(Option.some(file), "from-cli")).toBe(
544 "from-cli",
545 );
546
547 yield* Effect.sync(() => {
548 process.env.ALCHEMY_PROFILE = "from-process";
549 });
550 expect(yield* resolveProfileName(Option.some(file), undefined)).toBe(
551 "from-env-file",
552 );
553 expect(yield* resolveProfileName(Option.some(file), "from-cli")).toBe(
554 "from-cli",
555 );
556 }).pipe(Effect.scoped, Effect.provide(makeTestLayer())),
557 { tags: ["unit", "local"], exclusive: true },
558);
559
560it.live(
561 "environment notices share a provider cache and reset when its layer is rebuilt",
562 () => {
563 const messages: unknown[] = [];
564 const run = withTempHome(
565 Effect.gen(function* () {
566 const auth = yield* getAuthProvider(ENV_PROVIDER);
567 yield* AuthProvider()("OtherNoticeProvider", auth);
568 const resolve = resolveProviderConfig(ENV_PROVIDER);
569 yield* resolve.pipe(
570 Effect.provideService(SuppressMissingProviderConfig, true),
571 );
572 expect(messages).toEqual([]);
573 const results = yield* Effect.all(
574 Array.from({ length: 10 }, () => resolve),
575 { concurrency: "unbounded" },
576 );
577 for (const result of results) {
578 expect(yield* result.resolve).toBe("environment-credentials");
579 }
580 yield* resolveProviderConfig("OtherNoticeProvider");
581 expect(messages).toEqual([
582 [
583 "FakeEnvAuthProvider: using environment variables (FAKE_ENV_TOKEN) instead of the profile.",
584 ],
585 [
586 "OtherNoticeProvider: using environment variables (FAKE_ENV_TOKEN) instead of the profile.",
587 ],
588 ]);
589 }),
590 { FAKE_ENV_TOKEN: "from-env" },
591 );
592 return Effect.gen(function* () {
593 yield* run;
594 messages.length = 0;
595 yield* run;
596 }).pipe(
597 Effect.provide(
598 Logger.layer([
599 Logger.make<unknown, void>((options) => {
600 messages.push(options.message);
601 }),
602 ]),
603 ),
604 );
605 },
606 { tags: ["unit", "local"], exclusive: true },
607);
608
609it.live(
610 "provider variables present in config resolve without a profile",
611 () =>
612 withTempHome(
613 Effect.gen(function* () {
614 const resolved = yield* resolveProviderConfig(ENV_PROVIDER);
615 expect(resolved.source).toBe("environment");
616 expect(yield* resolved.resolve).toBe("environment-credentials");
617 }),
618 // Environment credentials are read through the config provider —
619 // the process environment, `.env`, and `--env-file` alike.
620 { FAKE_ENV_TOKEN: "from-env" },
621 ),
622 { tags: ["unit", "local"], exclusive: true },
623);
624
625it.live(
626 "provider environment variables take precedence over a selected profile",
627 () =>
628 withTempHome(
629 Effect.gen(function* () {
630 // ALCHEMY_PROFILE (the --profile mechanism) selects a profile, but
631 // a fully present environment contract still wins — the profile
632 // is never consulted, so its unconfigured provider cannot fail.
633 const resolved = yield* resolveProviderConfig(ENV_PROVIDER);
634 expect(resolved.source).toBe("environment");
635 expect(resolved.profileName).toBeUndefined();
636 expect(yield* resolved.resolve).toBe("environment-credentials");
637 }),
638 { ALCHEMY_PROFILE: "default", FAKE_ENV_TOKEN: "from-env" },
639 ),
640 { tags: ["unit", "local"], exclusive: true },
641);
642
643it.live(
644 "providers mix: one from environment variables, the rest from the profile",
645 () =>
646 withTempHome(
647 Effect.gen(function* () {
648 const profile = yield* ProfileStore;
649 yield* profile.setProviderConfig("default", FAKE_PROVIDER, {
650 method: "stored",
651 });
652 // Precedence is decided per provider, not per run: the provider
653 // whose contract is present resolves from the environment while a
654 // provider without those variables still comes from the profile.
655 const fromEnv = yield* resolveProviderConfig(ENV_PROVIDER);
656 expect(fromEnv.source).toBe("environment");
657 const fromProfile = yield* resolveProviderConfig(FAKE_PROVIDER);
658 expect(fromProfile.source).toBe("profile");
659 expect(fromProfile.profileName).toBe("default");
660 }),
661 { FAKE_ENV_TOKEN: "from-env" },
662 ),
663 { tags: ["unit", "local"], exclusive: true },
664);
665
666it.live(
667 "CI resolves environment credentials without touching profiles",
668 () =>
669 withTempHome(
670 Effect.gen(function* () {
671 // No process.env presence required: CI reads through the config
672 // provider (env + --env-file) and never consults the profile.
673 const resolved = yield* resolveProviderConfig(ENV_PROVIDER);
674 expect(resolved.source).toBe("environment");
675 expect(resolved.profileName).toBeUndefined();
676 }),
677 { CI: true, ALCHEMY_PROFILE: "default" },
678 ),
679 { tags: ["unit", "local"], exclusive: true },
680);
681
682it.live(
683 "an invalid stored provider entry fails with a reconfigure hint",
684 () =>
685 withTempHome(
686 Effect.gen(function* () {
687 const fs = yield* FileSystem.FileSystem;
688 const profile = yield* ProfileStore;
689 const auth = yield* getAuthProvider<{ method: "stored" }, undefined>(
690 FAKE_PROVIDER,
691 );
692 yield* fs.makeDirectory(path.dirname(configFilePath()), {
693 recursive: true,
694 });
695 yield* fs.writeFileString(
696 configFilePath(),
697 JSON.stringify({
698 version: 0,
699 profiles: { ci: { [FAKE_PROVIDER]: { method: "bogus" } } },
700 }),
701 );
702
703 const error = yield* profile
704 .loadProviderConfig(auth, "ci")
705 .pipe(Effect.flip);
706 expect(error).toBeInstanceOf(AuthError);
707 expect((error as AuthError).message).toContain("--reconfigure");
708 }),
709 ),
710 { tags: ["unit", "local"], exclusive: true },
711);