import { expect, tap } from '@git.zone/tstest/tapbundle'; import { S3Client, CreateBucketCommand, DeleteBucketCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, PutBucketPolicyCommand, DeleteBucketPolicyCommand, } from '@aws-sdk/client-s3'; import { Readable } from 'stream'; import * as smarts3 from '../ts/index.js'; let testSmarts3Instance: smarts3.Smarts3; let authClient: S3Client; const TEST_PORT = 3346; const ACCESS_KEY = 'TESTAKID'; const SECRET_KEY = 'TESTSECRETKEY123'; const BUCKET = 'eval-bucket'; const BASE_URL = `http://localhost:${TEST_PORT}`; async function streamToString(stream: Readable): Promise { 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'))); }); } 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 })); } // ============================ // 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: 'test-obj.txt', Body: 'hello policy eval', ContentType: 'text/plain', }) ); }); // ============================ // Principal matching // ============================ tap.test('Principal: "*" → anonymous fetch GET succeeds', async () => { await putPolicy([ { Sid: 'PrincipalWildcard', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(200); const text = await resp.text(); expect(text).toEqual('hello policy eval'); await clearPolicy(); }); tap.test('Principal: {"AWS": "*"} → anonymous GET fails, authenticated GET succeeds', async () => { await putPolicy([ { Sid: 'AwsWildcard', Effect: 'Allow', Principal: { AWS: '*' }, Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); // Anonymous → no identity → Principal AWS:* doesn't match anonymous → NoOpinion → denied const anonResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(anonResp.status).toEqual(403); // Authenticated → has identity → Principal AWS:* matches → Allow const authResp = await authClient.send( new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }) ); expect(authResp.$metadata.httpStatusCode).toEqual(200); await clearPolicy(); }); tap.test('Principal: {"AWS": "arn:aws:iam::TESTAKID"} → authenticated GET succeeds', async () => { await putPolicy([ { Sid: 'SpecificPrincipal', Effect: 'Allow', Principal: { AWS: `arn:aws:iam::${ACCESS_KEY}` }, Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await authClient.send( new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }) ); expect(resp.$metadata.httpStatusCode).toEqual(200); await clearPolicy(); }); tap.test('Principal: {"AWS": "arn:aws:iam::WRONGKEY"} → authenticated GET still succeeds (default allow)', async () => { await putPolicy([ { Sid: 'WrongPrincipal', Effect: 'Allow', Principal: { AWS: 'arn:aws:iam::WRONGKEY' }, Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); // Principal doesn't match our key → NoOpinion → default allow for authenticated const resp = await authClient.send( new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }) ); expect(resp.$metadata.httpStatusCode).toEqual(200); await clearPolicy(); }); // ============================ // Action matching // ============================ tap.test('Action: "s3:*" → anonymous can GET and PUT (wildcard matches all)', async () => { await putPolicy([ { Sid: 'S3Wildcard', Effect: 'Allow', Principal: '*', Action: 's3:*', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(getResp.status).toEqual(200); const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-wildcard.txt`, { method: 'PUT', body: 'wildcard put', }); expect(putResp.status).toEqual(200); // Clean up the object we created await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-wildcard.txt' })); await clearPolicy(); }); tap.test('Action: "*" → global wildcard matches all actions', async () => { await putPolicy([ { Sid: 'GlobalWildcard', Effect: 'Allow', Principal: '*', Action: '*', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(getResp.status).toEqual(200); const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-global.txt`, { method: 'PUT', body: 'global wildcard', }); expect(putResp.status).toEqual(200); await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-global.txt' })); await clearPolicy(); }); tap.test('Action: "s3:Get*" → anonymous can GET but not PUT (prefix wildcard)', async () => { await putPolicy([ { Sid: 'PrefixWildcard', Effect: 'Allow', Principal: '*', Action: 's3:Get*', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(getResp.status).toEqual(200); const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-prefix.txt`, { method: 'PUT', body: 'should fail', }); expect(putResp.status).toEqual(403); await clearPolicy(); }); tap.test('Action: ["s3:GetObject", "s3:PutObject"] → anonymous can GET and PUT but not DELETE', async () => { await putPolicy([ { Sid: 'MultiAction', Effect: 'Allow', Principal: '*', Action: ['s3:GetObject', 's3:PutObject'], Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(getResp.status).toEqual(200); const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, { method: 'PUT', body: 'multi action', }); expect(putResp.status).toEqual(200); const delResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, { method: 'DELETE', }); expect(delResp.status).toEqual(403); // Clean up await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-multi.txt' })); await clearPolicy(); }); // ============================ // Resource ARN matching // ============================ tap.test('Resource: "arn:aws:s3:::eval-bucket/*" → anonymous GET of object succeeds', async () => { await putPolicy([ { Sid: 'ResourceWildcard', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(200); await clearPolicy(); }); tap.test('Resource: exact key → anonymous GET of that key succeeds, other key fails', async () => { await putPolicy([ { Sid: 'ExactResource', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/test-obj.txt`, }, ]); const goodResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(goodResp.status).toEqual(200); // Other key → resource doesn't match → NoOpinion → denied for anonymous const badResp = await fetch(`${BASE_URL}/${BUCKET}/nonexistent.txt`); expect(badResp.status).toEqual(403); await clearPolicy(); }); tap.test('Resource: wrong bucket ARN → NoOpinion → anonymous GET denied', async () => { await putPolicy([ { Sid: 'WrongBucket', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: 'arn:aws:s3:::other-bucket/*', }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(403); await clearPolicy(); }); tap.test('Resource: "*" → matches everything, anonymous GET succeeds', async () => { await putPolicy([ { Sid: 'StarResource', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: '*', }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(200); await clearPolicy(); }); // ============================ // Deny-over-Allow priority // ============================ tap.test('Allow + Deny same action → anonymous GET denied', async () => { await putPolicy([ { Sid: 'AllowGet', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, { Sid: 'DenyGet', Effect: 'Deny', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(403); await clearPolicy(); }); tap.test('Allow s3:* + Deny s3:DeleteObject → anonymous GET succeeds, DELETE denied', async () => { await putPolicy([ { Sid: 'AllowAll', Effect: 'Allow', Principal: '*', Action: 's3:*', Resource: `arn:aws:s3:::${BUCKET}/*`, }, { Sid: 'DenyDelete', Effect: 'Deny', Principal: '*', Action: 's3:DeleteObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(getResp.status).toEqual(200); const delResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'DELETE' }); expect(delResp.status).toEqual(403); await clearPolicy(); }); tap.test('Statement order does not matter: Deny first, Allow second → still denied', async () => { await putPolicy([ { Sid: 'DenyFirst', Effect: 'Deny', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, { Sid: 'AllowSecond', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(403); await clearPolicy(); }); // ============================ // NoOpinion fallback // ============================ tap.test('NoOpinion: policy allows PutObject only → authenticated GET falls through (default allow)', async () => { await putPolicy([ { Sid: 'AllowPutOnly', Effect: 'Allow', Principal: '*', Action: 's3:PutObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); // Authenticated → NoOpinion → default allow const resp = await authClient.send( new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }) ); expect(resp.$metadata.httpStatusCode).toEqual(200); await clearPolicy(); }); tap.test('NoOpinion: same policy → anonymous GET falls through → default deny (403)', async () => { await putPolicy([ { Sid: 'AllowPutOnly', Effect: 'Allow', Principal: '*', Action: 's3:PutObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); // Anonymous → NoOpinion for GetObject → default deny const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`); expect(resp.status).toEqual(403); await clearPolicy(); }); // ============================ // IAM action mapping // ============================ tap.test('Policy allows s3:GetObject → anonymous HEAD object succeeds (HeadObject maps to s3:GetObject)', async () => { await putPolicy([ { Sid: 'AllowGet', Effect: 'Allow', Principal: '*', Action: 's3:GetObject', Resource: `arn:aws:s3:::${BUCKET}/*`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'HEAD' }); expect(resp.status).toEqual(200); await clearPolicy(); }); tap.test('Policy allows s3:ListBucket → anonymous HEAD bucket succeeds', async () => { await putPolicy([ { Sid: 'AllowList', Effect: 'Allow', Principal: '*', Action: 's3:ListBucket', Resource: `arn:aws:s3:::${BUCKET}`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}`, { method: 'HEAD' }); expect(resp.status).toEqual(200); await clearPolicy(); }); tap.test('Policy allows s3:ListBucket → anonymous GET bucket (list objects) succeeds', async () => { await putPolicy([ { Sid: 'AllowList', Effect: 'Allow', Principal: '*', Action: 's3:ListBucket', Resource: `arn:aws:s3:::${BUCKET}`, }, ]); const resp = await fetch(`${BASE_URL}/${BUCKET}`); expect(resp.status).toEqual(200); const text = await resp.text(); expect(text).toInclude('ListBucketResult'); await clearPolicy(); }); // ============================ // Teardown // ============================ tap.test('teardown: clean up and stop server', async () => { await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })); await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET })); await testSmarts3Instance.stop(); }); export default tap.start();