feat(auth,policy): add AWS SigV4 authentication and S3 bucket policy support
This commit is contained in:
301
test/test.auth.node.ts
Normal file
301
test/test.auth.node.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
ListBucketsCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let authClient: S3Client;
|
||||
let wrongClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3344;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('should start S3 server with auth enabled', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
server: {
|
||||
port: TEST_PORT,
|
||||
silent: true,
|
||||
region: 'us-east-1',
|
||||
},
|
||||
storage: {
|
||||
cleanSlate: true,
|
||||
},
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [
|
||||
{
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Authenticated client with correct credentials
|
||||
authClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Client with wrong credentials
|
||||
wrongClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'WRONGKEY',
|
||||
secretAccessKey: 'WRONGSECRET',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Authenticated CRUD
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: should list buckets', async () => {
|
||||
const response = await authClient.send(new ListBucketsCommand({}));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
expect(Array.isArray(response.Buckets)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should create a bucket', async () => {
|
||||
const response = await authClient.send(new CreateBucketCommand({ Bucket: 'auth-test-bucket' }));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should upload an object', async () => {
|
||||
const response = await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
Body: 'Hello authenticated world!',
|
||||
ContentType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should download the object', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const content = await streamToString(response.Body as Readable);
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Wrong credentials → 403
|
||||
// ============================
|
||||
|
||||
tap.test('wrong credentials: should fail to list buckets', async () => {
|
||||
await expect(wrongClient.send(new ListBucketsCommand({}))).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('wrong credentials: should fail to get object', async () => {
|
||||
await expect(
|
||||
wrongClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Anonymous → 403 (no policy yet)
|
||||
// ============================
|
||||
|
||||
tap.test('anonymous: should fail to list buckets', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('anonymous: should fail to get object (no policy)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Bucket policy: public read
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a public-read bucket policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Statement[0].Sid).toEqual('PublicRead');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should GET object after public-read policy', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const content = await resp.text();
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should still fail to PUT object (policy only allows GET)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/anon-file.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'should fail',
|
||||
});
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deny policy
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a deny policy that blocks authenticated delete', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: ['s3:DeleteObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should be denied delete by policy', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// DELETE bucket policy
|
||||
// ============================
|
||||
|
||||
tap.test('should DELETE the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET policy → 404 after deletion', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Cleanup
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: delete object after policy removed', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: delete the bucket', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketCommand({ Bucket: 'auth-test-bucket' }),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should stop the S3 server', async () => {
|
||||
await testSmarts3Instance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user