1/**
2 * Credential-free `alchemy dev` for AWS.
3 *
4 * THE RULING: local emulation is invisible plumbing — `alchemy dev`
5 * with zero AWS credentials must work, landing every emulatable resource in
6 * the local floci emulator, while `Alchemy.remote()` resources demand real
7 * credentials up front with a typed, actionable error.
8 *
9 * ## How zero-credentials is arranged
10 *
11 * This file exercises the real resolution path with nothing configured:
12 *
13 * 1. `Test.make({ profile: <name that exists nowhere on disk> })` — the
14 * harness's `withProfileOverride` makes `ALCHEMY_PROFILE` resolve to a
15 * profile that has no AWS (or any) section, so the real on-disk
16 * `~/.alchemy` config (e.g. the `testing` SSO profile set by
17 * `--profile testing`) is never consulted.
18 * 2. Ambient AWS_* env credentials are neutralized via the ConfigProvider
19 * seam: a masking provider is `Layer.provide`d onto `AWS.providers()`,
20 * so `AWSEnvironment.Default`'s env-var probe (`{ method: "env" }`)
21 * finds nothing even when the shell exports AWS keys. All other config
22 * (ALCHEMY_PROFILE, etc.) falls through to the harness provider.
23 *
24 * With neither a profile nor env creds, `AWSEnvironment.Default` in dev mode
25 * falls back to the local emulator environment (dummy creds, account
26 * 000000000000, endpoint localhost:4566, `ensureFloci`) instead of failing.
27 *
28 * Requires Docker (floci runs as the shared `alchemy-floci` container);
29 * emulator-touching tests are skipped when the daemon is unavailable.
30 */
31import { CredentialsRequired } from "@/Auth/Demand.ts";
32import { ProfileStore, type ProfileStoreService } from "@/Auth/Profile.ts";
33import * as AWS from "@/AWS";
34import { AWSEnvironment } from "@/AWS/Environment.ts";
35import { Bucket } from "@/AWS/S3";
36import { Queue } from "@/AWS/SQS";
37import * as Alchemy from "@/index.ts";
38import * as Test from "@/Test/Alchemy";
39import { expect } from "alchemy-test";
40import * as ConfigProvider from "effect/ConfigProvider";
41import * as Effect from "effect/Effect";
42import * as Layer from "effect/Layer";
43import * as Result from "effect/Result";
44import * as HttpBody from "effect/http/HttpBody";
45import * as HttpClient from "effect/http/HttpClient";
46import * as HttpClientRequest from "effect/http/HttpClientRequest";
47import { spawnSync } from "node:child_process";
48
49const FLOCI_ENDPOINT = "http://localhost:4566";
50
51/**
52 * A profile name that exists in no `~/.alchemy` config. `getProfile`
53 * returns `undefined` for it, which is exactly the "user never
54 * configured a profile" starting state.
55 */
56const NO_CREDS_PROFILE = "credfree-dev-test-does-not-exist";
57
58// skipIf gate: floci runs as a Docker container. `docker info` proves the
59// daemon is reachable. Sync probe at collection time — skipIf needs a
60// plain boolean.
61const dockerAvailable = (() => {
62 try {
63 return (
64 spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 })
65 .status === 0
66 );
67 } catch {
68 return false;
69 }
70})();
71
72/**
73 * Config keys that could leak ambient AWS credentials (or redirect the
74 * emulator endpoint / region) from the developer's shell or .env files
75 * into the environment resolution under test.
76 */
77const MASKED_AWS_KEYS = new Set([
78 "AWS_ACCESS_KEY_ID",
79 "AWS_SECRET_ACCESS_KEY",
80 "AWS_SESSION_TOKEN",
81 "AWS_ACCOUNT_ID",
82 "AWS_ENDPOINT_URL",
83 "AWS_PROFILE",
84 "AWS_REGION",
85 "AWS_DEFAULT_REGION",
86]);
87
88/**
89 * ConfigProvider that hides the AWS_* credential keys and delegates every
90 * other lookup to the ambient provider (so ALCHEMY_PROFILE overrides and
91 * .env config still work). Provided onto `AWS.providers()` so the
92 * environment resolution captured at layer build sees a shell with no AWS
93 * credentials, regardless of what the real shell exports.
94 */
95const maskAwsEnv = Layer.effect(
96 ConfigProvider.ConfigProvider,
97 Effect.gen(function* () {
98 const base = yield* ConfigProvider.ConfigProvider;
99 return ConfigProvider.make((path) =>
100 path.length === 1 &&
101 typeof path[0] === "string" &&
102 MASKED_AWS_KEYS.has(path[0])
103 ? Effect.succeed(undefined)
104 : base.load(path),
105 );
106 }),
107);
108
109const providers = AWS.providers().pipe(Layer.provide(maskAwsEnv));
110
111// `dev: true` runs the same topology as the real `alchemy dev` command
112// (including the RPC sidecar default for RPC-backed providers).
113const { test } = Test.make({
114 providers,
115 dev: true,
116 profile: NO_CREDS_PROFILE,
117});
118
119/**
120 * Raw (non-distilled) call against the emulator gateway — out-of-band proof
121 * that a resource exists IN THE EMULATOR, not the real cloud.
122 */
123const rawAwsJson = Effect.fn(function* (options: {
124 service: string;
125 region: string;
126 target: string;
127 body: Record<string, unknown>;
128}) {
129 const client = yield* HttpClient.HttpClient;
130 return yield* client.execute(
131 HttpClientRequest.post(`${FLOCI_ENDPOINT}/`).pipe(
132 HttpClientRequest.setHeaders({
133 "content-type": "application/x-amz-json-1.0",
134 "x-amz-target": options.target,
135 "x-amz-date": "20260101T000000Z",
136 authorization: `AWS4-HMAC-SHA256 Credential=test/20260101/${options.region}/${options.service}/aws4_request, SignedHeaders=host;x-amz-date, Signature=dummy`,
137 }),
138 HttpClientRequest.setBody(
139 HttpBody.text(
140 JSON.stringify(options.body),
141 "application/x-amz-json-1.0",
142 ),
143 ),
144 ),
145 );
146});
147
148/**
149 * The DX ruling, end to end: a dev deploy with NO configured profile and NO
150 * env credentials must succeed, transparently landing the resources in the
151 * local emulator. Forcing `AWSEnvironment.current` inside the stack program
152 * additionally pins the credential fallback itself: resolution yields the
153 * synthesized local environment (dummy account, emulator endpoint) instead
154 * of prompting or dying.
155 */
156test.provider.skipIf(!dockerAvailable)(
157 "dev deploy with zero AWS credentials lands in the local emulator",
158 (stack) =>
159 Effect.gen(function* () {
160 yield* stack.destroy();
161
162 const outputs = yield* stack.deploy(
163 Effect.gen(function* () {
164 const bucket = yield* Bucket("CredFreeBucket");
165 const queue = yield* Queue("CredFreeQueue");
166 // Force the ambient AWS environment: with no profile and no env
167 // creds, dev mode must synthesize the local emulator environment
168 // rather than prompting for credentials or failing.
169 const env = yield* AWSEnvironment.current;
170 return {
171 bucket,
172 queue,
173 accountId: env.accountId,
174 region: env.region,
175 endpoint: env.endpoint,
176 };
177 }),
178 );
179
180 // The synthesized environment IS the `{ method: "local" }` one.
181 expect(outputs.accountId).toBe("000000000000");
182 expect(outputs.region).toBe("us-east-1");
183 expect(outputs.endpoint).toBe(FLOCI_ENDPOINT);
184
185 // Emulator identities: the dummy-account ARNs prove no real AWS
186 // account was involved.
187 expect(outputs.bucket.bucketArn).toContain(outputs.bucket.bucketName);
188 expect(outputs.queue.queueArn).toContain(":000000000000:");
189 expect(outputs.queue.queueUrl).toContain(outputs.queue.queueName);
190
191 // Out-of-band: the queue exists in the emulator gateway.
192 const listQueues = yield* rawAwsJson({
193 service: "sqs",
194 region: "us-east-1",
195 target: "AmazonSQS.ListQueues",
196 body: {},
197 });
198 expect(listQueues.status).toBe(200);
199 const queues = (yield* listQueues.json) as { QueueUrls?: string[] };
200 expect(
201 queues.QueueUrls?.some((url) =>
202 url.endsWith(`/${outputs.queue.queueName}`),
203 ),
204 ).toBe(true);
205
206 // Destroy must be equally credential-free (rows are stamped local).
207 yield* stack.destroy();
208
209 const after = yield* rawAwsJson({
210 service: "sqs",
211 region: "us-east-1",
212 target: "AmazonSQS.ListQueues",
213 body: {},
214 });
215 const queuesAfter = (yield* after.json) as { QueueUrls?: string[] };
216 expect(
217 queuesAfter.QueueUrls?.some((url) =>
218 url.endsWith(`/${outputs.queue.queueName}`),
219 ) ?? false,
220 ).toBe(false);
221 }),
222 {
223 tags: ["provider:aws", "provider:aws:s3", "provider:aws:sqs", "local"],
224 timeout: 240_000,
225 },
226);
227
228/**
229 * Profile service with nothing configured. Mutation and configuration methods
230 * are tripwires: the credential-demand path must fail with the typed
231 * `CredentialsRequired` — it never starts a configure flow (logins belong to
232 * `alchemy profile edit` exclusively).
233 */
234const unexpectedProfileMutation = Effect.die(
235 new Error("the credential-free path must not mutate profiles"),
236);
237const noCredsProfile: ProfileStoreService = {
238 readManifest: unexpectedProfileMutation,
239 getProfile: () => Effect.succeed(undefined),
240 ensureProfile: () => unexpectedProfileMutation,
241 createProfile: () => unexpectedProfileMutation,
242 renameProfile: () => unexpectedProfileMutation,
243 current: Effect.succeed({ name: NO_CREDS_PROFILE, source: "configuration" }),
244 setProviderConfig: () => unexpectedProfileMutation,
245 deleteProviderConfig: () => unexpectedProfileMutation,
246 deleteProfile: () => Effect.succeed(false),
247 loadProviderConfig: () => unexpectedProfileMutation,
248};
249
250/**
251 * The test runner exports CI=true, but the CI contract for the
252 * credential-demand seam is "use env-var credentials" — a different behavior
253 * than the one under test. Mask CI and AWS credentials at the demand seam
254 * as well as provider construction, so fake-profile wrappers and .env files
255 * cannot turn the missing-credentials assertion into an environment login.
256 */
257const maskCredentialDemand = Layer.effect(
258 ConfigProvider.ConfigProvider,
259 Effect.gen(function* () {
260 const base = yield* ConfigProvider.ConfigProvider;
261 return ConfigProvider.make((path) =>
262 path.length === 1 &&
263 typeof path[0] === "string" &&
264 (path[0] === "CI" || MASKED_AWS_KEYS.has(path[0]))
265 ? Effect.succeed(undefined)
266 : base.load(path),
267 );
268 }),
269);
270
271/**
272 * The other half of the ruling: a resource pinned to the real cloud via
273 * `Alchemy.remote()` must NOT silently fall back to the emulator. With no
274 * credentials configured, the dev deploy fails up front — before apply —
275 * with the typed `CredentialsRequired` error naming the demanding resource
276 * and pointing at `alchemy profile edit`.
277 */
278test.provider(
279 "remote() without credentials fails with a typed CredentialsRequired error",
280 (stack) =>
281 Effect.gen(function* () {
282 const result = yield* Effect.result(
283 stack
284 .deploy(
285 Effect.gen(function* () {
286 const bucket = yield* Bucket("RemoteBucket").pipe(
287 Alchemy.remote(),
288 );
289 return { bucket };
290 }),
291 )
292 .pipe(
293 // No saved profile, CI mode, or ambient AWS credentials.
294 Effect.provideService(ProfileStore, noCredsProfile),
295 Effect.provide(maskCredentialDemand),
296 ),
297 );
298
299 expect(Result.isFailure(result)).toBe(true);
300 if (Result.isFailure(result)) {
301 const error = result.failure as CredentialsRequired;
302 expect(error._tag).toBe("CredentialsRequired");
303 expect(error.provider).toBe("AWS");
304 expect(error.reason).toBe("remote");
305 expect(
306 error.resources.some((fqn) => fqn.includes("RemoteBucket")),
307 ).toBe(true);
308 expect(error.message).toContain("AWS credentials are required");
309 expect(error.message).toContain("RemoteBucket");
310 expect(error.message).toContain(
311 `alchemy profile edit --profile ${NO_CREDS_PROFILE} --add AWS`,
312 );
313 expect(error.message).toContain("Alchemy.remote()");
314 }
315 }),
316 { tags: ["provider:aws", "provider:aws:s3", "local"], timeout: 60_000 },
317);