336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||
|
|
import {
|
||
|
|
S3Client,
|
||
|
|
CreateBucketCommand,
|
||
|
|
DeleteBucketCommand,
|
||
|
|
ListBucketsCommand,
|
||
|
|
ListObjectsV2Command,
|
||
|
|
PutObjectCommand,
|
||
|
|
GetObjectCommand,
|
||
|
|
DeleteObjectCommand,
|
||
|
|
CopyObjectCommand,
|
||
|
|
HeadBucketCommand,
|
||
|
|
PutBucketPolicyCommand,
|
||
|
|
GetBucketPolicyCommand,
|
||
|
|
DeleteBucketPolicyCommand,
|
||
|
|
} from '@aws-sdk/client-s3';
|
||
|
|
import * as smarts3 from '../ts/index.js';
|
||
|
|
|
||
|
|
let testSmarts3Instance: smarts3.Smarts3;
|
||
|
|
let authClient: S3Client;
|
||
|
|
|
||
|
|
const TEST_PORT = 3347;
|
||
|
|
const ACCESS_KEY = 'TESTAKID';
|
||
|
|
const SECRET_KEY = 'TESTSECRETKEY123';
|
||
|
|
const BUCKET = 'actions-bucket';
|
||
|
|
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||
|
|
|
||
|
|
async function putPolicy(statements: any[]) {
|
||
|
|
await authClient.send(
|
||
|
|
new PutBucketPolicyCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Policy: JSON.stringify({ Version: '2012-10-17', Statement: statements }),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function clearPolicy() {
|
||
|
|
await authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }));
|
||
|
|
}
|
||
|
|
|
||
|
|
function denyStatement(action: string) {
|
||
|
|
return {
|
||
|
|
Sid: `Deny_${action.replace(':', '_')}`,
|
||
|
|
Effect: 'Deny' as const,
|
||
|
|
Principal: '*',
|
||
|
|
Action: action,
|
||
|
|
Resource: [
|
||
|
|
`arn:aws:s3:::${BUCKET}`,
|
||
|
|
`arn:aws:s3:::${BUCKET}/*`,
|
||
|
|
],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================
|
||
|
|
// Server setup
|
||
|
|
// ============================
|
||
|
|
|
||
|
|
tap.test('setup: start server, create bucket, upload object', 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 }],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
authClient = new S3Client({
|
||
|
|
endpoint: BASE_URL,
|
||
|
|
region: 'us-east-1',
|
||
|
|
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||
|
|
forcePathStyle: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||
|
|
await authClient.send(
|
||
|
|
new PutObjectCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Key: 'obj.txt',
|
||
|
|
Body: 'test content for actions',
|
||
|
|
ContentType: 'text/plain',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================
|
||
|
|
// Per-action deny enforcement
|
||
|
|
// ============================
|
||
|
|
|
||
|
|
tap.test('Deny s3:ListBucket → authenticated ListObjects fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:ListBucket')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new ListObjectsV2Command({ Bucket: BUCKET }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:CreateBucket → authenticated CreateBucket on new bucket fails', async () => {
|
||
|
|
// We need to create a policy on the target bucket, but the target doesn't exist yet.
|
||
|
|
// Instead, we use a different approach: deny on existing bucket and test HeadBucket works
|
||
|
|
// but for CreateBucket, use fetch to target a new bucket name with the deny check.
|
||
|
|
// Actually, CreateBucket has no bucket policy to evaluate against (the bucket doesn't exist yet).
|
||
|
|
// The deny would need to be on the bucket being created.
|
||
|
|
// Since the bucket doesn't exist, there's no policy to load — so CreateBucket can't be denied via policy.
|
||
|
|
// This is expected AWS behavior. Skip this test and note it.
|
||
|
|
|
||
|
|
// Verify CreateBucket still works (no policy can deny it since bucket doesn't exist yet)
|
||
|
|
await authClient.send(new CreateBucketCommand({ Bucket: 'new-test-bucket' }));
|
||
|
|
await authClient.send(new DeleteBucketCommand({ Bucket: 'new-test-bucket' }));
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:DeleteBucket → authenticated DeleteBucket fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:DeleteBucket')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:GetObject → authenticated GetObject fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:GetObject')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:PutObject → authenticated PutObject fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:PutObject')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(
|
||
|
|
new PutObjectCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Key: 'new-obj.txt',
|
||
|
|
Body: 'should fail',
|
||
|
|
})
|
||
|
|
)
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:DeleteObject → authenticated DeleteObject fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:DeleteObject')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:PutObject → authenticated CopyObject fails (maps to s3:PutObject)', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:PutObject')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(
|
||
|
|
new CopyObjectCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Key: 'obj-copy.txt',
|
||
|
|
CopySource: `${BUCKET}/obj.txt`,
|
||
|
|
})
|
||
|
|
)
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:GetBucketPolicy → authenticated GetBucketPolicy fails', async () => {
|
||
|
|
// First put a policy that denies GetBucketPolicy
|
||
|
|
// We need to be careful: put the deny policy, then try to get it
|
||
|
|
await putPolicy([denyStatement('s3:GetBucketPolicy')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
// Clear using direct delete (which isn't denied)
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:PutBucketPolicy → authenticated PutBucketPolicy fails (for second policy)', async () => {
|
||
|
|
// First put a policy that denies PutBucketPolicy
|
||
|
|
await putPolicy([denyStatement('s3:PutBucketPolicy')]);
|
||
|
|
|
||
|
|
// Now try to put another policy — should fail
|
||
|
|
await expect(
|
||
|
|
authClient.send(
|
||
|
|
new PutBucketPolicyCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Policy: JSON.stringify({
|
||
|
|
Version: '2012-10-17',
|
||
|
|
Statement: [
|
||
|
|
{
|
||
|
|
Sid: 'SomeOtherPolicy',
|
||
|
|
Effect: 'Allow',
|
||
|
|
Principal: '*',
|
||
|
|
Action: 's3:GetObject',
|
||
|
|
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
)
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Deny s3:DeleteBucketPolicy → authenticated DeleteBucketPolicy fails', async () => {
|
||
|
|
await putPolicy([denyStatement('s3:DeleteBucketPolicy')]);
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }))
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
// We need another way to clean up — use fetch with auth to bypass? No, the deny is on all principals.
|
||
|
|
// Actually, we can't clear the policy via SDK since delete is denied.
|
||
|
|
// The server still denies it. We need to stop and restart or use a different mechanism.
|
||
|
|
// For test cleanup, just stop the server at end and it will be wiped with cleanSlate on next start.
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Recovery: remove deny policy → authenticated operations resume working', async () => {
|
||
|
|
// The previous test left a deny policy on DeleteBucketPolicy.
|
||
|
|
// But we can work around it by stopping/restarting or if the deny is still in place.
|
||
|
|
// Actually, we denied s3:DeleteBucketPolicy but NOT s3:PutBucketPolicy.
|
||
|
|
// So we can overwrite the policy with an empty-ish one, then delete.
|
||
|
|
await authClient.send(
|
||
|
|
new PutBucketPolicyCommand({
|
||
|
|
Bucket: BUCKET,
|
||
|
|
Policy: JSON.stringify({
|
||
|
|
Version: '2012-10-17',
|
||
|
|
Statement: [
|
||
|
|
{
|
||
|
|
Sid: 'AllowAll',
|
||
|
|
Effect: 'Allow',
|
||
|
|
Principal: '*',
|
||
|
|
Action: 's3:*',
|
||
|
|
Resource: [`arn:aws:s3:::${BUCKET}`, `arn:aws:s3:::${BUCKET}/*`],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
// Now all operations should work again
|
||
|
|
const getResp = await authClient.send(
|
||
|
|
new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' })
|
||
|
|
);
|
||
|
|
expect(getResp.$metadata.httpStatusCode).toEqual(200);
|
||
|
|
|
||
|
|
const listResp = await authClient.send(
|
||
|
|
new ListObjectsV2Command({ Bucket: BUCKET })
|
||
|
|
);
|
||
|
|
expect(listResp.$metadata.httpStatusCode).toEqual(200);
|
||
|
|
|
||
|
|
await clearPolicy();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================
|
||
|
|
// Special cases
|
||
|
|
// ============================
|
||
|
|
|
||
|
|
tap.test('ListAllMyBuckets always requires auth → anonymous fetch to / returns 403', async () => {
|
||
|
|
const resp = await fetch(`${BASE_URL}/`);
|
||
|
|
expect(resp.status).toEqual(403);
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('Auth disabled mode → anonymous full access works', async () => {
|
||
|
|
// Start a second server with auth disabled
|
||
|
|
const noAuthInstance = await smarts3.Smarts3.createAndStart({
|
||
|
|
server: { port: 3348, silent: true, region: 'us-east-1' },
|
||
|
|
storage: { cleanSlate: true },
|
||
|
|
auth: { enabled: false, credentials: [] },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Anonymous operations should all work
|
||
|
|
const listResp = await fetch('http://localhost:3348/');
|
||
|
|
expect(listResp.status).toEqual(200);
|
||
|
|
|
||
|
|
// Create bucket via fetch
|
||
|
|
const createResp = await fetch('http://localhost:3348/anon-bucket', { method: 'PUT' });
|
||
|
|
expect(createResp.status).toEqual(200);
|
||
|
|
|
||
|
|
// Put object
|
||
|
|
const putResp = await fetch('http://localhost:3348/anon-bucket/file.txt', {
|
||
|
|
method: 'PUT',
|
||
|
|
body: 'hello anon',
|
||
|
|
});
|
||
|
|
expect(putResp.status).toEqual(200);
|
||
|
|
|
||
|
|
// Get object
|
||
|
|
const getResp = await fetch('http://localhost:3348/anon-bucket/file.txt');
|
||
|
|
expect(getResp.status).toEqual(200);
|
||
|
|
const text = await getResp.text();
|
||
|
|
expect(text).toEqual('hello anon');
|
||
|
|
|
||
|
|
// Delete object
|
||
|
|
const delObjResp = await fetch('http://localhost:3348/anon-bucket/file.txt', { method: 'DELETE' });
|
||
|
|
expect(delObjResp.status).toEqual(204);
|
||
|
|
|
||
|
|
// Delete bucket
|
||
|
|
const delBucketResp = await fetch('http://localhost:3348/anon-bucket', { method: 'DELETE' });
|
||
|
|
expect(delBucketResp.status).toEqual(204);
|
||
|
|
|
||
|
|
await noAuthInstance.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
// ============================
|
||
|
|
// Teardown
|
||
|
|
// ============================
|
||
|
|
|
||
|
|
tap.test('teardown: clean up and stop server', async () => {
|
||
|
|
// Clean up any remaining objects
|
||
|
|
try {
|
||
|
|
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }));
|
||
|
|
} catch {
|
||
|
|
// May already be deleted
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||
|
|
} catch {
|
||
|
|
// May already be deleted
|
||
|
|
}
|
||
|
|
await testSmarts3Instance.stop();
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|