| 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 | */ |
| 31 | import { CredentialsRequired } from "@/Auth/Demand.ts"; |
| 32 | import { ProfileStore, type ProfileStoreService } from "@/Auth/Profile.ts"; |
| 33 | import * as AWS from "@/AWS"; |
| 34 | import { AWSEnvironment } from "@/AWS/Environment.ts"; |
| 35 | import { Bucket } from "@/AWS/S3"; |
| 36 | import { Queue } from "@/AWS/SQS"; |
| 37 | import * as Alchemy from "@/index.ts"; |
| 38 | import * as Test from "@/Test/Alchemy"; |
| 39 | import { expect } from "alchemy-test"; |
| 40 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 41 | import * as Effect from "effect/Effect"; |
| 42 | import * as Layer from "effect/Layer"; |
| 43 | import * as Result from "effect/Result"; |
| 44 | import * as HttpBody from "effect/http/HttpBody"; |
| 45 | import * as HttpClient from "effect/http/HttpClient"; |
| 46 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 47 | import { spawnSync } from "node:child_process"; |
| 48 | |
| 49 | const 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 | */ |
| 56 | const 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. |
| 61 | const 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 | */ |
| 77 | const 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 | */ |
| 95 | const 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 | |
| 109 | const 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). |
| 113 | const { 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 | */ |
| 123 | const 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 | */ |
| 156 | test.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 | */ |
| 234 | const unexpectedProfileMutation = Effect.die( |
| 235 | new Error("the credential-free path must not mutate profiles"), |
| 236 | ); |
| 237 | const 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 | */ |
| 257 | const 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 | */ |
| 278 | test.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 | ); |