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 */
25import * as AWS from "@/AWS";
26import * as Alchemy from "@/index.ts";
27import { State, type ResourceState } from "@/State";
28import { Stack } from "@/Stack";
29import * as Test from "@/Test/Alchemy";
30import * as SSM from "@distilled.cloud/aws/ssm";
31import { expect } from "alchemy-test";
32import * as Effect from "effect/Effect";
33import * as Redacted from "effect/Redacted";
34import {
35 dockerAvailable,
36 rawAwsJson,
37 rawAwsQuery,
38 rawS3GetBucket,
39 regionOfArn,
40} from "./fixtures/raw.ts";
41import { liveContext } from "./fixtures/live.ts";
42
43const { test } = Test.make({ providers: AWS.providers(), dev: true });
44
45const 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
55const 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
66test.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 */
269test.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);