229 lines
10 KiB
TypeScript
229 lines
10 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
|
import {
|
|
GetBucketNotificationConfigurationCommand,
|
|
GetBucketLocationCommand,
|
|
GetBucketTaggingCommand,
|
|
GetBucketWebsiteCommand,
|
|
ListBucketsCommand,
|
|
PutBucketNotificationConfigurationCommand,
|
|
PutBucketWebsiteCommand,
|
|
type S3Client,
|
|
} from '@aws-sdk/client-s3'
|
|
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
|
import {AwsStorageAdapter} from './AwsStorageAdapter'
|
|
|
|
function fakeS3(overrides: Partial<{
|
|
buckets: Array<{Name: string; CreationDate?: Date}>
|
|
tagSet: Array<{Key: string; Value: string}>
|
|
taggingError: Error
|
|
onGetBucketTagging: (bucketName: string) => void
|
|
}> = {}): S3Client {
|
|
return {
|
|
send: async (command: unknown) => {
|
|
if (command instanceof ListBucketsCommand) {
|
|
return {
|
|
Buckets: overrides.buckets ?? [
|
|
{Name: 'my-tf-test-bucket', CreationDate: new Date('2026-01-01')},
|
|
],
|
|
}
|
|
}
|
|
if (command instanceof GetBucketTaggingCommand) {
|
|
overrides.onGetBucketTagging?.((command as {input: {Bucket: string}}).input.Bucket)
|
|
if (overrides.taggingError) throw overrides.taggingError
|
|
return {TagSet: overrides.tagSet ?? [{Key: 'Environment', Value: 'dev'}]}
|
|
}
|
|
if (command instanceof GetBucketLocationCommand) return {LocationConstraint: 'us-west-2'}
|
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
|
},
|
|
} as unknown as S3Client
|
|
}
|
|
|
|
describe('AwsStorageAdapter', () => {
|
|
test('list includes bucket tags in metadata', async () => {
|
|
const adapter = new AwsStorageAdapter(fakeS3())
|
|
const result = await adapter.list()
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].metadata.tags).toEqual([{key: 'Environment', value: 'dev'}])
|
|
expect(result[0].metadata.tagsUnavailable).toBe(false)
|
|
})
|
|
|
|
test('list returns empty tags for untagged bucket instead of throwing', async () => {
|
|
const error = new Error('The TagSet does not exist')
|
|
error.name = 'NoSuchTagSet'
|
|
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
|
|
|
|
const result = await adapter.list()
|
|
|
|
expect(result[0].metadata.tags).toEqual([])
|
|
expect(result[0].metadata.tagsUnavailable).toBe(false)
|
|
})
|
|
|
|
test('list does not fail entirely when tag fetch is denied for a bucket', async () => {
|
|
const error = new Error('Access Denied')
|
|
error.name = 'AccessDenied'
|
|
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
|
|
|
|
const result = await adapter.list()
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].metadata.tags).toEqual([])
|
|
})
|
|
|
|
test('list marks tags unavailable (not empty) when tag fetch is denied', async () => {
|
|
const error = new Error('Access Denied')
|
|
error.name = 'AccessDenied'
|
|
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
|
|
|
|
const result = await adapter.list()
|
|
|
|
expect(result[0].metadata.tagsUnavailable).toBe(true)
|
|
})
|
|
|
|
test('list marks tags unavailable on transport/runtime failures too', async () => {
|
|
const error = new Error('socket hang up')
|
|
error.name = 'TimeoutError'
|
|
const adapter = new AwsStorageAdapter(fakeS3({taggingError: error}))
|
|
|
|
const result = await adapter.list()
|
|
|
|
expect(result[0].metadata.tags).toEqual([])
|
|
expect(result[0].metadata.tagsUnavailable).toBe(true)
|
|
})
|
|
|
|
test('search filters buckets before fetching tags, not after', async () => {
|
|
const requestedBuckets: string[] = []
|
|
const adapter = new AwsStorageAdapter(fakeS3({
|
|
buckets: [
|
|
{Name: 'prod-logs'},
|
|
{Name: 'dev-logs'},
|
|
{Name: 'prod-assets'},
|
|
],
|
|
onGetBucketTagging: (bucketName) => requestedBuckets.push(bucketName),
|
|
}))
|
|
|
|
const result = await adapter.list({search: 'prod'})
|
|
|
|
expect(result.map((r) => r.name).sort()).toEqual(['prod-assets', 'prod-logs'])
|
|
expect(requestedBuckets.sort()).toEqual(['prod-assets', 'prod-logs'])
|
|
})
|
|
|
|
test('gets normalized website configuration and maps no website configuration to null', async () => {
|
|
const s3 = {
|
|
send: async (command: unknown) => {
|
|
if (command instanceof GetBucketWebsiteCommand) {
|
|
return {RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}}
|
|
}
|
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
|
},
|
|
} as unknown as S3Client
|
|
const adapter = new AwsStorageAdapter(s3)
|
|
|
|
expect(await adapter.getS3BucketWebsite('website-bucket')).toEqual({mode: 'redirect', hostName: 'www.example.com', protocol: 'https'})
|
|
|
|
const absent = new Error('No website configuration')
|
|
absent.name = 'NoSuchWebsiteConfiguration'
|
|
const missing = new AwsStorageAdapter({send: async () => { throw absent }} as unknown as S3Client)
|
|
expect(await missing.getS3BucketWebsite('website-bucket')).toBeNull()
|
|
})
|
|
|
|
test('puts mutually exclusive S3 website configuration shapes', async () => {
|
|
const sent: unknown[] = []
|
|
const s3 = {send: async (command: unknown) => { sent.push(command); return {} }} as unknown as S3Client
|
|
const adapter = new AwsStorageAdapter(s3)
|
|
|
|
await adapter.putS3BucketWebsite('website-bucket', {mode: 'index', indexDocument: 'index.html', errorDocument: 'error.html'})
|
|
await adapter.putS3BucketWebsite('website-bucket', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'})
|
|
|
|
const [index, redirect] = sent as PutBucketWebsiteCommand[]
|
|
expect(index.input.WebsiteConfiguration).toEqual({IndexDocument: {Suffix: 'index.html'}, ErrorDocument: {Key: 'error.html'}})
|
|
expect(redirect.input.WebsiteConfiguration).toEqual({RedirectAllRequestsTo: {HostName: 'www.example.com', Protocol: 'https'}})
|
|
})
|
|
|
|
test('adds an S3 Lambda trigger without replacing other bucket notifications', async () => {
|
|
const sent: unknown[] = []
|
|
const s3 = {
|
|
send: async (command: unknown) => {
|
|
sent.push(command)
|
|
if (command instanceof GetBucketNotificationConfigurationCommand) {
|
|
return {
|
|
QueueConfigurations: [{Id: 'queue-trigger', QueueArn: 'arn:aws:sqs:us-east-1:000000000000:jobs', Events: ['s3:ObjectCreated:*']}],
|
|
EventBridgeConfiguration: {},
|
|
}
|
|
}
|
|
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
|
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
|
},
|
|
} as unknown as S3Client
|
|
const lambda = fakeLambda()
|
|
const adapter = new AwsStorageAdapter(s3, lambda.client, '000000000000')
|
|
|
|
await adapter.createS3LambdaTrigger('uploads', {
|
|
functionName: 'thumbnailer',
|
|
events: ['s3:ObjectCreated:*'],
|
|
prefix: 'images/',
|
|
suffix: '.jpg',
|
|
})
|
|
|
|
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
|
|
expect(put.input.NotificationConfiguration?.QueueConfigurations).toHaveLength(1)
|
|
expect(put.input.NotificationConfiguration?.EventBridgeConfiguration).toEqual({})
|
|
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([{
|
|
Id: 'floci-ui-s3-thumbnailer-s3-ObjectCreated---images---jpg',
|
|
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
|
|
Events: ['s3:ObjectCreated:*'],
|
|
Filter: {Key: {FilterRules: [{Name: 'prefix', Value: 'images/'}, {Name: 'suffix', Value: '.jpg'}]}},
|
|
}])
|
|
const permission = lambda.sent.find((command) => command instanceof AddPermissionCommand) as AddPermissionCommand
|
|
expect(permission.input).toMatchObject({
|
|
FunctionName: 'thumbnailer',
|
|
Principal: 's3.amazonaws.com',
|
|
SourceArn: 'arn:aws:s3:::uploads',
|
|
SourceAccount: '000000000000',
|
|
})
|
|
})
|
|
|
|
test('removes the S3 permission only after the final bucket trigger for a Lambda is deleted', async () => {
|
|
const sent: unknown[] = []
|
|
const s3 = {
|
|
send: async (command: unknown) => {
|
|
sent.push(command)
|
|
if (command instanceof GetBucketNotificationConfigurationCommand) {
|
|
return {LambdaFunctionConfigurations: [{
|
|
Id: 'trigger-a',
|
|
LambdaFunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer',
|
|
Events: ['s3:ObjectCreated:*'],
|
|
}]}
|
|
}
|
|
if (command instanceof PutBucketNotificationConfigurationCommand) return {}
|
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
|
},
|
|
} as unknown as S3Client
|
|
const lambda = fakeLambda()
|
|
const adapter = new AwsStorageAdapter(s3, lambda.client)
|
|
|
|
await adapter.deleteS3LambdaTrigger('uploads', 'trigger-a')
|
|
|
|
const put = sent.find((command) => command instanceof PutBucketNotificationConfigurationCommand) as PutBucketNotificationConfigurationCommand
|
|
expect(put.input.NotificationConfiguration?.LambdaFunctionConfigurations).toEqual([])
|
|
expect(lambda.sent.some((command) => command instanceof RemovePermissionCommand)).toBe(true)
|
|
})
|
|
})
|
|
|
|
function fakeLambda(): {client: LambdaClient; sent: unknown[]} {
|
|
const sent: unknown[] = []
|
|
return {
|
|
sent,
|
|
client: {
|
|
send: async (command: unknown) => {
|
|
sent.push(command)
|
|
if (command instanceof GetFunctionCommand) {
|
|
return {Configuration: {FunctionArn: 'arn:aws:lambda:us-east-1:000000000000:function:thumbnailer'}}
|
|
}
|
|
if (command instanceof AddPermissionCommand || command instanceof RemovePermissionCommand) return {}
|
|
throw new Error(`Unexpected command: ${command?.constructor?.name}`)
|
|
},
|
|
} as unknown as LambdaClient,
|
|
}
|
|
}
|