| 1 | /** |
| 2 | * Mode-scoped local dev for the AWS serverless data plane. |
| 3 | * |
| 4 | * `Test.make({ dev: true })` alone (no profile stub, no endpoint config) |
| 5 | * must route the dualized data-plane resources to the floci emulator: each |
| 6 | * `flociDual` registration in `AWS/Providers.ts` builds the SAME live |
| 7 | * provider code with every lifecycle method wrapped in the floci override |
| 8 | * context (endpoint `http://localhost:4566`, dummy credentials, account |
| 9 | * `000000000000`, region `us-east-1`). |
| 10 | * |
| 11 | * Proof structure: |
| 12 | * - attrs carry the dummy account (`:000000000000:` ARNs) and localhost |
| 13 | * queue URL — the live cloud can never produce these; |
| 14 | * - persisted state rows are stamped `providerMode: "local"` (the |
| 15 | * stamping/replacement law of ProviderLayer.dual then guarantees a live |
| 16 | * deploy plans replacements — pinned by test/provider-mode.test.ts); |
| 17 | * - out-of-band raw HTTP against the emulator gateway finds the |
| 18 | * resources (and finds them gone after destroy); |
| 19 | * - `Alchemy.remote()` opts a resource back to the live provider even in |
| 20 | * dev: real-account identity, out-of-band verification through the real |
| 21 | * cloud API, and a live delete on destroy. |
| 22 | * |
| 23 | * Requires Docker (floci runs as a container); skipped when unavailable. |
| 24 | */ |
| 25 | import * as AWS from "@/AWS"; |
| 26 | import * as Alchemy from "@/index.ts"; |
| 27 | import { State, type ResourceState } from "@/State"; |
| 28 | import { Stack } from "@/Stack"; |
| 29 | import * as Test from "@/Test/Alchemy"; |
| 30 | import * as SSM from "@distilled.cloud/aws/ssm"; |
| 31 | import { expect } from "alchemy-test"; |
| 32 | import * as Effect from "effect/Effect"; |
| 33 | import * as Redacted from "effect/Redacted"; |
| 34 | import { |
| 35 | dockerAvailable, |
| 36 | rawAwsJson, |
| 37 | rawAwsQuery, |
| 38 | rawS3GetBucket, |
| 39 | regionOfArn, |
| 40 | } from "./fixtures/raw.ts"; |
| 41 | import { liveContext } from "./fixtures/live.ts"; |
| 42 | |
| 43 | const { test } = Test.make({ providers: AWS.providers(), dev: true }); |
| 44 | |
| 45 | const getState = Effect.fn(function* (fqn: string) { |
| 46 | const state = yield* yield* State; |
| 47 | const stk = yield* Stack; |
| 48 | return (yield* state.get({ |
| 49 | stack: stk.name, |
| 50 | stage: stk.stage, |
| 51 | fqn, |
| 52 | })) as ResourceState | undefined; |
| 53 | }); |
| 54 | |
| 55 | const assumeRolePolicy = { |
| 56 | Version: "2012-10-17" as const, |
| 57 | Statement: [ |
| 58 | { |
| 59 | Effect: "Allow" as const, |
| 60 | Principal: { Service: "lambda.amazonaws.com" }, |
| 61 | Action: ["sts:AssumeRole"], |
| 62 | }, |
| 63 | ], |
| 64 | }; |
| 65 | |
| 66 | test.provider.skipIf(!dockerAvailable)( |
| 67 | "dev mode routes the serverless data plane to floci and stamps local mode", |
| 68 | (stack) => |
| 69 | Effect.gen(function* () { |
| 70 | yield* stack.destroy(); |
| 71 | |
| 72 | const outputs = yield* stack.deploy( |
| 73 | Effect.gen(function* () { |
| 74 | const bucket = yield* AWS.S3.Bucket("DataBucket"); |
| 75 | const queue = yield* AWS.SQS.Queue("DataQueue"); |
| 76 | const table = yield* AWS.DynamoDB.Table("DataTable", { |
| 77 | partitionKey: "id", |
| 78 | attributes: { id: "S" }, |
| 79 | }); |
| 80 | // Requires the alchemy floci fork ≥ 1.6.0-alchemy.2: the Topic |
| 81 | // reconciler observes SNS GetDataProtectionPolicy unconditionally |
| 82 | // on standard topics, which stock floci 1.6.0 rejects. |
| 83 | const topic = yield* AWS.SNS.Topic("DataTopic"); |
| 84 | const secret = yield* AWS.SecretsManager.Secret("DataSecret", { |
| 85 | secretString: Redacted.make("local-secret-value"), |
| 86 | }); |
| 87 | const parameter = yield* AWS.SSM.Parameter("DataParameter", { |
| 88 | value: "local-parameter-value", |
| 89 | }); |
| 90 | const role = yield* AWS.IAM.Role("DataRole", { |
| 91 | assumeRolePolicyDocument: assumeRolePolicy, |
| 92 | }); |
| 93 | const bus = yield* AWS.EventBridge.EventBus("DataBus"); |
| 94 | const rule = yield* AWS.EventBridge.Rule("DataRule", { |
| 95 | eventBusName: bus.eventBusName, |
| 96 | eventPattern: { source: ["alchemy.local-test"] }, |
| 97 | }); |
| 98 | return { |
| 99 | bucket, |
| 100 | queue, |
| 101 | table, |
| 102 | topic, |
| 103 | secret, |
| 104 | parameter, |
| 105 | role, |
| 106 | bus, |
| 107 | rule, |
| 108 | }; |
| 109 | }), |
| 110 | ); |
| 111 | |
| 112 | // --- Dummy-account identities: the live cloud can never mint these. |
| 113 | expect(outputs.queue.queueArn).toContain(":000000000000:"); |
| 114 | expect(outputs.queue.queueUrl).toContain("localhost"); |
| 115 | expect(outputs.table.tableArn).toContain(":000000000000:"); |
| 116 | expect(outputs.topic.topicArn).toContain(":000000000000:"); |
| 117 | expect(outputs.secret.secretArn).toContain(":000000000000:"); |
| 118 | expect(outputs.parameter.parameterArn).toContain(":000000000000:"); |
| 119 | expect(outputs.role.roleArn).toContain("::000000000000:"); |
| 120 | expect(outputs.bus.eventBusArn).toContain(":000000000000:"); |
| 121 | expect(outputs.rule.ruleArn).toContain(":000000000000:"); |
| 122 | expect(outputs.bucket.bucketArn).toContain(outputs.bucket.bucketName); |
| 123 | |
| 124 | // --- Stamped-mode proof: every dualized row commits providerMode |
| 125 | // "local" (the replacement-on-mode-switch law is pinned by |
| 126 | // provider-mode.test.ts on exactly this stamp). |
| 127 | for (const fqn of [ |
| 128 | "DataBucket", |
| 129 | "DataQueue", |
| 130 | "DataTable", |
| 131 | "DataTopic", |
| 132 | "DataSecret", |
| 133 | "DataParameter", |
| 134 | "DataRole", |
| 135 | "DataBus", |
| 136 | "DataRule", |
| 137 | ]) { |
| 138 | const row = yield* getState(fqn); |
| 139 | expect(`${fqn}:${row?.status}`).toBe(`${fqn}:created`); |
| 140 | expect(`${fqn}:${row?.providerMode}`).toBe(`${fqn}:local`); |
| 141 | } |
| 142 | |
| 143 | const region = regionOfArn(outputs.queue.queueArn); |
| 144 | |
| 145 | // --- Out-of-band: raw HTTP against the emulator gateway (no distilled |
| 146 | // SDK, no alchemy layers). Real-cloud resources would not be here. |
| 147 | const listQueues = yield* rawAwsJson({ |
| 148 | service: "sqs", |
| 149 | region, |
| 150 | target: "AmazonSQS.ListQueues", |
| 151 | contentType: "application/x-amz-json-1.0", |
| 152 | body: {}, |
| 153 | }); |
| 154 | expect(listQueues.status).toBe(200); |
| 155 | const queues = (yield* listQueues.json) as { QueueUrls?: string[] }; |
| 156 | expect( |
| 157 | queues.QueueUrls?.some((url) => |
| 158 | url.endsWith(`/${outputs.queue.queueName}`), |
| 159 | ), |
| 160 | ).toBe(true); |
| 161 | |
| 162 | const describeTable = yield* rawAwsJson({ |
| 163 | service: "dynamodb", |
| 164 | region, |
| 165 | target: "DynamoDB_20120810.DescribeTable", |
| 166 | contentType: "application/x-amz-json-1.0", |
| 167 | body: { TableName: outputs.table.tableName }, |
| 168 | }); |
| 169 | expect(describeTable.status).toBe(200); |
| 170 | |
| 171 | const getBucket = yield* rawS3GetBucket(outputs.bucket.bucketName); |
| 172 | expect(getBucket.status).toBe(200); |
| 173 | |
| 174 | const getTopicAttrs = yield* rawAwsQuery({ |
| 175 | service: "sns", |
| 176 | region, |
| 177 | params: { |
| 178 | Action: "GetTopicAttributes", |
| 179 | Version: "2010-03-31", |
| 180 | TopicArn: outputs.topic.topicArn, |
| 181 | }, |
| 182 | }); |
| 183 | expect(getTopicAttrs.status).toBe(200); |
| 184 | |
| 185 | const getParameter = yield* rawAwsJson({ |
| 186 | service: "ssm", |
| 187 | region, |
| 188 | target: "AmazonSSM.GetParameter", |
| 189 | contentType: "application/x-amz-json-1.1", |
| 190 | body: { Name: outputs.parameter.parameterName }, |
| 191 | }); |
| 192 | expect(getParameter.status).toBe(200); |
| 193 | const param = (yield* getParameter.json) as { |
| 194 | Parameter?: { Value?: string }; |
| 195 | }; |
| 196 | expect(param.Parameter?.Value).toBe("local-parameter-value"); |
| 197 | |
| 198 | const describeSecret = yield* rawAwsJson({ |
| 199 | service: "secretsmanager", |
| 200 | region, |
| 201 | target: "secretsmanager.DescribeSecret", |
| 202 | contentType: "application/x-amz-json-1.1", |
| 203 | body: { SecretId: outputs.secret.secretArn }, |
| 204 | }); |
| 205 | expect(describeSecret.status).toBe(200); |
| 206 | |
| 207 | // --- Destroy and verify gone from the emulator. |
| 208 | yield* stack.destroy(); |
| 209 | |
| 210 | const queuesAfter = (yield* (yield* rawAwsJson({ |
| 211 | service: "sqs", |
| 212 | region, |
| 213 | target: "AmazonSQS.ListQueues", |
| 214 | contentType: "application/x-amz-json-1.0", |
| 215 | body: {}, |
| 216 | })).json) as { QueueUrls?: string[] }; |
| 217 | expect( |
| 218 | queuesAfter.QueueUrls?.some((url) => |
| 219 | url.endsWith(`/${outputs.queue.queueName}`), |
| 220 | ) ?? false, |
| 221 | ).toBe(false); |
| 222 | |
| 223 | const tableAfter = yield* rawAwsJson({ |
| 224 | service: "dynamodb", |
| 225 | region, |
| 226 | target: "DynamoDB_20120810.DescribeTable", |
| 227 | contentType: "application/x-amz-json-1.0", |
| 228 | body: { TableName: outputs.table.tableName }, |
| 229 | }); |
| 230 | expect(tableAfter.status).toBe(400); // ResourceNotFoundException |
| 231 | |
| 232 | const bucketAfter = yield* rawS3GetBucket(outputs.bucket.bucketName); |
| 233 | expect(bucketAfter.status).toBe(404); // NoSuchBucket |
| 234 | |
| 235 | const topicAfter = yield* rawAwsQuery({ |
| 236 | service: "sns", |
| 237 | region, |
| 238 | params: { |
| 239 | Action: "GetTopicAttributes", |
| 240 | Version: "2010-03-31", |
| 241 | TopicArn: outputs.topic.topicArn, |
| 242 | }, |
| 243 | }); |
| 244 | expect(topicAfter.status).toBe(404); // NotFoundException |
| 245 | }), |
| 246 | { |
| 247 | tags: [ |
| 248 | "provider:aws", |
| 249 | "provider:aws:dynamodb", |
| 250 | "provider:aws:eventbridge", |
| 251 | "provider:aws:iam", |
| 252 | "provider:aws:s3", |
| 253 | "provider:aws:secretsmanager", |
| 254 | "provider:aws:sns", |
| 255 | "provider:aws:sqs", |
| 256 | "provider:aws:ssm", |
| 257 | "local", |
| 258 | ], |
| 259 | timeout: 300_000, |
| 260 | }, |
| 261 | ); |
| 262 | |
| 263 | /** |
| 264 | * `Alchemy.remote()` opts a resource OUT of local emulation: even in a dev |
| 265 | * run the parameter is created on real AWS by the live provider variant |
| 266 | * (testing-profile credentials), its state row is stamped `live`, and the |
| 267 | * destroy deletes it from the real cloud. |
| 268 | */ |
| 269 | test.provider.skipIf(!dockerAvailable)( |
| 270 | "Alchemy.remote() parameter runs live in dev alongside a local one", |
| 271 | (stack) => |
| 272 | Effect.gen(function* () { |
| 273 | yield* stack.destroy(); |
| 274 | |
| 275 | const outputs = yield* stack.deploy( |
| 276 | Effect.gen(function* () { |
| 277 | const localParam = yield* AWS.SSM.Parameter("MixedLocalParam", { |
| 278 | value: "local-value", |
| 279 | }); |
| 280 | const liveParam = yield* AWS.SSM.Parameter("MixedLiveParam", { |
| 281 | value: "live-value", |
| 282 | }).pipe(Alchemy.remote()); |
| 283 | return { localParam, liveParam }; |
| 284 | }), |
| 285 | ); |
| 286 | |
| 287 | // The default parameter is emulated; the remote() one is real. |
| 288 | expect(outputs.localParam.parameterArn).toContain(":000000000000:"); |
| 289 | expect(outputs.liveParam.parameterArn).not.toContain(":000000000000:"); |
| 290 | expect((yield* getState("MixedLocalParam"))?.providerMode).toBe("local"); |
| 291 | expect((yield* getState("MixedLiveParam"))?.providerMode).toBe("live"); |
| 292 | |
| 293 | // Out-of-band: read the REAL cloud explicitly (the ambient |
| 294 | // environment in a dev run is the emulator) — the remote() parameter |
| 295 | // must be there with its value. |
| 296 | const live = yield* SSM.getParameter({ |
| 297 | Name: outputs.liveParam.parameterName, |
| 298 | }).pipe(Effect.provide(liveContext)); |
| 299 | const liveValue = live.Parameter?.Value; |
| 300 | expect( |
| 301 | typeof liveValue === "string" ? liveValue : Redacted.value(liveValue!), |
| 302 | ).toBe("live-value"); |
| 303 | |
| 304 | yield* stack.destroy(); |
| 305 | |
| 306 | // The live parameter was deleted from the real cloud on destroy (its |
| 307 | // state row is stamped live, so the live provider handles the delete |
| 308 | // even in a dev run). |
| 309 | const gone = yield* SSM.getParameter({ |
| 310 | Name: outputs.liveParam.parameterName, |
| 311 | }).pipe( |
| 312 | Effect.as(false), |
| 313 | Effect.catchTag("ParameterNotFound", () => Effect.succeed(true)), |
| 314 | Effect.provide(liveContext), |
| 315 | ); |
| 316 | expect(gone).toBe(true); |
| 317 | }), |
| 318 | { tags: ["provider:aws", "provider:aws:ssm", "live"], timeout: 300_000 }, |
| 319 | ); |