| 1 | import { |
| 2 | AuthProviderLayer, |
| 3 | AuthProviders, |
| 4 | NeedsReauth, |
| 5 | } from "@/Auth/AuthProvider.ts"; |
| 6 | import { |
| 7 | type CredentialDemand, |
| 8 | type CredentialsRequired, |
| 9 | demandCredentials, |
| 10 | } from "@/Auth/Demand.ts"; |
| 11 | import { ProfileStore, ProfileStoreLive } from "@/Auth/Profile.ts"; |
| 12 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 13 | import { expect, it } from "alchemy-test"; |
| 14 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Schema from "effect/Schema"; |
| 19 | |
| 20 | const 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 | */ |
| 27 | const state = { reads: 0, mode: "ok" as "ok" | "needs-reauth" }; |
| 28 | |
| 29 | const 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 | |
| 50 | const ENV_PROBE = "DemandEnvProbe"; |
| 51 | const ENV_PROBE_TOKEN = "DEMAND_PROBE_TOKEN"; |
| 52 | const envState = { reads: 0 }; |
| 53 | |
| 54 | /** A provider that also supports environment credentials. */ |
| 55 | const 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 | |
| 72 | const 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 | */ |
| 91 | const 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 | |
| 117 | const configureProbe = Effect.gen(function* () { |
| 118 | const profile = yield* ProfileStore; |
| 119 | yield* profile.setProviderConfig("default", PROBE, { method: "stored" }); |
| 120 | }); |
| 121 | |
| 122 | const demand: CredentialDemand = { |
| 123 | provider: PROBE, |
| 124 | resources: [{ fqn: "Website", reason: "remote" }], |
| 125 | }; |
| 126 | |
| 127 | it.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 | |
| 140 | it.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 | |
| 153 | it.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 | |
| 173 | it.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 | |
| 190 | it.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 | |
| 210 | it.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 | ); |