1import {
2 AlchemyContext,
3 Secrets,
4 Stack,
5 Stage,
6 inMemoryState,
7 type SecretProviders,
8 type StackSecrets,
9} from "@/index.ts";
10import { evalStack } from "@/Stack.ts";
11import * as TestCore from "@/Test/Core.ts";
12import { loadConfigProvider } from "@/Util/ConfigProvider.ts";
13import { describe, expect, it } from "alchemy-test";
14import * as Config from "effect/Config";
15import type { ConfigError } from "effect/Config";
16import * as ConfigProvider from "effect/ConfigProvider";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Logger from "effect/Logger";
22import * as PlatformError from "effect/PlatformError";
23import { MinimumLogLevel } from "effect/References";
24
25const files = (contents: Record<string, string>) =>
26 FileSystem.layerNoop({
27 exists: (path) => Effect.succeed(path in contents),
28 readFileString: (path) =>
29 path in contents
30 ? Effect.succeed(contents[path])
31 : Effect.fail(
32 PlatformError.systemError({
33 _tag: "NotFound",
34 module: "FileSystem",
35 method: "readFileString",
36 pathOrDescriptor: path,
37 }),
38 ),
39 });
40
41/**
42 * Run with the real process environment temporarily changed; `undefined`
43 * removes a variable. Tests using this must be `{ exclusive: true }`.
44 */
45const withProcessEnv = <A, E, R>(
46 values: Record<string, string | undefined>,
47 effect: Effect.Effect<A, E, R>,
48) =>
49 Effect.acquireRelease(
50 Effect.sync(() => {
51 const previous = Object.fromEntries(
52 Object.keys(values).map((key) => [key, process.env[key]]),
53 );
54 for (const [key, value] of Object.entries(values)) {
55 if (value === undefined) delete process.env[key];
56 else process.env[key] = value;
57 }
58 return previous;
59 }),
60 (previous) =>
61 Effect.sync(() => {
62 for (const [key, value] of Object.entries(previous)) {
63 if (value === undefined) delete process.env[key];
64 else process.env[key] = value;
65 }
66 }),
67 ).pipe(Effect.andThen(effect));
68
69/** Build a stack with the given `secrets` and return its output. */
70const runStack = <A>(
71 name: string,
72 secrets: StackSecrets | undefined,
73 body: Effect.Effect<A, ConfigError>,
74) =>
75 Stack(
76 name,
77 { providers: Layer.empty, state: inMemoryState(), secrets },
78 body,
79 ).pipe(
80 Effect.map((stack) => stack.output),
81 Effect.provideService(AlchemyContext, {
82 dotAlchemy: ".alchemy",
83 dev: false,
84 adopt: false,
85 }),
86 );
87
88/** Run under a stage and a fake file system. */
89const withFiles = <A, E, R>(
90 contents: Record<string, string>,
91 effect: Effect.Effect<A, E, R>,
92 stage = "test",
93) =>
94 effect.pipe(
95 Effect.provideService(Stage, stage),
96 Effect.provide(files(contents)),
97 Effect.scoped,
98 );
99
100const values = Effect.all({
101 value: Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
102 fallback: Config.String("ALCHEMY_DOTENV_TEST_FALLBACK"),
103 empty: Config.String("ALCHEMY_DOTENV_TEST_EMPTY"),
104});
105
106describe("stack secrets", { tags: ["unit", "local"] }, () => {
107 it.effect("scratch deploys and plans retain per-call config overrides", () =>
108 TestCore.toEffect(
109 Effect.gen(function* () {
110 const stack = TestCore.scratchStack(
111 { providers: Layer.empty },
112 "scratch-config-overrides",
113 );
114 const read = Config.String("ALCHEMY_SCRATCH_CONFIG_VALUE");
115 yield* stack.destroy();
116 for (const value of ["first", "second"]) {
117 const config = ConfigProvider.fromEnv({
118 env: { ALCHEMY_SCRATCH_CONFIG_VALUE: value },
119 });
120 expect(
121 yield* stack
122 .deploy(read)
123 .pipe(
124 Effect.provideService(ConfigProvider.ConfigProvider, config),
125 ),
126 ).toBe(value);
127 yield* stack
128 .plan(
129 read.pipe(
130 Effect.tap((actual) =>
131 Effect.sync(() => {
132 expect(actual).toBe(value);
133 }),
134 ),
135 ),
136 )
137 .pipe(Effect.provideService(ConfigProvider.ConfigProvider, config));
138 }
139 yield* stack.destroy();
140 }),
141 { providers: Layer.empty, state: inMemoryState() },
142 ),
143 );
144
145 it.effect(
146 "the CLI loads .env without mutating process.env when secrets is omitted",
147 () => {
148 const before = process.env.ALCHEMY_DOTENV_TEST_VALUE;
149 return withFiles(
150 { ".env": "ALCHEMY_DOTENV_TEST_VALUE=default" },
151 loadConfigProvider(Option.none()).pipe(
152 Effect.flatMap((config) =>
153 runStack(
154 "dotenv-default",
155 undefined,
156 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
157 ).pipe(
158 Effect.provideService(ConfigProvider.ConfigProvider, config),
159 ),
160 ),
161 Effect.map((output) => {
162 expect(output).toBe("default");
163 expect(process.env.ALCHEMY_DOTENV_TEST_VALUE).toBe(before);
164 }),
165 ),
166 );
167 },
168 );
169
170 it.effect(
171 "omitting secrets preserves the caller's complete ConfigProvider",
172 () =>
173 withFiles(
174 { ".env": "ALCHEMY_DOTENV_TEST_FALLBACK=must-not-be-loaded" },
175 runStack(
176 "inherited-config",
177 undefined,
178 Effect.all({
179 value: Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
180 profile: Config.String("ALCHEMY_PROFILE"),
181 absent: Config.String("ALCHEMY_DOTENV_TEST_FALLBACK").pipe(
182 Config.option,
183 ),
184 }),
185 ).pipe(
186 Effect.provideService(
187 ConfigProvider.ConfigProvider,
188 ConfigProvider.fromUnknown({
189 ALCHEMY_DOTENV_TEST_VALUE: "injected",
190 ALCHEMY_PROFILE: "injected-profile",
191 }),
192 ),
193 Effect.map((output) => {
194 expect(output.value).toBe("injected");
195 expect(output.profile).toBe("injected-profile");
196 expect(Option.isNone(output.absent)).toBe(true);
197 }),
198 ),
199 ),
200 );
201
202 it.effect("ignores a missing default .env", () =>
203 withFiles(
204 {},
205 runStack("dotenv-missing-default", undefined, Effect.succeed("ok")).pipe(
206 Effect.map((output) => expect(output).toBe("ok")),
207 ),
208 ),
209 );
210
211 it.effect(
212 "isolates stage-specific config when stacks share a secrets layer",
213 () => {
214 const source = Secrets.DotEnv(({ stage }) => ({ path: `${stage}.env` }));
215 const stack = runStack(
216 "dotenv-isolation",
217 [source],
218 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
219 );
220 return Effect.all(
221 [
222 stack.pipe(Effect.provideService(Stage, "dev")),
223 stack.pipe(Effect.provideService(Stage, "prod")),
224 ],
225 { concurrency: "unbounded" },
226 ).pipe(
227 Effect.map((outputs) => expect(outputs).toEqual(["dev", "prod"])),
228 Effect.provide(
229 files({
230 "dev.env": "ALCHEMY_DOTENV_TEST_VALUE=dev",
231 "prod.env": "ALCHEMY_DOTENV_TEST_VALUE=prod",
232 }),
233 ),
234 Effect.scoped,
235 );
236 },
237 );
238
239 it.effect(
240 "loads ordered path arrays from plain and stage-based options",
241 () =>
242 withFiles(
243 {
244 "base.env":
245 "ALCHEMY_DOTENV_TEST_VALUE=base\nALCHEMY_DOTENV_TEST_FALLBACK=base\nALCHEMY_DOTENV_TEST_EMPTY=base",
246 "prod.env":
247 "ALCHEMY_DOTENV_TEST_VALUE=prod\nALCHEMY_DOTENV_TEST_EMPTY=",
248 },
249 Effect.gen(function* () {
250 for (const source of [
251 Secrets.DotEnv({ path: ["base.env", "prod.env"] }),
252 Secrets.DotEnv(({ stage }) => ({
253 path: ["base.env", `${stage}.env`] as const,
254 })),
255 ]) {
256 expect(yield* runStack("dotenv-paths", [source], values)).toEqual({
257 value: "prod",
258 fallback: "base",
259 empty: "",
260 });
261 }
262 }),
263 "prod",
264 ),
265 );
266
267 it.effect(
268 "secrets may be a single provider, a list, or a callback picking either",
269 () => {
270 const seen: Array<{ stack: string; dev: boolean }> = [];
271 const stack = runStack(
272 "secrets-by-stage",
273 ({ stage, stack, alchemyContext }) => {
274 seen.push({ stack: stack.name, dev: alchemyContext.dev });
275 return stage === "dev"
276 ? Secrets.DotEnv({ path: "dev.env" })
277 : [Secrets.DotEnv({ path: "other.env" })];
278 },
279 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
280 );
281 return Effect.all([
282 stack.pipe(Effect.provideService(Stage, "dev")),
283 stack.pipe(Effect.provideService(Stage, "prod")),
284 ]).pipe(
285 Effect.map((outputs) => {
286 expect(outputs).toEqual(["dev", "other"]);
287 expect(seen).toEqual([
288 { stack: "secrets-by-stage", dev: false },
289 { stack: "secrets-by-stage", dev: false },
290 ]);
291 }),
292 Effect.provide(
293 files({
294 "dev.env": "ALCHEMY_DOTENV_TEST_VALUE=dev",
295 "other.env": "ALCHEMY_DOTENV_TEST_VALUE=other",
296 }),
297 ),
298 Effect.scoped,
299 );
300 },
301 );
302
303 it.effect("identifies the missing file in a path array", () =>
304 withFiles(
305 { "base.env": "" },
306 runStack(
307 "dotenv-missing-path",
308 [Secrets.DotEnv({ path: ["base.env", "missing.env"] })],
309 Effect.void,
310 ).pipe(
311 Effect.flip,
312 Effect.map((error) => expect(error.message).toContain("missing.env")),
313 ),
314 ),
315 );
316
317 it.effect("explicit arrays exclude automatic and ambient dotenv values", () =>
318 withFiles(
319 { ".env": "ALCHEMY_DOTENV_TEST_DEFAULT=default", "explicit.env": "" },
320 Effect.gen(function* () {
321 for (const secrets of [
322 [],
323 [Secrets.DotEnv({ path: [] })],
324 [Secrets.DotEnv({ path: "explicit.env" })],
325 ]) {
326 const output = yield* runStack(
327 "dotenv-explicit",
328 secrets,
329 Config.String("ALCHEMY_DOTENV_TEST_DEFAULT").pipe(Config.option),
330 );
331 expect(Option.isNone(output)).toBe(true);
332 }
333 }).pipe(
334 Effect.provide(
335 ConfigProvider.layer(
336 ConfigProvider.fromEnv({
337 env: { ALCHEMY_DOTENV_TEST_DEFAULT: "ambient" },
338 }),
339 ),
340 ),
341 ),
342 ),
343 );
344
345 it.effect(
346 "selects files by stage and preserves earlier keys and empty overrides",
347 () =>
348 withFiles(
349 {
350 "base.env":
351 "ALCHEMY_DOTENV_TEST_VALUE=base\nALCHEMY_DOTENV_TEST_FALLBACK=base\nALCHEMY_DOTENV_TEST_EMPTY=base",
352 "config/prod.env":
353 "ALCHEMY_DOTENV_TEST_VALUE=production\nALCHEMY_DOTENV_TEST_EMPTY=",
354 },
355 runStack(
356 "dotenv-order",
357 [
358 Secrets.DotEnv({ path: "base.env" }),
359 Secrets.DotEnv(({ stage }) => ({ path: `config/${stage}.env` })),
360 ],
361 values,
362 ).pipe(
363 Effect.map((output) =>
364 expect(output).toEqual({
365 value: "production",
366 fallback: "base",
367 empty: "",
368 }),
369 ),
370 ),
371 "prod",
372 ),
373 );
374
375 it.effect(
376 "provides secrets to state, providers, and subsequent stack operations",
377 () => {
378 const observed: string[] = [];
379 return TestCore.toEffect(
380 evalStack(
381 Stack(
382 "dotenv-services",
383 {
384 state: Layer.unwrap(
385 Config.String("ALCHEMY_DOTENV_TEST_VALUE").pipe(
386 Effect.map((value) => {
387 observed.push(`state:${value}`);
388 }),
389 Effect.map(() => inMemoryState()),
390 Effect.orDie,
391 ),
392 ),
393 providers: Layer.effectDiscard(
394 Config.String("ALCHEMY_DOTENV_TEST_VALUE").pipe(
395 Effect.map((value) => {
396 observed.push(`providers:${value}`);
397 }),
398 Effect.orDie,
399 ),
400 ),
401 secrets: [Secrets.DotEnv({ path: "services.env" })],
402 },
403 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
404 ).pipe(
405 Effect.provide(
406 files({ "services.env": "ALCHEMY_DOTENV_TEST_VALUE=secret" }),
407 ),
408 ),
409 (stack) =>
410 Effect.gen(function* () {
411 expect(stack.output).toBe("secret");
412 expect(yield* Config.String("ALCHEMY_DOTENV_TEST_VALUE")).toBe(
413 "secret",
414 );
415 expect(observed).toEqual(["state:secret", "providers:secret"]);
416 }),
417 { stage: "test" },
418 ),
419 { providers: Layer.empty, state: inMemoryState() },
420 );
421 },
422 );
423
424 it.effect("fails for an explicitly missing file", () =>
425 withFiles(
426 {},
427 runStack(
428 "dotenv-missing-explicit",
429 [Secrets.DotEnv({ path: "missing.env" })],
430 Effect.void,
431 ).pipe(
432 Effect.flip,
433 Effect.map((error) => {
434 expect(error._tag).toBe("ConfigError");
435 expect(error.message).toContain("missing.env");
436 }),
437 ),
438 ),
439 );
440
441 it.effect("does not replace an explicit empty path with .env", () =>
442 withFiles(
443 { ".env": "ALCHEMY_DOTENV_TEST_VALUE=default" },
444 runStack(
445 "dotenv-empty-path",
446 Secrets.DotEnv({ path: "" }),
447 Effect.void,
448 ).pipe(
449 Effect.flip,
450 Effect.map((error) => expect(error._tag).toBe("ConfigError")),
451 ),
452 ),
453 );
454
455 it.effect("reapplies a shared provider at each position in the list", () => {
456 const first = Secrets.DotEnv({ path: "first.env" });
457 return withFiles(
458 {
459 "first.env": "ALCHEMY_DOTENV_TEST_VALUE=first",
460 "second.env": "ALCHEMY_DOTENV_TEST_FALLBACK=second",
461 },
462 runStack(
463 "dotenv-repeated-provider",
464 [first, Secrets.DotEnv({ path: "second.env" }), first],
465 Config.String("ALCHEMY_DOTENV_TEST_FALLBACK"),
466 ).pipe(Effect.map((output) => expect(output).toBe("second"))),
467 );
468 });
469
470 it.effect(
471 "accepts native ConfigProvider layers; layerAdd merges, layer replaces",
472 () =>
473 withFiles(
474 {},
475 Effect.gen(function* () {
476 const merged = yield* runStack(
477 "native-config-layers",
478 [
479 ConfigProvider.layerAdd(
480 ConfigProvider.fromEnv({
481 env: {
482 ALCHEMY_DOTENV_TEST_VALUE: "first",
483 ALCHEMY_DOTENV_TEST_FALLBACK: "fallback",
484 },
485 }),
486 { asPrimary: true },
487 ),
488 ConfigProvider.layerAdd(
489 ConfigProvider.fromEnv({
490 env: { ALCHEMY_DOTENV_TEST_VALUE: "last" },
491 }),
492 { asPrimary: true },
493 ),
494 ],
495 Effect.all([
496 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
497 Config.String("ALCHEMY_DOTENV_TEST_FALLBACK"),
498 ]),
499 );
500 expect(merged).toEqual(["last", "fallback"]);
501
502 // A plain ConfigProvider.layer replaces everything before it.
503 const replaced = yield* runStack(
504 "native-config-layer-replaces",
505 [
506 ConfigProvider.layerAdd(
507 ConfigProvider.fromEnv({
508 env: { ALCHEMY_DOTENV_TEST_FALLBACK: "fallback" },
509 }),
510 { asPrimary: true },
511 ),
512 ConfigProvider.layer(
513 ConfigProvider.fromEnv({
514 env: { ALCHEMY_DOTENV_TEST_VALUE: "only" },
515 }),
516 ),
517 ],
518 Effect.all([
519 Config.String("ALCHEMY_DOTENV_TEST_VALUE"),
520 Config.String("ALCHEMY_DOTENV_TEST_FALLBACK").pipe(Config.option),
521 ]),
522 );
523 expect(replaced[0]).toBe("only");
524 expect(Option.isNone(replaced[1])).toBe(true);
525 }),
526 ),
527 );
528
529 it.effect(
530 "ProcessEnv() preserves empty overrides; legacy --env-file keeps file precedence",
531 () => {
532 const key = "ALCHEMY_DOTENV_TEST_PROCESS";
533 return withFiles(
534 { "process.env": "ALCHEMY_DOTENV_TEST_PROCESS=file" },
535 withProcessEnv(
536 { [key]: "shell" },
537 Effect.gen(function* () {
538 for (const value of ["shell", ""]) {
539 process.env[key] = value;
540 const output = yield* runStack(
541 "dotenv-process",
542 [Secrets.DotEnv({ path: "process.env" }), Secrets.ProcessEnv()],
543 Config.String(key),
544 );
545 expect(output).toBe(value);
546 const provider = yield* loadConfigProvider(
547 Option.some("process.env"),
548 );
549 expect(
550 yield* Config.String(key).pipe(
551 Effect.provide(ConfigProvider.layer(provider)),
552 ),
553 ).toBe("file");
554 expect(process.env[key]).toBe(value);
555 }
556 }),
557 ),
558 );
559 },
560 { exclusive: true },
561 );
562
563 it.effect("logs which keys each provider loaded at debug level", () => {
564 const messages: string[] = [];
565 const capture = Logger.make(({ message }) => {
566 messages.push(String(message));
567 });
568 return withFiles(
569 {
570 "base.env":
571 "ALCHEMY_DOTENV_TEST_VALUE=hunter2\nALCHEMY_DOTENV_TEST_FALLBACK=base",
572 "prod.env": "ALCHEMY_DOTENV_TEST_VALUE=hunter2",
573 "empty.env": "",
574 },
575 runStack(
576 "dotenv-logging",
577 [
578 Secrets.DotEnv({ path: "base.env" }),
579 Secrets.DotEnv({ path: ["prod.env", "empty.env"] }),
580 ],
581 Effect.void,
582 ).pipe(
583 Effect.provide(Logger.layer([capture])),
584 Effect.provideService(MinimumLogLevel, "Debug"),
585 Effect.map(() => {
586 expect(messages.slice(0, 2)).toEqual([
587 "Loaded 2 secrets from dotenv (base.env): ALCHEMY_DOTENV_TEST_FALLBACK, ALCHEMY_DOTENV_TEST_VALUE",
588 "Loaded 1 secrets from dotenv (prod.env, empty.env): ALCHEMY_DOTENV_TEST_VALUE",
589 ]);
590 // The implicit shell closes the list; it logs a count, never names.
591 expect(messages[2]).toMatch(
592 /^Loaded \d+ secrets from the process environment$/,
593 );
594 expect(messages).toHaveLength(3);
595 // Keys only, never values.
596 expect(messages.join("\n")).not.toContain("hunter2");
597 }),
598 ),
599 );
600 });
601
602 it.effect(
603 "the shell closes the list unless ProcessEnv() is placed or disabled",
604 () => {
605 const key = "ALCHEMY_DOTENV_TEST_PROCESS_POS";
606 const read = (secrets: SecretProviders | undefined) =>
607 runStack("dotenv-process-position", secrets, Config.String(key));
608 const file = Secrets.DotEnv({ path: "pos.env" });
609 return withFiles(
610 { "pos.env": "ALCHEMY_DOTENV_TEST_PROCESS_POS=file" },
611 withProcessEnv(
612 { [key]: "shell" },
613 Effect.gen(function* () {
614 // Implicitly last: the shell wins over any list...
615 expect(yield* read([file])).toBe("shell");
616 expect(yield* read([])).toBe("shell");
617 // ...unless it is placed, in which case position decides...
618 expect(yield* read([Secrets.ProcessEnv(), file])).toBe("file");
619 expect(yield* read([file, Secrets.ProcessEnv()])).toBe("shell");
620 // ...or disabled, in which case it is never consulted.
621 expect(
622 yield* read([file, Secrets.ProcessEnv({ disabled: true })]),
623 ).toBe("file");
624 const disabled = yield* read([
625 Secrets.ProcessEnv({ disabled: true }),
626 ]).pipe(Effect.flip);
627 expect(disabled._tag).toBe("ConfigError");
628 const byStage = Secrets.ProcessEnv(({ stage }) => ({
629 disabled: stage === "prod",
630 }));
631 expect(
632 yield* read([file, byStage]).pipe(
633 Effect.provideService(Stage, "dev"),
634 ),
635 ).toBe("shell");
636 expect(
637 yield* read([file, byStage]).pipe(
638 Effect.provideService(Stage, "prod"),
639 ),
640 ).toBe("file");
641 }),
642 ),
643 );
644 },
645 { exclusive: true },
646 );
647
648 it.effect(
649 "ALCHEMY_PROFILE comes from the real process environment, never a provider",
650 () => {
651 const read = (secrets: SecretProviders) =>
652 runStack(
653 "dotenv-profile-pinned",
654 secrets,
655 Config.option(Config.String("ALCHEMY_PROFILE")),
656 ).pipe(Effect.map(Option.getOrUndefined));
657 const file = Secrets.DotEnv({ path: "profile.env" });
658 // A provider listed after the file must also see the pinned value, since
659 // that is where its credentials resolve the profile from.
660 const seenByProvider: Array<string | undefined> = [];
661 const observer = ConfigProvider.layerAdd(
662 Effect.gen(function* () {
663 seenByProvider.push(
664 Option.getOrUndefined(
665 yield* Config.option(Config.String("ALCHEMY_PROFILE")),
666 ),
667 );
668 return ConfigProvider.fromEnv({ env: {} });
669 }),
670 { asPrimary: true },
671 );
672 return withFiles(
673 { "profile.env": "ALCHEMY_PROFILE=file" },
674 withProcessEnv(
675 { ALCHEMY_PROFILE: undefined },
676 Effect.gen(function* () {
677 // A provider's ALCHEMY_PROFILE is filtered out...
678 expect(yield* read([file])).toBeUndefined();
679 expect(yield* read([file, Secrets.ProcessEnv()])).toBeUndefined();
680 expect(yield* read([file, observer])).toBeUndefined();
681 // ...while the shell's is seen even without ProcessEnv() listed.
682 process.env.ALCHEMY_PROFILE = "shell";
683 expect(yield* read([file])).toBe("shell");
684 expect(yield* read([])).toBe("shell");
685 expect(yield* read([file, observer])).toBe("shell");
686 expect(seenByProvider).toEqual([undefined, "shell"]);
687 }),
688 ),
689 );
690 },
691 { exclusive: true },
692 );
693});