1import {
2 AuthProviderLayer,
3 AuthProviders,
4 NeedsReauth,
5} from "@/Auth/AuthProvider.ts";
6import {
7 type CredentialDemand,
8 type CredentialsRequired,
9 demandCredentials,
10} from "@/Auth/Demand.ts";
11import { ProfileStore, ProfileStoreLive } from "@/Auth/Profile.ts";
12import * as NodeServices from "@effect/platform-node/NodeServices";
13import { expect, it } from "alchemy-test";
14import * as ConfigProvider from "effect/ConfigProvider";
15import * as Effect from "effect/Effect";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19
20const PROBE = "DemandProbe";
21
22/**
23 * Every credential touch goes through `read` — the counter is how the tests
24 * prove both directions: warm-up runs it exactly once per demanded provider,
25 * and a zero-demand run never runs it at all.
26 */
27const state = { reads: 0, mode: "ok" as "ok" | "needs-reauth" };
28
29const ProbeAuth = AuthProviderLayer<{ method: "stored" }, string>()(PROBE, {
30 configSchema: Schema.Struct({ method: Schema.Literal("stored") }),
31 configure: () => Effect.succeed({ method: "stored" as const }),
32 login: () => Effect.void,
33 logout: () => Effect.void,
34 details: () => Effect.succeed({ lines: [] }),
35 read: (profileName) =>
36 Effect.suspend(() => {
37 state.reads += 1;
38 return state.mode === "ok"
39 ? Effect.succeed("probe-credentials")
40 : Effect.fail(
41 new NeedsReauth({
42 provider: PROBE,
43 profile: profileName,
44 message: "Probe refresh token is dead.",
45 }),
46 );
47 }),
48});
49
50const ENV_PROBE = "DemandEnvProbe";
51const ENV_PROBE_TOKEN = "DEMAND_PROBE_TOKEN";
52const envState = { reads: 0 };
53
54/** A provider that also supports environment credentials. */
55const EnvProbeAuth = AuthProviderLayer<{ method: "stored" }, string>()(
56 ENV_PROBE,
57 {
58 configSchema: Schema.Struct({ method: Schema.Literal("stored") }),
59 configure: () => Effect.succeed({ method: "stored" as const }),
60 login: () => Effect.void,
61 logout: () => Effect.void,
62 details: () => Effect.succeed({ lines: [] }),
63 read: () => Effect.succeed("profile-credentials"),
64 readEnvironment: Effect.sync(() => {
65 envState.reads += 1;
66 return "env-credentials";
67 }),
68 environment: [{ name: ENV_PROBE_TOKEN, required: true, secret: true }],
69 },
70);
71
72const makeTestLayer = (config: Record<string, unknown> = {}) =>
73 Layer.mergeAll(ProfileStoreLive, ProbeAuth, EnvProbeAuth).pipe(
74 Layer.provideMerge(
75 Layer.mergeAll(
76 Layer.succeed(AuthProviders, {}),
77 // Fully replaces the ambient provider: an exported CI=1 (or
78 // ALCHEMY_PROFILE) in the developer's shell must not leak into the
79 // demand seam under test.
80 ConfigProvider.layer(ConfigProvider.fromUnknown(config)),
81 NodeServices.layer,
82 ),
83 ),
84 );
85
86/**
87 * Point `ALCHEMY_HOME` at a scoped temp directory so the store never
88 * touches the developer's real `~/.alchemy`. Tests using this must be
89 * `exclusive` — the env var is process-global.
90 */
91const withTempHome = <A, E, R>(
92 effect: Effect.Effect<A, E, R>,
93 config: Record<string, unknown> = {},
94) =>
95 Effect.gen(function* () {
96 state.reads = 0;
97 state.mode = "ok";
98 envState.reads = 0;
99 const fs = yield* FileSystem.FileSystem;
100 const dir = yield* fs.makeTempDirectoryScoped({
101 prefix: "alchemy-demand-",
102 });
103 const previous = process.env.ALCHEMY_HOME;
104 yield* Effect.acquireRelease(
105 Effect.sync(() => {
106 process.env.ALCHEMY_HOME = dir;
107 }),
108 () =>
109 Effect.sync(() => {
110 if (previous === undefined) delete process.env.ALCHEMY_HOME;
111 else process.env.ALCHEMY_HOME = previous;
112 }),
113 );
114 return yield* effect.pipe(Effect.provide(makeTestLayer(config)));
115 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
116
117const configureProbe = Effect.gen(function* () {
118 const profile = yield* ProfileStore;
119 yield* profile.setProviderConfig("default", PROBE, { method: "stored" });
120});
121
122const demand: CredentialDemand = {
123 provider: PROBE,
124 resources: [{ fqn: "Website", reason: "remote" }],
125};
126
127it.live(
128 "zero demand touches no credentials at all",
129 () =>
130 withTempHome(
131 Effect.gen(function* () {
132 yield* configureProbe;
133 yield* demandCredentials([]);
134 expect(state.reads).toBe(0);
135 }),
136 ),
137 { tags: ["unit", "local"], exclusive: true },
138);
139
140it.live(
141 "a demanded provider is warmed up through its real read path",
142 () =>
143 withTempHome(
144 Effect.gen(function* () {
145 yield* configureProbe;
146 yield* demandCredentials([demand]);
147 expect(state.reads).toBe(1);
148 }),
149 ),
150 { tags: ["unit", "local"], exclusive: true },
151);
152
153it.live(
154 "a dead token fails typed before apply, naming the demanding resources",
155 () =>
156 withTempHome(
157 Effect.gen(function* () {
158 yield* configureProbe;
159 state.mode = "needs-reauth";
160 const error = yield* Effect.flip(demandCredentials([demand]));
161 expect(error._tag).toBe("NeedsReauth");
162 expect(error.message).toContain("Probe refresh token is dead.");
163 expect(error.message).toContain(
164 `These resources require ${PROBE} credentials:`,
165 );
166 expect(error.message).toContain("Website");
167 expect(error.message).toContain("Alchemy.remote()");
168 }),
169 ),
170 { tags: ["unit", "local"], exclusive: true },
171);
172
173it.live(
174 "an unconfigured provider fails with CredentialsRequired and never reads",
175 () =>
176 withTempHome(
177 Effect.gen(function* () {
178 const error = (yield* Effect.flip(
179 demandCredentials([demand]),
180 )) as CredentialsRequired;
181 expect(error._tag).toBe("CredentialsRequired");
182 expect(error.resources).toEqual(["Website"]);
183 expect(error.message).toContain(`--add ${PROBE}`);
184 expect(state.reads).toBe(0);
185 }),
186 ),
187 { tags: ["unit", "local"], exclusive: true },
188);
189
190it.live(
191 "a nonexistent explicit profile fails with the actionable CredentialsRequired",
192 () =>
193 withTempHome(
194 Effect.gen(function* () {
195 const error = (yield* Effect.flip(
196 demandCredentials([demand]),
197 )) as CredentialsRequired;
198 // NOT a generic ProfileError: the user must still learn which
199 // resources demanded credentials and which command fixes it.
200 expect(error._tag).toBe("CredentialsRequired");
201 expect(error.message).toContain("ghost");
202 expect(error.message).toContain("Website");
203 expect(state.reads).toBe(0);
204 }),
205 { ALCHEMY_PROFILE: "ghost" },
206 ),
207 { tags: ["unit", "local"], exclusive: true },
208);
209
210it.live(
211 "exported env credentials satisfy the gate when no profile is selected",
212 () =>
213 withTempHome(
214 Effect.gen(function* () {
215 yield* demandCredentials([
216 {
217 provider: ENV_PROBE,
218 resources: [{ fqn: "Api", reason: "remote" }],
219 },
220 ]);
221 // The gate warmed through the provider's environment path — the
222 // same source the run's lazy resolution will use.
223 expect(envState.reads).toBe(1);
224 }),
225 { [ENV_PROBE_TOKEN]: "token" },
226 ),
227 { tags: ["unit", "local"], exclusive: true },
228);