| 1 | /** |
| 2 | * Floci local-emulator smoke test. |
| 3 | * |
| 4 | * Deploys real AWS resources (S3 Bucket, SQS Queue, DynamoDB Table) through |
| 5 | * the regular live providers, but pointed at a locally-running floci emulator |
| 6 | * via the floci-scoped environment the local AWS providers carry. |
| 7 | * |
| 8 | * ## How local mode is activated |
| 9 | * |
| 10 | * The AWS auth method is normally chosen by the profile entry in |
| 11 | * `~/.alchemy/profiles.json` (written by `alchemy profile edit`). Tests |
| 12 | * must not depend on the developer's on-disk profiles. `Test.make({ dev: |
| 13 | * true })` runs the stack the way `alchemy dev` does, so the local AWS |
| 14 | * providers are selected; they carry their own floci-scoped environment, |
| 15 | * which resolves dummy credentials (`test`/`test`), accountId |
| 16 | * `000000000000`, and endpoint `http://localhost:4566`, and `ensureFloci()` |
| 17 | * guarantees the emulator is serving (starting the `alchemy-floci` |
| 18 | * container if needed). Without `dev: true` the live providers run against |
| 19 | * whatever account the profile resolves — the real cloud. |
| 20 | * |
| 21 | * ## Why no real-AWS calls can happen |
| 22 | * |
| 23 | * The floci environment's credentials are hardcoded dummies and its accountId |
| 24 | * is fixed — it never calls STS. Every SDK call carries the emulator endpoint |
| 25 | * (via `Endpoint.fromEnvironment`); if any call ever escaped to real AWS it |
| 26 | * would fail auth immediately (the dummy keys exist in no real account). |
| 27 | * |
| 28 | * Requires Docker (floci runs as a container); skipped when the Docker |
| 29 | * daemon is unavailable. |
| 30 | */ |
| 31 | import * as AWS from "@/AWS"; |
| 32 | import { Table } from "@/AWS/DynamoDB"; |
| 33 | import { Bucket } from "@/AWS/S3"; |
| 34 | import { Queue } from "@/AWS/SQS"; |
| 35 | import * as Test from "@/Test/Alchemy"; |
| 36 | import { expect } from "alchemy-test"; |
| 37 | import * as Effect from "effect/Effect"; |
| 38 | import * as HttpBody from "effect/http/HttpBody"; |
| 39 | import * as HttpClient from "effect/http/HttpClient"; |
| 40 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 41 | import { spawnSync } from "node:child_process"; |
| 42 | |
| 43 | const FLOCI_ENDPOINT = "http://localhost:4566"; |
| 44 | |
| 45 | // skipIf gate: floci runs as a Docker container. `docker info` proves the |
| 46 | // daemon is reachable (a mere `docker --version` would pass with the daemon |
| 47 | // down). Sync probe at collection time — skipIf needs a plain boolean. |
| 48 | const dockerAvailable = (() => { |
| 49 | try { |
| 50 | return ( |
| 51 | spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }) |
| 52 | .status === 0 |
| 53 | ); |
| 54 | } catch { |
| 55 | return false; |
| 56 | } |
| 57 | })(); |
| 58 | |
| 59 | const providers = AWS.providers(); |
| 60 | |
| 61 | // `dev: true` runs the same topology as the real `alchemy dev` command |
| 62 | // (including the RPC sidecar default for RPC-backed providers). |
| 63 | const { test } = Test.make({ providers, dev: true }); |
| 64 | |
| 65 | /** |
| 66 | * Raw (non-distilled) call against the emulator gateway — out-of-band proof |
| 67 | * that a resource exists IN THE EMULATOR, not the real cloud. The dummy |
| 68 | * SigV4 Authorization header is never verified by the emulator; it only |
| 69 | * carries the service/region/account scoping the gateway routes by. |
| 70 | */ |
| 71 | const rawAwsJson = Effect.fn(function* (options: { |
| 72 | service: string; |
| 73 | region: string; |
| 74 | target: string; |
| 75 | contentType: string; |
| 76 | body: Record<string, unknown>; |
| 77 | }) { |
| 78 | const client = yield* HttpClient.HttpClient; |
| 79 | return yield* client.execute( |
| 80 | HttpClientRequest.post(`${FLOCI_ENDPOINT}/`).pipe( |
| 81 | HttpClientRequest.setHeaders({ |
| 82 | "content-type": options.contentType, |
| 83 | "x-amz-target": options.target, |
| 84 | "x-amz-date": "20260101T000000Z", |
| 85 | authorization: `AWS4-HMAC-SHA256 Credential=test/20260101/${options.region}/${options.service}/aws4_request, SignedHeaders=host;x-amz-date, Signature=dummy`, |
| 86 | }), |
| 87 | HttpClientRequest.setBody( |
| 88 | HttpBody.text(JSON.stringify(options.body), options.contentType), |
| 89 | ), |
| 90 | ), |
| 91 | ); |
| 92 | }); |
| 93 | |
| 94 | /** Path-style S3 GET against the gateway (list the bucket's objects). */ |
| 95 | const rawS3GetBucket = Effect.fn(function* (bucketName: string) { |
| 96 | const client = yield* HttpClient.HttpClient; |
| 97 | return yield* client.execute( |
| 98 | HttpClientRequest.get(`${FLOCI_ENDPOINT}/${bucketName}`).pipe( |
| 99 | HttpClientRequest.setHeaders({ |
| 100 | "x-amz-date": "20260101T000000Z", |
| 101 | authorization: |
| 102 | "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=dummy", |
| 103 | }), |
| 104 | ), |
| 105 | ); |
| 106 | }); |
| 107 | |
| 108 | /** Region segment of an ARN (`arn:aws:sqs:REGION:ACCOUNT:name`). */ |
| 109 | const regionOfArn = (arn: string) => arn.split(":")[3]!; |
| 110 | |
| 111 | test.provider.skipIf(!dockerAvailable)( |
| 112 | "deploys S3 + SQS + DynamoDB into floci and destroys them", |
| 113 | (stack) => |
| 114 | Effect.gen(function* () { |
| 115 | yield* stack.destroy(); |
| 116 | |
| 117 | const outputs = yield* stack.deploy( |
| 118 | Effect.gen(function* () { |
| 119 | const bucket = yield* Bucket("FlociBucket"); |
| 120 | const queue = yield* Queue("FlociQueue"); |
| 121 | const table = yield* Table("FlociTable", { |
| 122 | partitionKey: "id", |
| 123 | attributes: { id: "S" }, |
| 124 | }); |
| 125 | return { bucket, queue, table }; |
| 126 | }), |
| 127 | ); |
| 128 | |
| 129 | // Attributes prove the providers ran their full reconcile loops. The |
| 130 | // dummy-account ARNs double as proof no real AWS account was involved. |
| 131 | expect(outputs.bucket.bucketName).toBeDefined(); |
| 132 | expect(outputs.bucket.bucketArn).toContain(outputs.bucket.bucketName); |
| 133 | expect(outputs.queue.queueUrl).toContain(outputs.queue.queueName); |
| 134 | expect(outputs.queue.queueArn).toContain(":000000000000:"); |
| 135 | expect(outputs.table.tableArn).toContain(":000000000000:"); |
| 136 | |
| 137 | const region = regionOfArn(outputs.queue.queueArn); |
| 138 | |
| 139 | // --- Out-of-band verification: raw HTTP against the emulator gateway, |
| 140 | // no distilled SDK, no alchemy layers. If the resources had gone to |
| 141 | // real AWS these calls would not find them here. |
| 142 | const listQueues = yield* rawAwsJson({ |
| 143 | service: "sqs", |
| 144 | region, |
| 145 | target: "AmazonSQS.ListQueues", |
| 146 | contentType: "application/x-amz-json-1.0", |
| 147 | body: {}, |
| 148 | }); |
| 149 | expect(listQueues.status).toBe(200); |
| 150 | const queues = (yield* listQueues.json) as { QueueUrls?: string[] }; |
| 151 | expect( |
| 152 | queues.QueueUrls?.some((url) => |
| 153 | url.endsWith(`/${outputs.queue.queueName}`), |
| 154 | ), |
| 155 | ).toBe(true); |
| 156 | |
| 157 | const describeTable = yield* rawAwsJson({ |
| 158 | service: "dynamodb", |
| 159 | region: regionOfArn(outputs.table.tableArn), |
| 160 | target: "DynamoDB_20120810.DescribeTable", |
| 161 | contentType: "application/x-amz-json-1.0", |
| 162 | body: { TableName: outputs.table.tableName }, |
| 163 | }); |
| 164 | expect(describeTable.status).toBe(200); |
| 165 | const described = (yield* describeTable.json) as { |
| 166 | Table?: { TableArn?: string }; |
| 167 | }; |
| 168 | expect(described.Table?.TableArn).toBe(outputs.table.tableArn); |
| 169 | |
| 170 | const getBucket = yield* rawS3GetBucket(outputs.bucket.bucketName); |
| 171 | expect(getBucket.status).toBe(200); |
| 172 | |
| 173 | // --- Destroy and verify gone from the emulator. |
| 174 | yield* stack.destroy(); |
| 175 | |
| 176 | const listQueuesAfter = yield* rawAwsJson({ |
| 177 | service: "sqs", |
| 178 | region, |
| 179 | target: "AmazonSQS.ListQueues", |
| 180 | contentType: "application/x-amz-json-1.0", |
| 181 | body: {}, |
| 182 | }); |
| 183 | const queuesAfter = (yield* listQueuesAfter.json) as { |
| 184 | QueueUrls?: string[]; |
| 185 | }; |
| 186 | expect( |
| 187 | queuesAfter.QueueUrls?.some((url) => |
| 188 | url.endsWith(`/${outputs.queue.queueName}`), |
| 189 | ) ?? false, |
| 190 | ).toBe(false); |
| 191 | |
| 192 | const describeTableAfter = yield* rawAwsJson({ |
| 193 | service: "dynamodb", |
| 194 | region: regionOfArn(outputs.table.tableArn), |
| 195 | target: "DynamoDB_20120810.DescribeTable", |
| 196 | contentType: "application/x-amz-json-1.0", |
| 197 | body: { TableName: outputs.table.tableName }, |
| 198 | }); |
| 199 | expect(describeTableAfter.status).toBe(400); // ResourceNotFoundException |
| 200 | |
| 201 | const getBucketAfter = yield* rawS3GetBucket(outputs.bucket.bucketName); |
| 202 | expect(getBucketAfter.status).toBe(404); // NoSuchBucket |
| 203 | }), |
| 204 | { |
| 205 | tags: [ |
| 206 | "provider:aws", |
| 207 | "provider:aws:dynamodb", |
| 208 | "provider:aws:s3", |
| 209 | "provider:aws:sqs", |
| 210 | "local", |
| 211 | ], |
| 212 | timeout: 240_000, |
| 213 | }, |
| 214 | ); |