S3 emulator
An imposter created with "protocol": "S3" is an in-memory S3 service. Point an AWS SDK at it with path-style URLs (/<bucket>/<key>). Any credentials and region work, since signatures are not checked.
Start one
Section titled “Start one”The repository’s examples/s3.json starts one on port 7070:
{ "imposters": [ { "name": "local-s3", "port": 7070, "protocol": "S3" } ]}npx imposters start --config examples/s3.jsonCreated S3 imposter "local-s3" on port 7070Imposters admin server running on http://127.0.0.1:2525 (bound to 127.0.0.1, runtime: node)Admin UI: http://127.0.0.1:2525/_uiOr over the admin API:
curl -X POST http://localhost:2525/imposters \ -H "Content-Type: application/json" \ -d '{"name": "local-s3", "port": 7070, "protocol": "S3"}'Then start it with PATCH /imposters/:id and {"status": "running"}, as with any imposter.
Point the SDK at it
Section titled “Point the SDK at it”import { CreateBucketCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
const s3 = new S3Client({ region: "us-east-1", endpoint: "http://127.0.0.1:7070", forcePathStyle: true, // required: virtual-hosted URLs are not supported credentials: { accessKeyId: "local", secretAccessKey: "local-secret" }})
await s3.send(new CreateBucketCommand({ Bucket: "my-bucket" }))await s3.send(new PutObjectCommand({ Bucket: "my-bucket", Key: "hello.txt", Body: "hi there", ContentType: "text/plain" }))
const obj = await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "hello.txt" }))console.log(obj.ContentType, obj.ETag, await obj.Body?.transformToString())text/plain "fd33e2e8ad3cb1bdd3ea8f5633fcf5c7" hi thereSupported operations
Section titled “Supported operations”| Operation | Request | Notes |
|---|---|---|
| ListBuckets | GET / |
|
| CreateBucket | PUT /<bucket> |
An existing bucket is 409 BucketAlreadyOwnedByYou. Bucket naming rules are enforced (400 InvalidBucketName) |
| HeadBucket | HEAD /<bucket> |
|
| DeleteBucket | DELETE /<bucket> |
A bucket with objects is 409 BucketNotEmpty |
| GetBucketVersioning | GET /<bucket>?versioning |
Always an empty configuration: versioning is never enabled |
| PutBucketOwnershipControls | PUT /<bucket>?ownershipControls |
Accepted (200) and discarded: nothing is stored or enforced |
| PutBucketPolicy | PUT /<bucket>?policy |
Accepted (204) and discarded: nothing is stored or enforced |
| ListObjectsV2 | GET /<bucket>?list-type=2 |
prefix, max-keys and encoding-type=url. A listing that would be truncated is 501, not paginated |
| PutObject | PUT /<bucket>/<key> |
Stores the bytes exactly, and the Content-Type (default binary/octet-stream). The ETag is the quoted MD5 |
| GetObject / HeadObject | GET / HEAD /<bucket>/<key> |
ETag, Content-Type, Content-Length, Last-Modified. If-None-Match answers 304. No checksum headers |
| CopyObject | PUT /<bucket>/<key> + x-amz-copy-source |
Keeps the body and type. A missing source is 404 NoSuchKey |
| DeleteObject | DELETE /<bucket>/<key> |
|
| DeleteObjects | POST /<bucket>?delete |
Quiet and verbose. A key that is not there counts as deleted, as on S3 |
Errors are S3 <Error> documents (Code, Message, Resource, RequestId) with S3’s status codes; HEAD errors have no body:
curl http://localhost:7070/my-bucket/missing.txt<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message><Resource>/my-bucket/missing.txt</Resource><RequestId>0804D7D41BD671F2</RequestId></Error>What it refuses
Section titled “What it refuses”Answered with 501 NotImplemented: other bucket configuration (?publicAccessBlock, ?encryption, ?lifecycle, ?cors, versioning PUT, …), reading a policy or ownership controls back, multipart uploads, presigned URLs, aws-chunked / STREAMING-* payloads, Range and conditional headers other than If-None-Match, ListObjectsV2 pagination and delimiter, and any other operation or query parameter the emulator does not know. It never guesses: an unsupported request fails loudly instead of answering wrong.
Expected owner: every bucket belongs to whoever asks. A request whose x-amz-expected-bucket-owner (or x-amz-source-expected-bucket-owner, on a copy) differs from the access key id it is signed with is 403 AccessDenied, as S3 answers an owner mismatch; the access key id stands in for S3’s account id. An unsigned request has no requester to compare with, so it is not checked. The check runs first, before any other answer.
Keys with dot segments do not survive. The server parses each request URL with the WHATWG URL parser, which resolves . and .. path segments (even percent-encoded as %2e) before the emulator sees the path. A key such as a/../b is stored as b, and DELETE /bucket/k/.. becomes a DeleteBucket. Avoid such keys.
Not checked: SigV4 signatures. No virtual-hosted URLs, CORS, or versioning.
Fault injection with stubs
Section titled “Fault injection with stubs”Stubs are matched before the emulator, so a stub on an S3 imposter overrides one request and everything else still reaches S3. A 503 SlowDown for one key:
{ "predicates": [ { "field": "method", "operator": "equals", "value": "GET" }, { "field": "path", "operator": "equals", "value": "/my-bucket/flaky.pdf" } ], "responses": [{ "status": 503, "headers": { "content-type": "application/xml" }, "body": "<Error><Code>SlowDown</Code><Message>Please reduce your request rate.</Message></Error>" }]}Add it with POST /imposters/:id/stubs. The SDK surfaces it as an S3ServiceException named SlowDown; set maxAttempts: 1 on the client to see it without retries:
try { await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "flaky.pdf" }))} catch (e) { console.log(e.name, e.$metadata.httpStatusCode)}SlowDown 503A "delay" on the response instead trips the client’s request timeout.