s3-test-server 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,18 +2,12 @@
2
2
 
3
3
  A lightweight Amazon S3 emulator for local development and testing.
4
4
 
5
- Zero runtime dependencies, a single small package, and wire-level
6
- compatibility with both old and new S3 clients:
7
-
8
- - **AWS SDK for JavaScript v3**, including flexible checksums
9
- (CRC32, CRC32C, CRC64NVME, SHA1, SHA256), `aws-chunked` streaming uploads
10
- with trailers, and full-object / composite multipart checksums
11
- - **AWS SDK for JavaScript v2**, SigV4 and legacy SigV2 (`signatureVersion: 's3'`)
12
- - **Any other S3 client** that signs with Signature V2 or V4, header-based or
13
- presigned, path-style or virtual-host-style
14
-
15
- Data is stored on the filesystem (a temp directory by default), so it survives
16
- restarts when you point it at a directory of your own.
5
+ Zero runtime dependencies and wire-level compatibility with old and new S3
6
+ clients: AWS SDK for JavaScript v3 (flexible checksums, `aws-chunked`
7
+ streaming, multipart), v2 (SigV4 and legacy SigV2), and any other client that
8
+ signs with Signature V2/V4, header-based or presigned, path-style or
9
+ virtual-host-style. Objects are stored on the filesystem and persist across
10
+ restarts when you point `directory` at a folder of your own.
17
11
 
18
12
  ## Quick start
19
13
 
@@ -82,7 +76,7 @@ map to 127.0.0.1):
82
76
  ```js
83
77
  const server = new S3TestServer({
84
78
  address: 'aws-s3.local',
85
- configureBuckets: [{ name: 'reports' }],
79
+ configureBuckets: [{ name: 'reports', acl: 'public-read' }],
86
80
  });
87
81
 
88
82
  beforeAll(() => server.run()); // http://aws-s3.local:4568
@@ -108,44 +102,141 @@ multipart thresholds, this test fails, exactly as it would against AWS.
108
102
  All options are optional. `new S3TestServer()` starts an HTTP server on
109
103
  `localhost:4568` backed by a temporary directory.
110
104
 
111
- ### Server
112
-
113
- - `address` (string, default `'localhost'`): hostname or IP to bind to.
114
- - `port` (number, default `4568`): port to listen on. Use `0` for an
115
- ephemeral port.
116
- - `key`, `cert` (string | Buffer): TLS key and certificate. Provide both to
117
- serve HTTPS.
118
- - `silent` (boolean, default `false`): suppress log output.
119
-
120
- ### Storage
121
-
122
- - `directory` (string, default: a temporary directory): where buckets and
123
- objects are stored. Data persists across restarts.
124
- - `resetOnStart` (boolean, default `false`): clear the data directory on
125
- every start, so a persistent `directory` begins empty.
126
- - `resetOnClose` (boolean, default `false`): delete all bucket data when
127
- `close()` is called.
128
- - `configureBuckets` (array, default `[]`): buckets to create at startup,
129
- each `{ name, configs }` with optional raw XML configs (CORS, website,
130
- tagging).
131
-
132
- ### Credentials and addressing
133
-
134
- - `accessKeyId` (string, default `'S3TEST'`): access key clients must sign
135
- with.
136
- - `secretAccessKey` (string, default `'S3TEST'`): matching secret key.
137
- - `region` (string, default `'us-east-1'`): region reported in responses and
138
- event records.
139
- - `vhostBuckets` (boolean, default `true`): virtual-host-style addressing
140
- (`bucket.localhost`, `bucket.s3.<region>.amazonaws.com`).
141
- - `serviceEndpoint` (string, default `'amazonaws.com'`): root domain
142
- recognized for virtual-host-style access.
143
- - `allowMismatchedSignatures` (boolean, default `false`): accept well-formed
144
- but incorrect signatures.
145
-
146
- Anonymous (unsigned) requests are accepted, as on a public bucket, which is
147
- handy for plain `fetch`/`curl` in tests. Signed requests are verified against
148
- `accessKeyId`/`secretAccessKey`.
105
+ ```js
106
+ // bucket configuration documents used below
107
+ const corsXml = `<CORSConfiguration>
108
+ <CORSRule>
109
+ <AllowedOrigin>https://app.example.com</AllowedOrigin>
110
+ <AllowedMethod>GET</AllowedMethod>
111
+ <AllowedMethod>PUT</AllowedMethod>
112
+ <AllowedHeader>*</AllowedHeader>
113
+ </CORSRule>
114
+ </CORSConfiguration>`;
115
+
116
+ const websiteXml = `<WebsiteConfiguration>
117
+ <IndexDocument><Suffix>index.html</Suffix></IndexDocument>
118
+ <ErrorDocument><Key>error.html</Key></ErrorDocument>
119
+ </WebsiteConfiguration>`;
120
+
121
+ const policyJson = JSON.stringify({
122
+ Version: '2012-10-17',
123
+ Statement: [
124
+ {
125
+ Effect: 'Allow',
126
+ Principal: '*',
127
+ Action: 's3:GetObject',
128
+ Resource: 'arn:aws:s3:::site/public/*', // anonymous reads under public/ only
129
+ },
130
+ ],
131
+ });
132
+
133
+ const server = new S3TestServer({
134
+ // server
135
+ address: 'localhost', // hostname or IP the HTTP server binds to (default)
136
+ port: 4568, // listening port (default); 0 picks a free ephemeral port
137
+ key: fs.readFileSync('./key.pem'), // TLS private key; give key AND cert to
138
+ cert: fs.readFileSync('./cert.pem'), // serve HTTPS; omit both for HTTP (default)
139
+ silent: false, // default; true suppresses request and startup logging
140
+
141
+ // storage
142
+ directory: './s3-data', // where buckets and objects are stored, persisted
143
+ // across restarts; default: a fresh temporary directory per server
144
+ resetOnStart: false, // default; true empties the directory on every run()
145
+ resetOnClose: false, // default; true deletes all bucket data on close()
146
+ configureBuckets: [
147
+ // buckets created before the server starts; default: none
148
+ { name: 'assets', acl: 'public-read' }, // canned ACL (default: defaultBucketAcl)
149
+ { name: 'site', configs: [corsXml, websiteXml, policyJson] }, // raw CORS or
150
+ // website XML documents and bucket-policy JSON, applied at startup
151
+ ],
152
+
153
+ // credentials and addressing
154
+ accessKeyId: 'S3TEST', // access key id clients must sign with (default)
155
+ secretAccessKey: 'S3TEST', // matching SigV2/SigV4 secret key (default)
156
+ region: 'eu-west-1', // pinning a region rejects requests signed for others;
157
+ // omit to accept any region (reported as us-east-1)
158
+ defaultBucketAcl: 'private', // default, like real S3; ACL for buckets without
159
+ // one: 'private', 'public-read' (anonymous GETs) or 'public-read-write'
160
+ vhostBuckets: true, // default; resolve bucket.localhost and
161
+ // bucket.s3.<region>.amazonaws.com style Host headers to the bucket
162
+ serviceEndpoint: 'amazonaws.com', // default; root domain recognized for
163
+ // virtual-host-style addressing
164
+ allowMismatchedSignatures: false, // default; true accepts well-formed but
165
+ // incorrect signatures (useful with exotic clients)
166
+ });
167
+ ```
168
+
169
+ ## Public and private buckets
170
+
171
+ Like real S3, buckets are private by default. Signed requests are verified
172
+ against `accessKeyId`/`secretAccessKey` and have full access. Anonymous
173
+ (unsigned) requests are checked against the bucket's canned ACL:
174
+
175
+ - `private` or `authenticated-read` (the default): all anonymous requests
176
+ get `403 AccessDenied`.
177
+ - `public-read`: anonymous GET/HEAD only; anonymous writes get
178
+ `403 AccessDenied`.
179
+ - `public-read-write`: anonymous reads and writes, like a fully public
180
+ bucket. Set `defaultBucketAcl: 'public-read-write'` to restore the 1.x
181
+ behavior where unsigned `fetch`/`curl` works everywhere.
182
+
183
+ Set a bucket's ACL any of the usual ways:
184
+
185
+ ```js
186
+ // at startup
187
+ new S3TestServer({
188
+ configureBuckets: [
189
+ { name: 'assets', acl: 'public-read' },
190
+ { name: 'internal' }, // private, the default
191
+ ],
192
+ });
193
+ ```
194
+
195
+ ```js
196
+ // through the API
197
+ await client.send(
198
+ new CreateBucketCommand({ Bucket: 'assets', ACL: 'public-read' })
199
+ );
200
+ await client.send(
201
+ new PutBucketAclCommand({ Bucket: 'assets', ACL: 'private' })
202
+ );
203
+ ```
204
+
205
+ `GetBucketAcl` reflects the canned ACL (an `AllUsers` grant appears for the
206
+ public ones). Object-level ACLs are accepted but not enforced.
207
+
208
+ ### Per-prefix access with bucket policies
209
+
210
+ For finer control than canned ACLs, the server evaluates a subset of bucket
211
+ policies against anonymous requests: `Effect` Allow/Deny, `Principal` `"*"`,
212
+ `Action` wildcards (`s3:GetObject`, `s3:ListBucket`, `s3:PutObject`,
213
+ `s3:DeleteObject`, `s3:*`) and `Resource` ARNs with wildcards. Signed
214
+ requests always have full access; an explicit `Deny` only narrows anonymous
215
+ access. Set policies with `PutBucketPolicy`, or as a JSON document in
216
+ `configureBuckets` (detected by its leading `{`):
217
+
218
+ ```js
219
+ // only keys under public/ are readable without signing
220
+ const policy = JSON.stringify({
221
+ Version: '2012-10-17',
222
+ Statement: [
223
+ {
224
+ Effect: 'Allow',
225
+ Principal: '*',
226
+ Action: 's3:GetObject',
227
+ Resource: 'arn:aws:s3:::site/public/*',
228
+ },
229
+ ],
230
+ });
231
+ new S3TestServer({ configureBuckets: [{ name: 'site', configs: [policy] }] });
232
+ ```
233
+
234
+ Anonymous `GET /site/public/logo.png` then succeeds while
235
+ `GET /site/internal/report.csv` and all anonymous writes return
236
+ `403 AccessDenied`. `GetBucketPolicy` returns the stored document and
237
+ `DeleteBucketPolicy` removes it. `NotAction`, `NotResource`, `NotPrincipal`
238
+ and `Condition` are rejected as `NotImplemented` rather than silently
239
+ ignored.
149
240
 
150
241
  ## Supported operations
151
242
 
@@ -158,7 +249,8 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
158
249
  Get/Put/DeleteBucketTagging
159
250
  - GetBucketVersioning, GetBucketEncryption, GetBucketNotification,
160
251
  GetBucketRequestPayment, GetBucketLogging, GetBucketAccelerate
161
- - Bucket ACLs (dummy responses)
252
+ - Bucket ACLs (canned, enforced for anonymous requests)
253
+ - Bucket policies (anonymous-access subset): Get/Put/DeleteBucketPolicy
162
254
 
163
255
  ### Objects
164
256
 
@@ -188,7 +280,7 @@ handy for plain `fetch`/`curl` in tests. Signed requests are verified against
188
280
  - Index and error documents, folder redirects
189
281
  - `x-amz-website-redirect-location` and `RedirectAllRequestsTo`
190
282
  - Served for anonymous GET/HEAD requests on buckets with a website
191
- configuration
283
+ configuration; make the bucket `public-read`, as on real S3
192
284
 
193
285
  ### CORS
194
286
 
@@ -216,6 +308,33 @@ server.on('event', (notification) => {
216
308
  Emitted for `ObjectCreated:Put`, `ObjectCreated:Copy`, `ObjectCreated:Post`,
217
309
  `ObjectCreated:CompleteMultipartUpload` and `ObjectRemoved:Delete`.
218
310
 
311
+ ## Test helpers
312
+
313
+ Three helpers make assertions and fixtures easy without building an S3
314
+ client; they talk to the store directly:
315
+
316
+ ```js
317
+ // seed fixtures (bucket is created if missing; no events are emitted)
318
+ await server.putObjects('reports', [
319
+ { key: 'public/a.csv', body: 'a,b,c', contentType: 'text/csv' },
320
+ { key: 'internal/b.csv', body: Buffer.from('d,e,f') },
321
+ ]);
322
+
323
+ // inspect bucket contents in assertions
324
+ const all = await server.getObjects('reports');
325
+ const csvs = await server.getObjects('reports', {
326
+ prefix: 'public/',
327
+ bodies: true, // each summary gets a `body` Buffer
328
+ });
329
+
330
+ // wait for background uploads from the code under test;
331
+ // multipart uploads only count once completed
332
+ const files = await server.waitForObjects('reports', 2, { timeout: 5000 });
333
+ ```
334
+
335
+ After `run()` resolves, `server.hostPort` (`"localhost:4568"`) and
336
+ `server.endpoint` (`"http://localhost:4568"`) are also set on the instance.
337
+
219
338
  ## Using as middleware
220
339
 
221
340
  ```js
@@ -234,6 +353,9 @@ http.createServer(s3.callback()).listen(4568);
234
353
  - `callback()` / `getMiddleware()` returns a request handler
235
354
  - `configureBuckets()` provisions `options.configureBuckets` without the API
236
355
  - `reset()` deletes all buckets and configurations
356
+ - `getObjects(bucket, { prefix, bodies })` lists completed objects
357
+ - `waitForObjects(bucket, count, { prefix, timeout })` awaits uploads
358
+ - `putObjects(bucket, objects)` seeds fixtures without the HTTP API
237
359
 
238
360
  TypeScript definitions ship with the package (`lib/index.d.ts`).
239
361
 
package/lib/cli.js CHANGED
@@ -14,7 +14,10 @@ Options:
14
14
  -d, --directory <path> Data directory (default: a temporary directory)
15
15
  --access-key-id <id> Access key clients must sign with (default: S3TEST)
16
16
  --secret-access-key <key> Matching secret access key (default: S3TEST)
17
- --region <region> Region reported by the server (default: us-east-1)
17
+ --region <region> Pin the server's region; requests signed for other
18
+ regions are rejected (default: accept any, report us-east-1)
19
+ --default-bucket-acl <acl> ACL for buckets without one: private (default),
20
+ public-read, public-read-write, authenticated-read
18
21
  --key <path> Path to a private key file for HTTPS
19
22
  --cert <path> Path to a certificate file for HTTPS
20
23
  --service-endpoint <host> Root domain for virtual-host-style access
@@ -69,6 +72,9 @@ function parseArgs(argv) {
69
72
  case '--region':
70
73
  options.region = next();
71
74
  break;
75
+ case '--default-bucket-acl':
76
+ options.defaultBucketAcl = next();
77
+ break;
72
78
  case '--key':
73
79
  options.key = fs.readFileSync(next());
74
80
  break;
@@ -18,15 +18,58 @@ const {
18
18
  DUMMY_OWNER,
19
19
  } = require('../utils');
20
20
 
21
- const dummyAclXml = () =>
22
- '<?xml version="1.0" encoding="UTF-8"?>\n' +
23
- `<AccessControlPolicy xmlns="${S3_XMLNS}">` +
24
- `<Owner><ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Owner>` +
25
- '<AccessControlList><Grant>' +
26
- '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="CanonicalUser">' +
27
- `<ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Grantee>` +
28
- '<Permission>FULL_CONTROL</Permission>' +
29
- '</Grant></AccessControlList></AccessControlPolicy>';
21
+ const CANNED_ACLS = new Set([
22
+ 'private',
23
+ 'public-read',
24
+ 'public-read-write',
25
+ 'authenticated-read',
26
+ ]);
27
+
28
+ const ALL_USERS_URI = 'http://acs.amazonaws.com/groups/global/AllUsers';
29
+
30
+ function groupGrant(permission) {
31
+ return (
32
+ '<Grant>' +
33
+ '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="Group">' +
34
+ `<URI>${ALL_USERS_URI}</URI></Grantee>` +
35
+ `<Permission>${permission}</Permission>` +
36
+ '</Grant>'
37
+ );
38
+ }
39
+
40
+ /** Renders an AccessControlPolicy document reflecting a canned ACL. */
41
+ function aclXml(acl = 'private') {
42
+ let grants =
43
+ '<Grant>' +
44
+ '<Grantee xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="CanonicalUser">' +
45
+ `<ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Grantee>` +
46
+ '<Permission>FULL_CONTROL</Permission>' +
47
+ '</Grant>';
48
+ if (acl === 'public-read' || acl === 'public-read-write') {
49
+ grants += groupGrant('READ');
50
+ }
51
+ if (acl === 'public-read-write') {
52
+ grants += groupGrant('WRITE');
53
+ }
54
+ return (
55
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
56
+ `<AccessControlPolicy xmlns="${S3_XMLNS}">` +
57
+ `<Owner><ID>${DUMMY_OWNER.ID}</ID><DisplayName>${DUMMY_OWNER.DisplayName}</DisplayName></Owner>` +
58
+ `<AccessControlList>${grants}</AccessControlList></AccessControlPolicy>`
59
+ );
60
+ }
61
+
62
+ const dummyAclXml = () => aclXml('private');
63
+
64
+ function validateCannedAcl(acl) {
65
+ if (!CANNED_ACLS.has(acl)) {
66
+ throw new S3Error('InvalidArgument', 'The ACL you provided is not valid', {
67
+ ArgumentName: 'x-amz-acl',
68
+ ArgumentValue: acl,
69
+ });
70
+ }
71
+ return acl;
72
+ }
30
73
 
31
74
  async function requireBucket(ctx) {
32
75
  const meta = await ctx.store.getBucket(ctx.bucket);
@@ -56,8 +99,8 @@ async function getBucket(ctx) {
56
99
  return;
57
100
  }
58
101
  if (query.has('acl')) {
59
- await requireBucket(ctx);
60
- sendXml(res, 200, dummyAclXml());
102
+ const meta = await requireBucket(ctx);
103
+ sendXml(res, 200, aclXml(meta.configs.acl || ctx.options.defaultBucketAcl));
61
104
  return;
62
105
  }
63
106
  if (query.has('cors')) {
@@ -110,14 +153,23 @@ async function getBucket(ctx) {
110
153
  return;
111
154
  }
112
155
  if (query.has('policy')) {
113
- await requireBucket(ctx);
114
- throw new S3Error(
115
- 'NoSuchBucketPolicy',
116
- 'The bucket policy does not exist',
117
- {
118
- BucketName: ctx.bucket,
119
- }
156
+ const meta = await requireBucket(ctx);
157
+ if (!meta.configs.policy) {
158
+ throw new S3Error(
159
+ 'NoSuchBucketPolicy',
160
+ 'The bucket policy does not exist',
161
+ {
162
+ BucketName: ctx.bucket,
163
+ }
164
+ );
165
+ }
166
+ send(
167
+ res,
168
+ 200,
169
+ { 'Content-Type': 'application/json' },
170
+ meta.configs.policy.json
120
171
  );
172
+ return;
121
173
  }
122
174
  if (query.has('lifecycle')) {
123
175
  await requireBucket(ctx);
@@ -486,9 +538,26 @@ async function putBucket(ctx) {
486
538
  if (query.has('acl')) {
487
539
  await requireBucket(ctx);
488
540
  await readBody(ctx.req);
541
+ const acl = ctx.req.headers['x-amz-acl'];
542
+ if (acl) {
543
+ await ctx.store.putBucketConfig(
544
+ ctx.bucket,
545
+ 'acl',
546
+ validateCannedAcl(String(acl))
547
+ );
548
+ }
489
549
  send(res, 200, {});
490
550
  return;
491
551
  }
552
+ if (query.has('policy')) {
553
+ await requireBucket(ctx);
554
+ const body = await readBody(ctx.req);
555
+ const { parseBucketPolicy } = require('../models/policy');
556
+ const policy = parseBucketPolicy(body.toString('utf8'));
557
+ await ctx.store.putBucketConfig(ctx.bucket, 'policy', policy);
558
+ send(res, 204, {});
559
+ return;
560
+ }
492
561
  if (query.has('versioning')) {
493
562
  await requireBucket(ctx);
494
563
  const body = await readBody(ctx.req);
@@ -512,7 +581,6 @@ async function putBucket(ctx) {
512
581
  }
513
582
  const knownUnsupported = [
514
583
  'lifecycle',
515
- 'policy',
516
584
  'encryption',
517
585
  'object-lock',
518
586
  'publicAccessBlock',
@@ -553,10 +621,26 @@ async function putBucket(ctx) {
553
621
  );
554
622
  }
555
623
  }
624
+ if (
625
+ ctx.options.enforceRegion &&
626
+ location &&
627
+ location !== ctx.options.region
628
+ ) {
629
+ throw new S3Error(
630
+ 'IllegalLocationConstraintException',
631
+ `The ${location} location constraint is incompatible for the region specific endpoint this request was sent to.`,
632
+ { statusCode: 400 }
633
+ );
634
+ }
635
+ const acl = ctx.req.headers['x-amz-acl'];
636
+ if (acl) validateCannedAcl(String(acl));
556
637
  const { created } = await ctx.store.createBucket(ctx.bucket);
557
638
  if (created && location) {
558
639
  await ctx.store.putBucketConfig(ctx.bucket, 'location', location);
559
640
  }
641
+ if (created && acl) {
642
+ await ctx.store.putBucketConfig(ctx.bucket, 'acl', String(acl));
643
+ }
560
644
  // Matching us-east-1 behavior: re-creating a bucket you own succeeds.
561
645
  send(ctx.res, 200, { Location: `/${ctx.bucket}` });
562
646
  }
@@ -570,7 +654,7 @@ async function deleteBucket(ctx) {
570
654
  website: 'website',
571
655
  tagging: 'tagging',
572
656
  lifecycle: null,
573
- policy: null,
657
+ policy: 'policy',
574
658
  encryption: null,
575
659
  publicAccessBlock: null,
576
660
  replication: null,
@@ -618,6 +702,7 @@ module.exports = {
618
702
  deleteBucket,
619
703
  headBucket,
620
704
  dummyAclXml,
705
+ validateCannedAcl,
621
706
  parseCorsConfig,
622
707
  parseWebsiteConfig,
623
708
  parseTaggingConfig,
package/lib/index.d.ts CHANGED
@@ -11,6 +11,12 @@ declare class S3TestServer extends EventEmitter {
11
11
  /** The underlying HTTP(S) server, available after `run()` resolves. */
12
12
  httpServer: HttpServer | null;
13
13
 
14
+ /** `host:port` the server listens on; set once `run()` resolves. */
15
+ hostPort: string | null;
16
+
17
+ /** Full endpoint URL (e.g. `http://localhost:4568`); set once `run()` resolves. */
18
+ endpoint: string | null;
19
+
14
20
  /** Starts the server. */
15
21
  run(): Promise<S3TestServer.RunAddress>;
16
22
  run(
@@ -32,6 +38,34 @@ declare class S3TestServer extends EventEmitter {
32
38
  /** Deletes all buckets, objects and configurations in the store. */
33
39
  reset(): Promise<void>;
34
40
 
41
+ /**
42
+ * Lists the completed objects in a bucket, sorted by key. With
43
+ * `bodies: true`, each summary also carries its content as a Buffer.
44
+ */
45
+ getObjects(
46
+ bucket: string,
47
+ options?: { prefix?: string; bodies?: boolean }
48
+ ): Promise<S3TestServer.ObjectSummary[]>;
49
+
50
+ /**
51
+ * Resolves with the bucket's objects once at least `count` exist
52
+ * (optionally under `prefix`); rejects after `timeout` ms (default 10000).
53
+ */
54
+ waitForObjects(
55
+ bucket: string,
56
+ count: number,
57
+ options?: { prefix?: string; timeout?: number }
58
+ ): Promise<S3TestServer.ObjectSummary[]>;
59
+
60
+ /**
61
+ * Seeds objects directly into the store (the bucket is created when
62
+ * missing). No S3 event notifications are emitted for seeded objects.
63
+ */
64
+ putObjects(
65
+ bucket: string,
66
+ objects: S3TestServer.SeedObject[]
67
+ ): Promise<S3TestServer.ObjectSummary[]>;
68
+
35
69
  on(
36
70
  event: 'event',
37
71
  listener: (notification: S3TestServer.S3EventNotification) => void
@@ -48,14 +82,21 @@ declare namespace S3TestServer {
48
82
  interface BucketConfiguration {
49
83
  /** Bucket name (must be a valid S3 bucket name). */
50
84
  name: string;
85
+ /** Canned ACL applied to the bucket. Default: the server's `defaultBucketAcl`. */
86
+ acl?: CannedBucketAcl;
51
87
  /**
52
- * Raw XML configuration documents applied to the bucket at startup.
53
- * CORS (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and
54
- * tagging (`<Tagging>`) documents are recognized by their root element.
88
+ * Raw configuration documents applied to the bucket at startup.
89
+ * XML documents are recognized by their root element: CORS
90
+ * (`<CORSConfiguration>`), website (`<WebsiteConfiguration>`) and tagging
91
+ * (`<Tagging>`). A JSON document (leading `{`) is stored as the bucket
92
+ * policy and evaluated for anonymous requests.
55
93
  */
56
94
  configs?: Array<string | Buffer>;
57
95
  }
58
96
 
97
+ type CannedBucketAcl =
98
+ 'private' | 'public-read' | 'public-read-write' | 'authenticated-read';
99
+
59
100
  interface S3TestServerOptions {
60
101
  /** Hostname or IP to bind to. Default: `"localhost"`. */
61
102
  address?: string;
@@ -95,8 +136,20 @@ declare namespace S3TestServer {
95
136
  accessKeyId?: string;
96
137
  /** Secret access key clients must sign requests with. Default: `"S3TEST"`. */
97
138
  secretAccessKey?: string;
98
- /** Region reported in responses and event records. Default: `"us-east-1"`. */
139
+ /**
140
+ * Region reported in responses and event records. Default: `"us-east-1"`.
141
+ * When set explicitly, the server acts like a regional endpoint and
142
+ * rejects SigV4 requests signed for other regions; when omitted, any
143
+ * region is accepted.
144
+ */
99
145
  region?: string;
146
+ /**
147
+ * ACL for buckets without an explicit one. `'private'` (the default, like
148
+ * real S3) rejects anonymous requests; `'public-read'` allows anonymous
149
+ * GET/HEAD only; `'public-read-write'` restores the fully public 1.x
150
+ * behavior.
151
+ */
152
+ defaultBucketAcl?: CannedBucketAcl;
100
153
  /** Buckets to create (and configure) before the server starts. */
101
154
  configureBuckets?: BucketConfiguration[];
102
155
  }
@@ -116,14 +169,37 @@ declare namespace S3TestServer {
116
169
  | 'accessKeyId'
117
170
  | 'secretAccessKey'
118
171
  | 'region'
172
+ | 'defaultBucketAcl'
119
173
  >
120
174
  >;
121
175
 
176
+ interface ObjectSummary {
177
+ key: string;
178
+ size: number;
179
+ /** Hex ETag without quotes. */
180
+ etag: string;
181
+ lastModified: Date;
182
+ contentType?: string;
183
+ /** User metadata (`x-amz-meta-*`), with the prefix stripped. */
184
+ metadata: Record<string, string>;
185
+ /** Present when requested with `bodies: true`. */
186
+ body?: Buffer;
187
+ }
188
+
189
+ interface SeedObject {
190
+ key: string;
191
+ body: string | Buffer;
192
+ contentType?: string;
193
+ metadata?: Record<string, string>;
194
+ }
195
+
122
196
  interface RunAddress {
123
197
  /** Bound address, as reported by `net.Server#address()`. */
124
198
  address: string;
125
199
  family: string;
126
200
  port: number;
201
+ /** `host:port` the server listens on. */
202
+ hostPort: string;
127
203
  /** `"http"` or `"https"`. */
128
204
  protocol: string;
129
205
  /** Convenience endpoint URL, e.g. `"http://localhost:4568"`. */
@@ -178,6 +254,9 @@ declare namespace S3TestServer {
178
254
 
179
255
  export {
180
256
  S3TestServer,
257
+ ObjectSummary,
258
+ SeedObject,
259
+ CannedBucketAcl,
181
260
  BucketConfiguration,
182
261
  S3TestServerOptions,
183
262
  ResolvedOptions,
package/lib/index.js CHANGED
@@ -26,6 +26,9 @@ const DEFAULT_OPTIONS = {
26
26
  accessKeyId: 'S3TEST',
27
27
  secretAccessKey: 'S3TEST',
28
28
  region: 'us-east-1',
29
+ // Like real S3, buckets are private by default: anonymous requests get
30
+ // 403 AccessDenied unless the bucket's ACL makes it public.
31
+ defaultBucketAcl: 'private',
29
32
  configureBuckets: [],
30
33
  };
31
34
 
@@ -33,6 +36,12 @@ class S3TestServer extends EventEmitter {
33
36
  constructor(options = {}) {
34
37
  super();
35
38
  this.options = { ...DEFAULT_OPTIONS, ...options };
39
+ // When the user pins a region, behave like a regional endpoint and
40
+ // reject requests signed for other regions. Otherwise accept any.
41
+ this.options.enforceRegion = Object.prototype.hasOwnProperty.call(
42
+ options,
43
+ 'region'
44
+ );
36
45
  this.logger = {
37
46
  info: (...args) => {
38
47
  if (!this.options.silent) console.log(...args);
@@ -43,6 +52,8 @@ class S3TestServer extends EventEmitter {
43
52
  };
44
53
  this.httpServer = null;
45
54
  this.store = null;
55
+ this.hostPort = null;
56
+ this.endpoint = null;
46
57
  this._middleware = null;
47
58
  this._sequencer = 0;
48
59
  }
@@ -101,13 +112,14 @@ class S3TestServer extends EventEmitter {
101
112
  });
102
113
  const address = this.httpServer.address();
103
114
  const protocol = this.options.key && this.options.cert ? 'https' : 'http';
104
- this.logger.info(
105
- `S3 test server listening on ${protocol}://${this.options.address}:${address.port}`
106
- );
115
+ this.hostPort = `${this.options.address}:${address.port}`;
116
+ this.endpoint = `${protocol}://${this.hostPort}`;
117
+ this.logger.info(`S3 test server listening on ${this.endpoint}`);
107
118
  return {
108
119
  ...address,
109
120
  protocol,
110
- endpoint: `${protocol}://${this.options.address}:${address.port}`,
121
+ hostPort: this.hostPort,
122
+ endpoint: this.endpoint,
111
123
  };
112
124
  })();
113
125
  if (typeof callback === 'function') {
@@ -157,16 +169,28 @@ class S3TestServer extends EventEmitter {
157
169
  parseCorsConfig,
158
170
  parseWebsiteConfig,
159
171
  parseTaggingConfig,
172
+ validateCannedAcl,
160
173
  } = require('./controllers/bucket');
161
174
  const { validateBucketName } = require('./utils');
162
- for (const { name, configs = [] } of this.options.configureBuckets || []) {
175
+ for (const { name, configs = [], acl } of this.options.configureBuckets ||
176
+ []) {
163
177
  validateBucketName(name);
164
178
  await this.store.createBucket(name);
179
+ if (acl) {
180
+ await this.store.putBucketConfig(name, 'acl', validateCannedAcl(acl));
181
+ }
165
182
  for (const rawConfig of configs) {
166
183
  const xml = Buffer.isBuffer(rawConfig)
167
184
  ? rawConfig.toString('utf8')
168
185
  : String(rawConfig);
169
- if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
186
+ if (/^\s*\{/.test(xml)) {
187
+ const { parseBucketPolicy } = require('./models/policy');
188
+ await this.store.putBucketConfig(
189
+ name,
190
+ 'policy',
191
+ parseBucketPolicy(xml)
192
+ );
193
+ } else if (/<\s*(\w+:)?CORSConfiguration[\s>]/.test(xml)) {
170
194
  await this.store.putBucketConfig(name, 'cors', parseCorsConfig(xml));
171
195
  } else if (/<\s*(\w+:)?WebsiteConfiguration[\s>]/.test(xml)) {
172
196
  await this.store.putBucketConfig(
@@ -196,6 +220,143 @@ class S3TestServer extends EventEmitter {
196
220
  await this.store.reset();
197
221
  }
198
222
 
223
+ // ---------------------------------------------------------------- helpers
224
+
225
+ _toSummary(meta) {
226
+ return {
227
+ key: meta.key,
228
+ size: meta.size,
229
+ etag: meta.etag,
230
+ lastModified: new Date(meta.lastModified),
231
+ contentType: meta.contentType,
232
+ metadata: { ...(meta.userMetadata || {}) },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Lists the completed objects in a bucket (multipart parts in flight are
238
+ * never included), sorted by key. With `bodies: true`, each entry also
239
+ * carries its content as a Buffer.
240
+ */
241
+ async getObjects(bucket, { prefix = '', bodies = false } = {}) {
242
+ this._ensureStore();
243
+ if (!(await this.store.getBucket(bucket))) {
244
+ throw new S3Error('NoSuchBucket', 'The specified bucket does not exist', {
245
+ BucketName: bucket,
246
+ });
247
+ }
248
+ const metas = (await this.store.listObjects(bucket)).filter(
249
+ (meta) => !prefix || meta.key.startsWith(prefix)
250
+ );
251
+ const summaries = metas.map((meta) => this._toSummary(meta));
252
+ if (bodies) {
253
+ for (const summary of summaries) {
254
+ summary.body = await this._readObject(bucket, summary.key);
255
+ }
256
+ }
257
+ return summaries;
258
+ }
259
+
260
+ _readObject(bucket, key) {
261
+ return new Promise((resolve, reject) => {
262
+ const chunks = [];
263
+ this.store
264
+ .createObjectReadStream(bucket, key)
265
+ .on('data', (chunk) => chunks.push(chunk))
266
+ .on('end', () => resolve(Buffer.concat(chunks)))
267
+ .on('error', reject);
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Resolves with the bucket's objects once at least `count` of them exist
273
+ * (optionally under `prefix`). Useful when the code under test uploads in
274
+ * the background. A bucket that does not exist yet simply counts as empty.
275
+ */
276
+ async waitForObjects(bucket, count, { prefix = '', timeout = 10000 } = {}) {
277
+ this._ensureStore();
278
+ const matching = async () => {
279
+ if (!(await this.store.getBucket(bucket))) return null;
280
+ const metas = (await this.store.listObjects(bucket)).filter(
281
+ (meta) => !prefix || meta.key.startsWith(prefix)
282
+ );
283
+ return metas.length >= count
284
+ ? metas.map((meta) => this._toSummary(meta))
285
+ : null;
286
+ };
287
+
288
+ const found = await matching();
289
+ if (found) return found;
290
+
291
+ return new Promise((resolve, reject) => {
292
+ let interval;
293
+ let timer;
294
+ const check = async () => {
295
+ const objects = await matching().catch(() => null);
296
+ if (objects) {
297
+ cleanup();
298
+ resolve(objects);
299
+ }
300
+ };
301
+ const cleanup = () => {
302
+ clearInterval(interval);
303
+ clearTimeout(timer);
304
+ this.removeListener('event', check);
305
+ };
306
+ this.on('event', check);
307
+ // Poll as well, so out-of-band writes (e.g. putObjects) are seen too.
308
+ interval = setInterval(check, 150);
309
+ timer = setTimeout(() => {
310
+ cleanup();
311
+ reject(
312
+ new Error(
313
+ `Timed out after ${timeout}ms waiting for ${count} object(s) in "${bucket}"` +
314
+ (prefix ? ` under "${prefix}"` : '')
315
+ )
316
+ );
317
+ }, timeout);
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Seeds objects directly into the store, bypassing the HTTP API. The
323
+ * bucket is created when missing. Each entry is
324
+ * `{ key, body, contentType?, metadata? }` with a string or Buffer body.
325
+ * No S3 event notifications are emitted for seeded objects.
326
+ */
327
+ async putObjects(bucket, objects) {
328
+ this._ensureStore();
329
+ await this.store.initialize();
330
+ const { validateBucketName } = require('./utils');
331
+ validateBucketName(bucket);
332
+ await this.store.createBucket(bucket);
333
+ const { Readable } = require('stream');
334
+ const results = [];
335
+ for (const object of objects) {
336
+ if (!object || typeof object.key !== 'string') {
337
+ throw new TypeError('putObjects entries need a string `key`');
338
+ }
339
+ const body = Buffer.isBuffer(object.body)
340
+ ? object.body
341
+ : Buffer.from(object.body ?? '');
342
+ const staged = await this.store.stageObject(
343
+ bucket,
344
+ Readable.from([body]),
345
+ ['CRC64NVME']
346
+ );
347
+ const meta = await staged.commit(object.key, {
348
+ contentType: object.contentType || 'binary/octet-stream',
349
+ userMetadata: { ...(object.metadata || {}) },
350
+ tagging: {},
351
+ checksums: staged.digests.checksums,
352
+ checksumAlgorithm: 'CRC64NVME',
353
+ checksumType: 'FULL_OBJECT',
354
+ });
355
+ results.push(this._toSummary(meta));
356
+ }
357
+ return results;
358
+ }
359
+
199
360
  _emitEvent(eventName, bucket, objectMeta) {
200
361
  if (!objectMeta) return;
201
362
  this._sequencer += 1;
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+
3
+ const { S3Error } = require('./error');
4
+
5
+ /**
6
+ * A small subset of S3 bucket policies, used to grant anonymous access per
7
+ * key prefix (e.g. make only `public/*` readable without signing).
8
+ *
9
+ * Supported: Effect Allow/Deny, Principal "*" (or {"AWS": "*"}), Action
10
+ * strings with wildcards (s3:GetObject, s3:ListBucket, s3:PutObject,
11
+ * s3:DeleteObject, s3:*), Resource ARNs with * and ? wildcards.
12
+ *
13
+ * Statements with other principals are stored but never match anonymous
14
+ * requests. Signed requests always have full access, so policies only widen
15
+ * or narrow what anonymous callers may do.
16
+ */
17
+
18
+ function asList(value) {
19
+ if (value === undefined || value === null) return [];
20
+ return Array.isArray(value) ? value : [value];
21
+ }
22
+
23
+ function malformed(message) {
24
+ return new S3Error('MalformedPolicy', message, { statusCode: 400 });
25
+ }
26
+
27
+ const UNSUPPORTED_FIELDS = [
28
+ 'NotAction',
29
+ 'NotResource',
30
+ 'NotPrincipal',
31
+ 'Condition',
32
+ ];
33
+
34
+ function parseBucketPolicy(text) {
35
+ let doc;
36
+ try {
37
+ doc = JSON.parse(text);
38
+ } catch {
39
+ throw malformed('Policies must be valid JSON and the first byte must be {');
40
+ }
41
+ const rawStatements = asList(doc.Statement);
42
+ if (rawStatements.length === 0) {
43
+ throw malformed('Missing required field Statement');
44
+ }
45
+ const statements = rawStatements.map((statement) => {
46
+ if (typeof statement !== 'object' || statement === null) {
47
+ throw malformed('Statement must be an object or an array of objects');
48
+ }
49
+ for (const field of UNSUPPORTED_FIELDS) {
50
+ if (statement[field] !== undefined) {
51
+ throw new S3Error(
52
+ 'NotImplemented',
53
+ `Bucket policy element ${field} is not supported by this server`
54
+ );
55
+ }
56
+ }
57
+ const effect = statement.Effect;
58
+ if (effect !== 'Allow' && effect !== 'Deny') {
59
+ throw malformed('Statement Effect must be Allow or Deny');
60
+ }
61
+ let principals;
62
+ if (statement.Principal === '*') {
63
+ principals = ['*'];
64
+ } else if (statement.Principal && typeof statement.Principal === 'object') {
65
+ principals = asList(statement.Principal.AWS).map(String);
66
+ } else if (statement.Principal === undefined) {
67
+ throw malformed('Missing required field Principal');
68
+ } else {
69
+ principals = [String(statement.Principal)];
70
+ }
71
+ const actions = asList(statement.Action).map((action) =>
72
+ String(action).toLowerCase()
73
+ );
74
+ if (actions.length === 0) {
75
+ throw malformed('Missing required field Action');
76
+ }
77
+ const resources = asList(statement.Resource).map(String);
78
+ if (resources.length === 0) {
79
+ throw malformed('Missing required field Resource');
80
+ }
81
+ return { effect, principals, actions, resources };
82
+ });
83
+ return {
84
+ json: typeof text === 'string' ? text : JSON.stringify(doc),
85
+ statements,
86
+ };
87
+ }
88
+
89
+ function wildcardMatch(pattern, value) {
90
+ const regex = new RegExp(
91
+ '^' +
92
+ String(pattern)
93
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
94
+ .replace(/\*/g, '.*')
95
+ .replace(/\?/g, '.') +
96
+ '$'
97
+ );
98
+ return regex.test(value);
99
+ }
100
+
101
+ /**
102
+ * Evaluates a parsed policy for an anonymous request.
103
+ * Returns 'deny', 'allow' or 'none' (no matching statement).
104
+ */
105
+ function evaluateBucketPolicy(policy, action, resource) {
106
+ const wantedAction = action.toLowerCase();
107
+ let allowed = false;
108
+ for (const statement of policy.statements) {
109
+ if (!statement.principals.includes('*')) continue;
110
+ const actionMatches = statement.actions.some((pattern) =>
111
+ wildcardMatch(pattern, wantedAction)
112
+ );
113
+ const resourceMatches = statement.resources.some((pattern) =>
114
+ wildcardMatch(pattern, resource)
115
+ );
116
+ if (actionMatches && resourceMatches) {
117
+ if (statement.effect === 'Deny') return 'deny';
118
+ allowed = true;
119
+ }
120
+ }
121
+ return allowed ? 'allow' : 'none';
122
+ }
123
+
124
+ module.exports = { parseBucketPolicy, evaluateBucketPolicy };
package/lib/routes.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { S3Error } = require('./models/error');
4
+ const { evaluateBucketPolicy } = require('./models/policy');
4
5
  const { authenticate } = require('./signature');
5
6
  const { generateRequestId, generateId2 } = require('./utils');
6
7
  const { resolveVhostBucket } = require('./middleware/vhost');
@@ -82,9 +83,44 @@ async function handleRequest(server, req, res) {
82
83
  }
83
84
  applyCorsHeaders(ctx, corsConfig);
84
85
 
85
- // Authentication (anonymous requests are allowed, like public buckets)
86
+ // Authentication (anonymous requests are allowed on public buckets)
86
87
  ctx.auth = authenticate({ req, rawPath, query, vhostBucket, options });
87
88
 
89
+ // Bucket access control: anonymous requests are checked against the
90
+ // bucket's canned ACL, then against its bucket policy (which can widen
91
+ // access per key prefix, or deny it). Signed requests have full access.
92
+ if (bucket && bucketMeta && ctx.auth.mode === 'anonymous') {
93
+ const acl = bucketMeta.configs.acl || options.defaultBucketAcl;
94
+ const isRead = req.method === 'GET' || req.method === 'HEAD';
95
+ let allowed =
96
+ acl === 'public-read-write' || (acl === 'public-read' && isRead);
97
+ const policy = bucketMeta.configs.policy;
98
+ if (policy) {
99
+ let action = null;
100
+ let resource;
101
+ if (key) {
102
+ resource = `arn:aws:s3:::${bucket}/${key}`;
103
+ if (isRead) action = 's3:GetObject';
104
+ else if (req.method === 'DELETE') action = 's3:DeleteObject';
105
+ else action = 's3:PutObject';
106
+ } else if (isRead) {
107
+ resource = `arn:aws:s3:::${bucket}`;
108
+ action = 's3:ListBucket';
109
+ } else if (req.method === 'POST') {
110
+ resource = `arn:aws:s3:::${bucket}/*`;
111
+ action = query.has('delete') ? 's3:DeleteObject' : 's3:PutObject';
112
+ }
113
+ if (action) {
114
+ const verdict = evaluateBucketPolicy(policy, action, resource);
115
+ if (verdict === 'allow') allowed = true;
116
+ else if (verdict === 'deny') allowed = false;
117
+ }
118
+ }
119
+ if (!allowed) {
120
+ throw new S3Error('AccessDenied', 'Access Denied');
121
+ }
122
+ }
123
+
88
124
  // Static website hosting
89
125
  if (isWebsiteRequest(ctx, bucketMeta)) {
90
126
  await serveWebsite(ctx, bucketMeta.configs.website);
@@ -93,6 +93,13 @@ function verifyV4Header({ req, rawPath, query, authHeader, options }) {
93
93
  }
94
94
  const [accessKeyId, credDate, region] = credParts;
95
95
  checkAccessKey(accessKeyId, options);
96
+ if (options.enforceRegion && region !== options.region) {
97
+ throw new S3Error(
98
+ 'AuthorizationHeaderMalformed',
99
+ `The authorization header is malformed; the region '${region}' is wrong; expecting '${options.region}'`,
100
+ { Region: options.region }
101
+ );
102
+ }
96
103
 
97
104
  const amzDate = req.headers['x-amz-date'];
98
105
  const dateMs = amzDate
@@ -175,7 +182,20 @@ function verifyV4Query({ req, rawPath, query, options }) {
175
182
  );
176
183
  }
177
184
  const [accessKeyId, credDate, region] = credParts;
185
+ if (credParts[3] !== 's3' || credParts[4] !== 'aws4_request') {
186
+ throw new S3Error(
187
+ 'AuthorizationQueryParametersError',
188
+ 'Error parsing the X-Amz-Credential parameter; the credential scope is incorrect.'
189
+ );
190
+ }
178
191
  checkAccessKey(accessKeyId, options);
192
+ if (options.enforceRegion && region !== options.region) {
193
+ throw new S3Error(
194
+ 'AuthorizationQueryParametersError',
195
+ `Error parsing the X-Amz-Credential parameter; the region '${region}' is wrong; expecting '${options.region}'`,
196
+ { Region: options.region }
197
+ );
198
+ }
179
199
 
180
200
  const dateMs = parseAmzDate(amzDate);
181
201
  if (Number.isNaN(dateMs)) {
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "s3-test-server",
3
- "version": "1.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A modern, lightweight Amazon S3 emulator for local development and testing. Compatible with AWS SDK v2 and v3, Signature V2/V4, path-style and virtual-host-style addressing, streaming (aws-chunked) uploads and flexible checksums.",
5
5
  "license": "MIT",
6
6
  "author": "Israel Alagbe <israelalagbe53@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/israelalagbe/s3-test-server.git"
10
+ },
11
+ "homepage": "https://github.com/israelalagbe/s3-test-server#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/israelalagbe/s3-test-server/issues"
14
+ },
7
15
  "main": "lib/index.js",
8
16
  "types": "lib/index.d.ts",
9
17
  "bin": {