diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index d29ae41ba42..8799414102f 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -822,14 +822,14 @@ tests: sample_name: [storage-gen2] spec: storage-gen2 browser: *minimal_browser_list - - test_name: storage-guest-access - desc: 'Next Storage guest access' + - test_name: integ_next_storage + desc: 'Next Storage Auth' framework: next category: storage - sample_name: [guest-access] + sample_name: [storage-auth] spec: storage-client-server - browser: *minimal_browser_list - + browser: [chrome] # firefox issues with secure cookies in cypress, manual testing works fine + # INAPPMESSAGING - test_name: integ_in_app_messaging desc: 'React InApp Messaging' diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 29e7e86e9c4..2cd6647167c 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,31 +293,31 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.09 kB" + "limit": "17.18 kB" }, { "name": "[Analytics] record (Kinesis)", "path": "./dist/esm/analytics/kinesis/index.mjs", "import": "{ record }", - "limit": "48.56 kB" + "limit": "48.61 kB" }, { "name": "[Analytics] record (Kinesis Firehose)", "path": "./dist/esm/analytics/kinesis-firehose/index.mjs", "import": "{ record }", - "limit": "45.68 kB" + "limit": "45.76 kB" }, { "name": "[Analytics] record (Personalize)", "path": "./dist/esm/analytics/personalize/index.mjs", "import": "{ record }", - "limit": "49.50 kB" + "limit": "49.58 kB" }, { "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.59 kB" + "limit": "15.68 kB" }, { "name": "[Analytics] enable", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.44 kB" + "limit": "12.53 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.39 kB" + "limit": "12.47 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.40 kB" + "limit": "12.49 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -383,31 +383,31 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.27 kB" + "limit": "28.38 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "11.74 kB" + "limit": "11.83 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "11.78 kB" + "limit": "11.86 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.6 kB" + "limit": "12.71 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.63 kB" + "limit": "12.73 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,85 +419,85 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "11.87 kB" + "limit": "11.95 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ getCurrentUser }", - "limit": "7.75 kB" + "limit": "7.85 kB" }, { "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.61 kB" + "limit": "12.71 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect }", - "limit": "21.10 kB" + "limit": "21.15 kB" }, { "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.69 kB" + "limit": "11.77 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.06 kB" + "limit": "30.15 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "21.47 kB" + "limit": "21.58 kB" }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "14.54 kB" + "limit": "14.86 kB" }, { "name": "[Storage] downloadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ downloadData }", - "limit": "15.17 kB" + "limit": "15.45 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getProperties }", - "limit": "14.43 kB" + "limit": "14.70 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "15.64 kB" + "limit": "15.90 kB" }, { "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "15.04 kB" + "limit": "15.30 kB" }, { "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "14.29 kB" + "limit": "14.56 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "19.66 kB" + "limit": "19.90 kB" } ] } diff --git a/packages/core/__tests__/parseAmplifyOutputs.test.ts b/packages/core/__tests__/parseAmplifyOutputs.test.ts index 7d15f7b1f52..bb93d12116c 100644 --- a/packages/core/__tests__/parseAmplifyOutputs.test.ts +++ b/packages/core/__tests__/parseAmplifyOutputs.test.ts @@ -229,6 +229,71 @@ describe('parseAmplifyOutputs tests', () => { }, }); }); + it('should parse storage multi bucket', () => { + const amplifyOutputs: AmplifyOutputs = { + version: '1', + storage: { + aws_region: 'us-west-2', + bucket_name: 'storage-bucket-test', + buckets: [ + { + name: 'default-bucket', + bucket_name: 'storage-bucket-test', + aws_region: 'us-west-2', + }, + { + name: 'bucket-2', + bucket_name: 'storage-bucket-test-2', + aws_region: 'us-west-2', + }, + ], + }, + }; + + const result = parseAmplifyOutputs(amplifyOutputs); + + expect(result).toEqual({ + Storage: { + S3: { + bucket: 'storage-bucket-test', + region: 'us-west-2', + buckets: { + 'bucket-2': { + bucketName: 'storage-bucket-test-2', + region: 'us-west-2', + }, + 'default-bucket': { + bucketName: 'storage-bucket-test', + region: 'us-west-2', + }, + }, + }, + }, + }); + }); + it('should throw for storage multi bucket parsing with same friendly name', () => { + const amplifyOutputs: AmplifyOutputs = { + version: '1', + storage: { + aws_region: 'us-west-2', + bucket_name: 'storage-bucket-test', + buckets: [ + { + name: 'default-bucket', + bucket_name: 'storage-bucket-test', + aws_region: 'us-west-2', + }, + { + name: 'default-bucket', + bucket_name: 'storage-bucket-test-2', + aws_region: 'us-west-2', + }, + ], + }, + }; + + expect(() => parseAmplifyOutputs(amplifyOutputs)).toThrow(); + }); }); describe('analytics tests', () => { diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index ed742189266..3a9265720e1 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -4,7 +4,7 @@ /* This is because JSON schema contains keys with snake_case */ /* eslint-disable camelcase */ -/* Does not like exahaustive checks */ +/* Does not like exhaustive checks */ /* eslint-disable no-case-declarations */ import { @@ -25,11 +25,13 @@ import { AmplifyOutputsDataProperties, AmplifyOutputsGeoProperties, AmplifyOutputsNotificationsProperties, + AmplifyOutputsStorageBucketProperties, AmplifyOutputsStorageProperties, } from './singleton/AmplifyOutputs/types'; import { AnalyticsConfig, AuthConfig, + BucketInfo, GeoConfig, LegacyConfig, ResourcesConfig, @@ -56,12 +58,13 @@ function parseStorage( return undefined; } - const { bucket_name, aws_region } = amplifyOutputsStorageProperties; + const { bucket_name, aws_region, buckets } = amplifyOutputsStorageProperties; return { S3: { bucket: bucket_name, region: aws_region, + buckets: buckets && createBucketInfoMap(buckets), }, }; } @@ -333,3 +336,24 @@ function getMfaStatus( return 'off'; } + +function createBucketInfoMap( + buckets: AmplifyOutputsStorageBucketProperties[], +): Record { + const mappedBuckets: Record = {}; + + buckets.forEach(({ name, bucket_name: bucketName, aws_region: region }) => { + if (name in mappedBuckets) { + throw new Error( + `Duplicate friendly name found: ${name}. Name must be unique.`, + ); + } + + mappedBuckets[name] = { + bucketName, + region, + }; + }); + + return mappedBuckets; +} diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index 9f03f49a7fb..2968955158f 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -43,9 +43,21 @@ export interface AmplifyOutputsAuthProperties { mfa_methods?: string[]; } +export interface AmplifyOutputsStorageBucketProperties { + /** Friendly bucket name provided in Amplify Outputs */ + name: string; + /** Actual S3 bucket name given */ + bucket_name: string; + /** Region for the bucket */ + aws_region: string; +} export interface AmplifyOutputsStorageProperties { + /** Default region for Storage */ aws_region: string; + /** Default bucket for Storage */ bucket_name: string; + /** List of buckets for Storage */ + buckets?: AmplifyOutputsStorageBucketProperties[]; } export interface AmplifyOutputsGeoProperties { diff --git a/packages/core/src/singleton/Storage/types.ts b/packages/core/src/singleton/Storage/types.ts index b21413a797a..5bca120c9b3 100644 --- a/packages/core/src/singleton/Storage/types.ts +++ b/packages/core/src/singleton/Storage/types.ts @@ -6,6 +6,13 @@ import { AtLeastOne } from '../types'; /** @deprecated This may be removed in the next major version. */ export type StorageAccessLevel = 'guest' | 'protected' | 'private'; +/** Information on bucket used to store files/objects */ +export interface BucketInfo { + /** Actual bucket name */ + bucketName: string; + /** Region of the bucket */ + region: string; +} export interface S3ProviderConfig { S3: { bucket?: string; @@ -16,6 +23,8 @@ export interface S3ProviderConfig { * @internal */ dangerouslyConnectToHttpEndpointForTesting?: string; + /** Map of friendly name for bucket to its information */ + buckets?: Record; }; } diff --git a/packages/core/src/singleton/types.ts b/packages/core/src/singleton/types.ts index e2acbeb6611..3419be9a1ec 100644 --- a/packages/core/src/singleton/types.ts +++ b/packages/core/src/singleton/types.ts @@ -19,6 +19,7 @@ import { import { GeoConfig } from './Geo/types'; import { PredictionsConfig } from './Predictions/types'; import { + BucketInfo, LibraryStorageOptions, StorageAccessLevel, StorageConfig, @@ -77,6 +78,7 @@ export { PredictionsConfig, StorageAccessLevel, StorageConfig, + BucketInfo, AnalyticsConfig, CognitoIdentityPoolConfig, GeoConfig, diff --git a/packages/interactions/package.json b/packages/interactions/package.json index 42f03c2d531..af81c31c396 100644 --- a/packages/interactions/package.json +++ b/packages/interactions/package.json @@ -89,19 +89,19 @@ "name": "Interactions (default to Lex v2)", "path": "./dist/esm/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.61 kB" }, { "name": "Interactions (Lex v2)", "path": "./dist/esm/lex-v2/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.61 kB" }, { "name": "Interactions (Lex v1)", "path": "./dist/esm/lex-v1/index.mjs", "import": "{ Interactions }", - "limit": "47.33 kB" + "limit": "47.41 kB" } ] } diff --git a/packages/storage/__tests__/providers/s3/apis/copy.test.ts b/packages/storage/__tests__/providers/s3/apis/copy.test.ts index 55547ae8e7c..7ddd0430dd8 100644 --- a/packages/storage/__tests__/providers/s3/apis/copy.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/copy.test.ts @@ -15,6 +15,7 @@ import { CopyWithPathOutput, } from '../../../../src/providers/s3/types'; import './testUtils'; +import { BucketInfo } from '../../../../src/providers/s3/types/options'; jest.mock('../../../../src/providers/s3/utils/client'); jest.mock('@aws-amplify/core', () => ({ @@ -64,6 +65,7 @@ describe('copy API', () => { S3: { bucket, region, + buckets: { 'bucket-1': { bucketName: bucket, region } }, }, }, }); @@ -198,6 +200,34 @@ describe('copy API', () => { }); }, ); + + it('should override bucket in copyObject call when bucket option is passed', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-2', + region: 'region-2', + }; + await copyWrapper({ + source: { key: 'sourceKey', bucket: 'bucket-1' }, + destination: { + key: 'destinationKey', + bucket: bucketInfo, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + await expect(copyObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketInfo.bucketName, + MetadataDirective: 'COPY', + CopySource: `${bucket}/public/sourceKey`, + Key: 'public/destinationKey', + }, + ); + }); }); describe('With path', () => { @@ -253,6 +283,33 @@ describe('copy API', () => { ); }, ); + it('should override bucket in copyObject call when bucket option is passed', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-2', + region: 'region-2', + }; + await copyWrapper({ + source: { path: 'sourcePath', bucket: 'bucket-1' }, + destination: { + path: 'destinationPath', + bucket: bucketInfo, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + await expect(copyObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketInfo.bucketName, + MetadataDirective: 'COPY', + CopySource: `${bucket}/sourcePath`, + Key: 'destinationPath', + }, + ); + }); }); }); @@ -316,5 +373,40 @@ describe('copy API', () => { expect(error.name).toBe(StorageValidationErrorCode.NoDestinationKey); } }); + + it('should throw an error when only source has bucket option', async () => { + expect.assertions(2); + try { + await copy({ + source: { path: 'source', bucket: 'bucket-1' }, + destination: { + path: 'destination', + }, + }); + } catch (error: any) { + expect(error).toBeInstanceOf(StorageError); + expect(error.name).toBe( + StorageValidationErrorCode.InvalidCopyOperationStorageBucket, + ); + } + }); + + it('should throw an error when only one destination has bucket option', async () => { + expect.assertions(2); + try { + await copy({ + source: { key: 'source' }, + destination: { + key: 'destination', + bucket: 'bucket-1', + }, + }); + } catch (error: any) { + expect(error).toBeInstanceOf(StorageError); + expect(error.name).toBe( + StorageValidationErrorCode.InvalidCopyOperationStorageBucket, + ); + } + }); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts index 57d402b1f24..35b790366bc 100644 --- a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts @@ -24,6 +24,7 @@ import { ItemWithPath, } from '../../../../src/providers/s3/types/outputs'; import './testUtils'; +import { BucketInfo } from '../../../../src/providers/s3/types/options'; jest.mock('../../../../src/providers/s3/utils/client'); jest.mock('../../../../src/providers/s3/utils'); @@ -62,7 +63,7 @@ const mockDownloadResultBase = { const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; const mockCreateDownloadTask = createDownloadTask as jest.Mock; const mockValidateStorageInput = validateStorageOperationInput as jest.Mock; -const mockGetConfig = Amplify.getConfig as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); describe('downloadData with key', () => { beforeAll(() => { @@ -75,6 +76,7 @@ describe('downloadData with key', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -220,6 +222,70 @@ describe('downloadData with key', () => { }), ); }); + + describe('bucket passed in options', () => { + it('should override bucket in getObject call when bucket is object', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const abortController = new AbortController(); + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + + downloadData({ + key: inputKey, + options: { + bucket: bucketInfo, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketInfo.bucketName, + Key: `public/${inputKey}`, + }, + ); + }); + + it('should override bucket in getObject call when bucket is string', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const abortController = new AbortController(); + + downloadData({ + key: inputKey, + options: { + bucket: 'default-bucket', + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: `public/${inputKey}`, + }, + ); + }); + }); }); describe('downloadData with path', () => { @@ -233,6 +299,7 @@ describe('downloadData with path', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -366,4 +433,68 @@ describe('downloadData with path', () => { }), ); }); + + describe('bucket passed in options', () => { + it('should override bucket in getObject call when bucket is object', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const abortController = new AbortController(); + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + + downloadData({ + path: inputPath, + options: { + bucket: bucketInfo, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketInfo.bucketName, + Key: inputPath, + }, + ); + }); + + it('should override bucket in getObject call when bucket is string', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const abortController = new AbortController(); + + downloadData({ + path: inputPath, + options: { + bucket: 'default-bucket', + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts index bb5a5b957a7..0fcd989453e 100644 --- a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts @@ -13,6 +13,7 @@ import { GetPropertiesWithPathOutput, } from '../../../../src/providers/s3/types'; import './testUtils'; +import { BucketInfo } from '../../../../src/providers/s3/types/options'; jest.mock('../../../../src/providers/s3/utils/client'); jest.mock('@aws-amplify/core', () => ({ @@ -28,7 +29,7 @@ jest.mock('@aws-amplify/core', () => ({ })); const mockHeadObject = headObject as jest.MockedFunction; const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = Amplify.getConfig as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); const bucket = 'bucket'; const region = 'region'; @@ -65,14 +66,16 @@ describe('getProperties with key', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); }); + describe('Happy cases: With key', () => { const config = { credentials, - region: 'region', + region, userAgentValue: expect.any(String), }; beforeEach(() => { @@ -152,6 +155,56 @@ describe('getProperties with key', () => { ); }, ); + + describe('bucket passed in options', () => { + it('should override bucket in headObject call when bucket is object', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + const headObjectOptions = { + Bucket: bucketInfo.bucketName, + Key: `public/${inputKey}`, + }; + + await getPropertiesWrapper({ + key: inputKey, + options: { + bucket: bucketInfo, + }, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + + userAgentValue: expect.any(String), + }, + headObjectOptions, + ); + }); + it('should override bucket in headObject call when bucket is string', async () => { + await getPropertiesWrapper({ + key: inputKey, + options: { + bucket: 'default-bucket', + }, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: `public/${inputKey}`, + }, + ); + }); + }); }); describe('Error cases : With key', () => { @@ -201,6 +254,7 @@ describe('Happy cases: With path', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -275,6 +329,55 @@ describe('Happy cases: With path', () => { ); }, ); + describe('bucket passed in options', () => { + it('should override bucket in headObject call when bucket is object', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + const headObjectOptions = { + Bucket: bucketInfo.bucketName, + Key: inputPath, + }; + + await getPropertiesWrapper({ + path: inputPath, + options: { + bucket: bucketInfo, + }, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + + userAgentValue: expect.any(String), + }, + headObjectOptions, + ); + }); + it('should override bucket in headObject call when bucket is string', async () => { + await getPropertiesWrapper({ + path: inputPath, + options: { + bucket: 'default-bucket', + }, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + }); }); describe('Error cases : With path', () => { diff --git a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts index b9658653967..52e65ddd1b0 100644 --- a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts @@ -16,6 +16,7 @@ import { GetUrlWithPathOutput, } from '../../../../src/providers/s3/types'; import './testUtils'; +import { BucketInfo } from '../../../../src/providers/s3/types/options'; jest.mock('../../../../src/providers/s3/utils/client'); jest.mock('@aws-amplify/core', () => ({ @@ -56,6 +57,7 @@ describe('getUrl test with key', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -135,6 +137,52 @@ describe('getUrl test with key', () => { expect({ url, expiresAt }).toEqual(expectedResult); }, ); + describe('bucket passed in options', () => { + it('should override bucket in getPresignedGetObjectUrl call when bucket is object', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + await getUrlWrapper({ + key: 'key', + options: { + bucket: bucketInfo, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + expiration: expect.any(Number), + }, + { + Bucket: bucketInfo.bucketName, + Key: 'public/key', + }, + ); + }); + it('should override bucket in getPresignedGetObjectUrl call when bucket is string', async () => { + await getUrlWrapper({ + key: 'key', + options: { + bucket: 'default-bucket', + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: 'public/key', + }, + ); + }); + }); }); describe('Error cases : With key', () => { afterAll(() => { @@ -175,6 +223,7 @@ describe('getUrl test with path', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -235,6 +284,55 @@ describe('getUrl test with path', () => { }); }, ); + + describe('bucket passed in options', () => { + it('should override bucket in getPresignedGetObjectUrl call when bucket is object', async () => { + const inputPath = 'path/'; + const bucketInfo: BucketInfo = { + bucketName: 'bucket-1', + region: 'region-1', + }; + await getUrlWrapper({ + path: inputPath, + options: { + bucket: bucketInfo, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region: bucketInfo.region, + expiration: expect.any(Number), + }, + { + Bucket: bucketInfo.bucketName, + Key: inputPath, + }, + ); + }); + it('should override bucket in getPresignedGetObjectUrl call when bucket is string', async () => { + const inputPath = 'path/'; + await getUrlWrapper({ + path: inputPath, + options: { + bucket: 'default-bucket', + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: inputPath, + }, + ); + }); + }); }); describe('Happy cases: With path and Content Disposition, Content Type', () => { const config = { diff --git a/packages/storage/__tests__/providers/s3/apis/list.test.ts b/packages/storage/__tests__/providers/s3/apis/list.test.ts index 82bde4a53e2..a13ae54b5a4 100644 --- a/packages/storage/__tests__/providers/s3/apis/list.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/list.test.ts @@ -31,7 +31,7 @@ jest.mock('@aws-amplify/core', () => ({ }, })); const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = Amplify.getConfig as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); const mockListObject = listObjectsV2 as jest.Mock; const inputKey = 'path/itemsKey'; const bucket = 'bucket'; @@ -93,6 +93,7 @@ describe('list API', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -304,6 +305,76 @@ describe('list API', () => { }); }, ); + + describe('bucket passed in options', () => { + it('should override bucket in listObject call when bucket is object', async () => { + mockListObject.mockImplementationOnce(() => { + return { + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: inputKey, + }, + ], + NextContinuationToken: nextToken, + }; + }); + const mockBucketName = 'bucket-1'; + const mockRegion = 'region-1'; + await listPaginatedWrapper({ + prefix: inputKey, + options: { + bucket: { bucketName: mockBucketName, region: mockRegion }, + }, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + userAgentValue: expect.any(String), + }, + { + Bucket: mockBucketName, + MaxKeys: 1000, + Prefix: `public/${inputKey}`, + }, + ); + }); + + it('should override bucket in listObject call when bucket is string', async () => { + mockListObject.mockImplementationOnce(() => { + return { + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: inputKey, + }, + ], + NextContinuationToken: nextToken, + }; + }); + await listPaginatedWrapper({ + prefix: inputKey, + options: { + bucket: 'default-bucket', + }, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + MaxKeys: 1000, + Prefix: `public/${inputKey}`, + }, + ); + }); + }); }); describe('Path: Happy Cases:', () => { @@ -482,6 +553,76 @@ describe('list API', () => { ); }, ); + + describe('bucket passed in options', () => { + it('should override bucket in listObject call when bucket is object', async () => { + mockListObject.mockImplementationOnce(() => { + return { + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: 'path/', + }, + ], + NextContinuationToken: nextToken, + }; + }); + const mockBucketName = 'bucket-1'; + const mockRegion = 'region-1'; + await listPaginatedWrapper({ + path: 'path/', + options: { + bucket: { bucketName: mockBucketName, region: mockRegion }, + }, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + userAgentValue: expect.any(String), + }, + { + Bucket: mockBucketName, + MaxKeys: 1000, + Prefix: 'path/', + }, + ); + }); + + it('should override bucket in listObject call when bucket is string', async () => { + mockListObject.mockImplementationOnce(() => { + return { + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: 'path/', + }, + ], + NextContinuationToken: nextToken, + }; + }); + await listPaginatedWrapper({ + path: 'path/', + options: { + bucket: 'default-bucket', + }, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + MaxKeys: 1000, + Prefix: 'path/', + }, + ); + }); + }); }); describe('Error Cases:', () => { diff --git a/packages/storage/__tests__/providers/s3/apis/remove.test.ts b/packages/storage/__tests__/providers/s3/apis/remove.test.ts index ca1107f0912..eb3407eb610 100644 --- a/packages/storage/__tests__/providers/s3/apis/remove.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/remove.test.ts @@ -29,7 +29,7 @@ jest.mock('@aws-amplify/core', () => ({ })); const mockDeleteObject = deleteObject as jest.Mock; const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = Amplify.getConfig as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); const inputKey = 'key'; const bucket = 'bucket'; const region = 'region'; @@ -56,6 +56,7 @@ describe('remove API', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -115,6 +116,51 @@ describe('remove API', () => { ); }); }); + + describe('bucket passed in options', () => { + it('should override bucket in deleteObject call when bucket is object', async () => { + const mockBucketName = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + key: inputKey, + options: { + bucket: { bucketName: mockBucketName, region: mockRegion }, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + userAgentValue: expect.any(String), + }, + { + Bucket: mockBucketName, + Key: `public/${inputKey}`, + }, + ); + }); + it('should override bucket in deleteObject call when bucket is string', async () => { + await removeWrapper({ + key: inputKey, + options: { + bucket: 'default-bucket', + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: `public/${inputKey}`, + }, + ); + }); + }); }); describe('With Path', () => { const removeWrapper = ( @@ -157,6 +203,51 @@ describe('remove API', () => { ); }); }); + + describe('bucket passed in options', () => { + it('should override bucket in deleteObject call when bucket is object', async () => { + const mockBucketName = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + path: 'path/', + options: { + bucket: { bucketName: mockBucketName, region: mockRegion }, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + userAgentValue: expect.any(String), + }, + { + Bucket: mockBucketName, + Key: 'path/', + }, + ); + }); + it('should override bucket in deleteObject call when bucket is string', async () => { + await removeWrapper({ + path: 'path/', + options: { + bucket: 'default-bucket', + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: 'path/', + }, + ); + }); + }); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts b/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts index c40e5c83de6..8957c9ef764 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts @@ -143,6 +143,7 @@ describe('getMultipartUploadHandlers with key', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -347,6 +348,63 @@ describe('getMultipartUploadHandlers with key', () => { expect(mockUploadPart).toHaveBeenCalledTimes(2); expect(mockCompleteMultipartUpload).not.toHaveBeenCalled(); }); + + describe('bucket passed in options', () => { + const mockData = 'Ü'.repeat(4 * MB); + it('should override bucket in putObject call when bucket as object', async () => { + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers({ + key: 'key', + data: mockData, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + }, + }); + await multipartUploadJob(); + await expect( + mockCreateMultipartUpload, + ).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ + credentials, + region: mockRegion, + abortSignal: expect.any(AbortSignal), + }), + expect.objectContaining({ + Bucket: mockBucket, + Key: 'public/key', + ContentType: defaultContentType, + }), + ); + }); + + it('should override bucket in putObject call when bucket as string', async () => { + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers({ + key: 'key', + data: mockData, + options: { + bucket: 'default-bucket', + }, + }); + await multipartUploadJob(); + await expect( + mockCreateMultipartUpload, + ).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ + credentials, + region, + abortSignal: expect.any(AbortSignal), + }), + expect.objectContaining({ + Bucket: bucket, + Key: 'public/key', + ContentType: defaultContentType, + }), + ); + }); + }); }); describe('upload caching', () => { @@ -665,6 +723,7 @@ describe('getMultipartUploadHandlers with path', () => { S3: { bucket, region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -861,6 +920,68 @@ describe('getMultipartUploadHandlers with path', () => { expect(mockUploadPart).toHaveBeenCalledTimes(2); expect(mockCompleteMultipartUpload).not.toHaveBeenCalled(); }); + + describe('bucket passed in options', () => { + const mockData = 'Ü'.repeat(4 * MB); + it('should override bucket in putObject call when bucket as object', async () => { + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers({ + path: 'path/', + data: mockData, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + }, + }); + await multipartUploadJob(); + await expect( + mockCreateMultipartUpload, + ).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ + credentials, + region: mockRegion, + abortSignal: expect.any(AbortSignal), + }), + expect.objectContaining({ + Bucket: mockBucket, + Key: 'path/', + ContentType: defaultContentType, + }), + ); + expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockCompleteMultipartUpload).toHaveBeenCalledTimes(1); + }); + it('should override bucket in putObject call when bucket as string', async () => { + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers({ + path: 'path/', + data: mockData, + options: { + bucket: 'default-bucket', + }, + }); + await multipartUploadJob(); + await expect( + mockCreateMultipartUpload, + ).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ + credentials, + region, + abortSignal: expect.any(AbortSignal), + }), + expect.objectContaining({ + Bucket: bucket, + Key: 'path/', + ContentType: defaultContentType, + }), + ); + expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockCompleteMultipartUpload).toHaveBeenCalledTimes(1); + }); + }); }); describe('upload caching', () => { diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts b/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts index 335e804c0ea..aa9cf2ff8cd 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts @@ -38,6 +38,8 @@ const credentials: AWSCredentials = { const identityId = 'identityId'; const mockFetchAuthSession = jest.mocked(Amplify.Auth.fetchAuthSession); const mockPutObject = jest.mocked(putObject); +const bucket = 'bucket'; +const region = 'region'; mockFetchAuthSession.mockResolvedValue({ credentials, @@ -46,8 +48,9 @@ mockFetchAuthSession.mockResolvedValue({ jest.mocked(Amplify.getConfig).mockReturnValue({ Storage: { S3: { - bucket: 'bucket', - region: 'region', + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, }, }, }); @@ -102,14 +105,14 @@ describe('putObjectJob with key', () => { await expect(mockPutObject).toBeLastCalledWithConfigAndInput( { credentials, - region: 'region', + region, abortSignal: abortController.signal, onUploadProgress: expect.any(Function), useAccelerateEndpoint: true, userAgentValue: expect.any(String), }, { - Bucket: 'bucket', + Bucket: bucket, Key: `public/${inputKey}`, Body: data, ContentType: mockContentType, @@ -139,6 +142,76 @@ describe('putObjectJob with key', () => { await job(); expect(calculateContentMd5).toHaveBeenCalledWith('data'); }); + + describe('bucket passed in options', () => { + it('should override bucket in putObject call when bucket as object', async () => { + const abortController = new AbortController(); + const data = 'data'; + const bucketName = 'bucket-1'; + const mockRegion = 'region-1'; + + const job = putObjectJob( + { + key: 'key', + data, + options: { + bucket: { + bucketName, + region: mockRegion, + }, + }, + }, + new AbortController().signal, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketName, + Key: 'public/key', + Body: data, + ContentType: 'application/octet-stream', + }, + ); + }); + + it('should override bucket in putObject call when bucket as string', async () => { + const abortController = new AbortController(); + const data = 'data'; + const job = putObjectJob( + { + key: 'key', + data, + options: { + bucket: 'default-bucket', + }, + }, + new AbortController().signal, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: 'public/key', + Body: data, + ContentType: 'application/octet-stream', + }, + ); + }); + }); }); describe('putObjectJob with path', () => { @@ -195,14 +268,14 @@ describe('putObjectJob with path', () => { await expect(mockPutObject).toBeLastCalledWithConfigAndInput( { credentials, - region: 'region', + region, abortSignal: abortController.signal, onUploadProgress: expect.any(Function), useAccelerateEndpoint: true, userAgentValue: expect.any(String), }, { - Bucket: 'bucket', + Bucket: bucket, Key: expectedKey, Body: data, ContentType: mockContentType, @@ -233,4 +306,74 @@ describe('putObjectJob with path', () => { await job(); expect(calculateContentMd5).toHaveBeenCalledWith('data'); }); + + describe('bucket passed in options', () => { + it('should override bucket in putObject call when bucket as object', async () => { + const abortController = new AbortController(); + const data = 'data'; + const bucketName = 'bucket-1'; + const mockRegion = 'region-1'; + + const job = putObjectJob( + { + path: 'path/', + data, + options: { + bucket: { + bucketName, + region: mockRegion, + }, + }, + }, + new AbortController().signal, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: mockRegion, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucketName, + Key: 'path/', + Body: data, + ContentType: 'application/octet-stream', + }, + ); + }); + + it('should override bucket in putObject call when bucket as string', async () => { + const abortController = new AbortController(); + const data = 'data'; + const job = putObjectJob( + { + path: 'path/', + data, + options: { + bucket: 'default-bucket', + }, + }, + new AbortController().signal, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + abortSignal: abortController.signal, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: 'path/', + Body: data, + ContentType: 'application/octet-stream', + }, + ); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts index e26cb63b6c7..022c2f0c1fb 100644 --- a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts @@ -9,6 +9,8 @@ import { StorageValidationErrorCode, validationErrorMap, } from '../../../../../src/errors/types/validation'; +import { BucketInfo } from '../../../../../src/providers/s3/types/options'; +import { StorageError } from '../../../../../src/errors/StorageError'; jest.mock('@aws-amplify/core', () => ({ ConsoleLogger: jest.fn(), @@ -21,7 +23,7 @@ jest.mock('@aws-amplify/core', () => ({ })); jest.mock('../../../../../src/utils/resolvePrefix'); -const mockGetConfig = Amplify.getConfig as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); const mockDefaultResolvePrefix = resolvePrefix as jest.Mock; const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; @@ -49,6 +51,7 @@ describe('resolveS3ConfigAndInput', () => { S3: { bucket, region, + buckets: { 'bucket-1': { bucketName: bucket, region } }, }, }, }); @@ -132,7 +135,7 @@ describe('resolveS3ConfigAndInput', () => { S3: { bucket, region, - dangerouslyConnectToHttpEndpointForTesting: true, + dangerouslyConnectToHttpEndpointForTesting: 'true', }, }, }); @@ -214,4 +217,33 @@ describe('resolveS3ConfigAndInput', () => { }); expect(keyPrefix).toEqual('prefix'); }); + + it('should resolve bucket and region with overrides when bucket API option is passed', async () => { + const bucketInfo: BucketInfo = { + bucketName: 'bucket-2', + region: 'region-2', + }; + + const { + bucket: resolvedBucket, + s3Config: { region: resolvedRegion }, + } = await resolveS3ConfigAndInput(Amplify, { + bucket: bucketInfo, + }); + + expect(mockGetConfig).toHaveBeenCalled(); + expect(resolvedBucket).toEqual(bucketInfo.bucketName); + expect(resolvedRegion).toEqual(bucketInfo.region); + }); + + it('should throw when unable to lookup bucket from the config when bucket API option is passed', async () => { + try { + await resolveS3ConfigAndInput(Amplify, { + bucket: 'error-bucket', + }); + } catch (error: any) { + expect(error).toBeInstanceOf(StorageError); + expect(error.name).toBe(StorageValidationErrorCode.InvalidStorageBucket); + } + }); }); diff --git a/packages/storage/src/errors/types/validation.ts b/packages/storage/src/errors/types/validation.ts index d72b9852162..7fb1bd89765 100644 --- a/packages/storage/src/errors/types/validation.ts +++ b/packages/storage/src/errors/types/validation.ts @@ -13,6 +13,8 @@ export enum StorageValidationErrorCode { NoDestinationPath = 'NoDestinationPath', NoBucket = 'NoBucket', NoRegion = 'NoRegion', + InvalidStorageBucket = 'InvalidStorageBucket', + InvalidCopyOperationStorageBucket = 'InvalidCopyOperationStorageBucket', InvalidStorageOperationPrefixInput = 'InvalidStorageOperationPrefixInput', InvalidStorageOperationInput = 'InvalidStorageOperationInput', InvalidStoragePathInput = 'InvalidStoragePathInput', @@ -70,4 +72,11 @@ export const validationErrorMap: AmplifyErrorMap = { [StorageValidationErrorCode.InvalidStoragePathInput]: { message: 'Input `path` does not allow a leading slash (/).', }, + [StorageValidationErrorCode.InvalidStorageBucket]: { + message: + 'Unable to lookup bucket from provided name in Amplify configuration.', + }, + [StorageValidationErrorCode.InvalidCopyOperationStorageBucket]: { + message: 'Missing bucket option in either source or destination.', + }, }; diff --git a/packages/storage/src/providers/s3/apis/internal/copy.ts b/packages/storage/src/providers/s3/apis/internal/copy.ts index e0c96a1fba4..5035f897017 100644 --- a/packages/storage/src/providers/s3/apis/internal/copy.ts +++ b/packages/storage/src/providers/s3/apis/internal/copy.ts @@ -10,7 +10,7 @@ import { CopyWithPathInput, CopyWithPathOutput, } from '../../types'; -import { ResolvedS3Config } from '../../types/options'; +import { ResolvedS3Config, StorageBucket } from '../../types/options'; import { isInputWithPath, resolveS3ConfigAndInput, @@ -26,6 +26,22 @@ const isCopyInputWithPath = ( input: CopyInput | CopyWithPathInput, ): input is CopyWithPathInput => isInputWithPath(input.source); +const storageBucketAssertion = ( + sourceBucket?: StorageBucket, + destBucket?: StorageBucket, +) => { + /** For multi-bucket, both source and destination bucket needs to be passed in + * or both can be undefined and we fallback to singleton's default value + */ + assertValidationError( + // Both src & dest bucket option is present is acceptable + (sourceBucket !== undefined && destBucket !== undefined) || + // or both are undefined is also acceptable + (!destBucket && !sourceBucket), + StorageValidationErrorCode.InvalidCopyOperationStorageBucket, + ); +}; + export const copy = async ( amplify: AmplifyClassV6, input: CopyInput | CopyWithPathInput, @@ -40,8 +56,18 @@ const copyWithPath = async ( input: CopyWithPathInput, ): Promise => { const { source, destination } = input; - const { s3Config, bucket, identityId } = - await resolveS3ConfigAndInput(amplify); + + storageBucketAssertion(source.bucket, destination.bucket); + + const { bucket: sourceBucket, identityId } = await resolveS3ConfigAndInput( + amplify, + input.source, + ); + + const { s3Config, bucket: destBucket } = await resolveS3ConfigAndInput( + amplify, + input.destination, + ); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. assertValidationError(!!source.path, StorageValidationErrorCode.NoSourcePath); assertValidationError( @@ -58,14 +84,14 @@ const copyWithPath = async ( identityId, ); - const finalCopySource = `${bucket}/${sourcePath}`; + const finalCopySource = `${sourceBucket}/${sourcePath}`; const finalCopyDestination = destinationPath; logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`); await serviceCopy({ source: finalCopySource, destination: finalCopyDestination, - bucket, + bucket: destBucket, s3Config, }); @@ -77,41 +103,39 @@ export const copyWithKey = async ( amplify: AmplifyClassV6, input: CopyInput, ): Promise => { - const { - source: { key: sourceKey }, - destination: { key: destinationKey }, - } = input; + const { source, destination } = input; + + storageBucketAssertion(source.bucket, destination.bucket); - assertValidationError(!!sourceKey, StorageValidationErrorCode.NoSourceKey); + assertValidationError(!!source.key, StorageValidationErrorCode.NoSourceKey); assertValidationError( - !!destinationKey, + !!destination.key, StorageValidationErrorCode.NoDestinationKey, ); + const { bucket: sourceBucket, keyPrefix: sourceKeyPrefix } = + await resolveS3ConfigAndInput(amplify, source); + const { s3Config, - bucket, - keyPrefix: sourceKeyPrefix, - } = await resolveS3ConfigAndInput(amplify, input.source); - const { keyPrefix: destinationKeyPrefix } = await resolveS3ConfigAndInput( - amplify, - input.destination, - ); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. + bucket: destBucket, + keyPrefix: destinationKeyPrefix, + } = await resolveS3ConfigAndInput(amplify, destination); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. // TODO(ashwinkumar6) V6-logger: warn `You may copy files from another user if the source level is "protected", currently it's ${srcLevel}` - const finalCopySource = `${bucket}/${sourceKeyPrefix}${sourceKey}`; - const finalCopyDestination = `${destinationKeyPrefix}${destinationKey}`; + const finalCopySource = `${sourceBucket}/${sourceKeyPrefix}${source.key}`; + const finalCopyDestination = `${destinationKeyPrefix}${destination.key}`; logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`); await serviceCopy({ source: finalCopySource, destination: finalCopyDestination, - bucket, + bucket: destBucket, s3Config, }); return { - key: destinationKey, + key: destination.key, }; }; diff --git a/packages/storage/src/providers/s3/types/index.ts b/packages/storage/src/providers/s3/types/index.ts index 4299687cd8e..4efd666fb33 100644 --- a/packages/storage/src/providers/s3/types/index.ts +++ b/packages/storage/src/providers/s3/types/index.ts @@ -17,6 +17,8 @@ export { DownloadDataOptionsWithKey, CopyDestinationOptionsWithKey, CopySourceOptionsWithKey, + CopyWithPathSourceOptions, + CopyWithPathDestinationOptions, } from './options'; export { UploadDataOutput, diff --git a/packages/storage/src/providers/s3/types/options.ts b/packages/storage/src/providers/s3/types/options.ts index 3378c38d2fd..c9948eabe3b 100644 --- a/packages/storage/src/providers/s3/types/options.ts +++ b/packages/storage/src/providers/s3/types/options.ts @@ -11,12 +11,19 @@ import { StorageSubpathStrategy, } from '../../../types/options'; +export interface BucketInfo { + bucketName: string; + region: string; +} + +export type StorageBucket = string | BucketInfo; interface CommonOptions { /** * Whether to use accelerate endpoint. * @default false */ useAccelerateEndpoint?: boolean; + bucket?: StorageBucket; } /** @@ -193,14 +200,23 @@ export type UploadDataOptionsWithPath = UploadDataOptions; export type CopySourceOptionsWithKey = ReadOptions & { /** @deprecated This may be removed in the next major version. */ key: string; + bucket?: StorageBucket; }; /** @deprecated This may be removed in the next major version. */ export type CopyDestinationOptionsWithKey = WriteOptions & { /** @deprecated This may be removed in the next major version. */ key: string; + bucket?: StorageBucket; }; +export interface CopyWithPathSourceOptions { + bucket?: StorageBucket; +} +export interface CopyWithPathDestinationOptions { + bucket?: StorageBucket; +} + /** * Internal only type for S3 API handlers' config parameter. * diff --git a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts index 065e1ebad7f..1e731ec2a12 100644 --- a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts +++ b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts @@ -6,7 +6,7 @@ import { AmplifyClassV6, StorageAccessLevel } from '@aws-amplify/core'; import { assertValidationError } from '../../../errors/utils/assertValidationError'; import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { resolvePrefix as defaultPrefixResolver } from '../../../utils/resolvePrefix'; -import { ResolvedS3Config } from '../types/options'; +import { BucketInfo, ResolvedS3Config, StorageBucket } from '../types/options'; import { DEFAULT_ACCESS_LEVEL, LOCAL_TESTING_S3_ENDPOINT } from './constants'; @@ -14,6 +14,7 @@ interface S3ApiOptions { accessLevel?: StorageAccessLevel; targetIdentityId?: string; useAccelerateEndpoint?: boolean; + bucket?: StorageBucket; } interface ResolvedS3ConfigAndInput { @@ -62,8 +63,16 @@ export const resolveS3ConfigAndInput = async ( return credentials; }; - const { bucket, region, dangerouslyConnectToHttpEndpointForTesting } = - amplify.getConfig()?.Storage?.S3 ?? {}; + const { + bucket: defaultBucket, + region: defaultRegion, + dangerouslyConnectToHttpEndpointForTesting, + buckets, + } = amplify.getConfig()?.Storage?.S3 ?? {}; + + const { bucket = defaultBucket, region = defaultRegion } = + (apiOptions?.bucket && resolveBucketConfig(apiOptions, buckets)) || {}; + assertValidationError(!!bucket, StorageValidationErrorCode.NoBucket); assertValidationError(!!region, StorageValidationErrorCode.NoRegion); @@ -101,3 +110,25 @@ export const resolveS3ConfigAndInput = async ( isObjectLockEnabled, }; }; + +const resolveBucketConfig = ( + apiOptions: S3ApiOptions, + buckets: Record | undefined, +): { bucket: string; region: string } | undefined => { + if (typeof apiOptions.bucket === 'string') { + const bucketConfig = buckets?.[apiOptions.bucket]; + assertValidationError( + !!bucketConfig, + StorageValidationErrorCode.InvalidStorageBucket, + ); + + return { bucket: bucketConfig.bucketName, region: bucketConfig.region }; + } + + if (typeof apiOptions.bucket === 'object') { + return { + bucket: apiOptions.bucket.bucketName, + region: apiOptions.bucket.region, + }; + } +}; diff --git a/packages/storage/src/types/inputs.ts b/packages/storage/src/types/inputs.ts index 403a2a14332..06c348b4b8f 100644 --- a/packages/storage/src/types/inputs.ts +++ b/packages/storage/src/types/inputs.ts @@ -3,6 +3,8 @@ import { StrictUnion } from '@aws-amplify/core/internals/utils'; +import { StorageBucket } from '../providers/s3/types/options'; + import { StorageListAllOptions, StorageListPaginateOptions, @@ -91,8 +93,8 @@ export interface StorageCopyInputWithKey< } export interface StorageCopyInputWithPath { - source: StorageOperationInputWithPath; - destination: StorageOperationInputWithPath; + source: StorageOperationInputWithPath & { bucket?: StorageBucket }; + destination: StorageOperationInputWithPath & { bucket?: StorageBucket }; } /** diff --git a/yarn.lock b/yarn.lock index a412c68a899..9a096c7ae52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6009,9 +6009,9 @@ aws-jwt-verify@^4.0.1: integrity sha512-kzvi71eD3w/mCpYRUY7cz6DX4bfYihGdI2yV3FYQ2JuZZenqAqDPz0gWj0ew6vlAtdEVBNb7p+Dm2TAIxpVYMA== axios@^1.0.0: - version "1.7.3" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.3.tgz#a1125f2faf702bc8e8f2104ec3a76fab40257d85" - integrity sha512-Ar7ND9pU99eJ9GpoGQKhKf58GpUOgnzuaB7ueNQ5BMi0p+LZ5oaEnfF999fAArcTIBwXTCHAmGcHOZJaWPq9Nw== + version "1.7.4" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" + integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -14727,7 +14727,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14805,7 +14814,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -14819,6 +14828,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -15957,7 +15973,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15984,6 +16000,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"