302 lines
8.0 KiB
TypeScript
302 lines
8.0 KiB
TypeScript
|
|
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();
|