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, } }