From ca2f3709dcad00eaee6e9c6d3d76ab8eee074b24 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 1 Nov 2024 15:42:45 -0700 Subject: [PATCH 1/2] refactor email mfa updates --- .../cognito/testUtils/generatePermutations.ts | 27 +++++ .../cognito/updateMFAPreference.test.ts | 42 ++----- .../cognito/utils/handleMFAChallenge.test.ts | 78 ++++++++++++ packages/auth/src/common/AuthErrorStrings.ts | 2 +- .../cognito/utils/handleMFAChallenge.ts | 77 ++++++++++++ .../providers/cognito/utils/signInHelpers.ts | 111 ++++-------------- .../auth/src/providers/cognito/utils/types.ts | 12 ++ 7 files changed, 226 insertions(+), 123 deletions(-) create mode 100644 packages/auth/__tests__/providers/cognito/testUtils/generatePermutations.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/handleMFAChallenge.test.ts create mode 100644 packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts diff --git a/packages/auth/__tests__/providers/cognito/testUtils/generatePermutations.ts b/packages/auth/__tests__/providers/cognito/testUtils/generatePermutations.ts new file mode 100644 index 00000000000..4e3813318c0 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/testUtils/generatePermutations.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generates all possible permutations of an object + * in which each key has the possible values + * @param keys an array of keys for the object + * @param values an array of possible values for each object[key] + * @returns an array of objects + */ +export const generatePermutations = ( + keys: string[], + values: T[], +): Record[] => { + if (!keys.length) return [{}]; + + const [curr, ...rest] = keys; + const permutations: Record[] = []; + + for (const value of values) { + for (const perm of generatePermutations(rest, values)) { + permutations.push({ ...perm, [curr]: value }); + } + } + + return permutations; +}; diff --git a/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts b/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts index 0d597b5ec9b..14b89b164cf 100644 --- a/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts +++ b/packages/auth/__tests__/providers/cognito/updateMFAPreference.test.ts @@ -17,6 +17,7 @@ import { createCognitoUserPoolEndpointResolver } from '../../../src/providers/co import { getMockError, mockAccessToken } from './testUtils/data'; import { setUpGetConfig } from './testUtils/setUpGetConfig'; +import { generatePermutations } from './testUtils/generatePermutations'; type MfaPreferenceValue = MFAPreference | undefined; @@ -33,39 +34,16 @@ jest.mock( ); jest.mock('../../../src/providers/cognito/factories'); -// generates all preference permutations -const generateUpdateMFAPreferenceOptions = () => { - const mfaPreferenceTypes: MfaPreferenceValue[] = [ - 'PREFERRED', - 'NOT_PREFERRED', - 'ENABLED', - 'DISABLED', - undefined, - ]; - const mfaKeys: (keyof UpdateMFAPreferenceInput)[] = ['email', 'sms', 'totp']; +const mfaPreferenceTypes: MfaPreferenceValue[] = [ + 'PREFERRED', + 'NOT_PREFERRED', + 'ENABLED', + 'DISABLED', + undefined, +]; +const mfaKeys: (keyof UpdateMFAPreferenceInput)[] = ['email', 'sms', 'totp']; - const generatePermutations = ( - keys: string[], - values: T[], - ): Record[] => { - if (!keys.length) return [{}]; - - const [curr, ...rest] = keys; - const permutations: Record[] = []; - - for (const value of values) { - for (const perm of generatePermutations(rest, values)) { - permutations.push({ ...perm, [curr]: value }); - } - } - - return permutations; - }; - - return generatePermutations(mfaKeys, mfaPreferenceTypes); -}; - -const mfaChoices = generateUpdateMFAPreferenceOptions(); +const mfaChoices = generatePermutations(mfaKeys, mfaPreferenceTypes); describe('updateMFAPreference', () => { // assert mocks diff --git a/packages/auth/__tests__/providers/cognito/utils/handleMFAChallenge.test.ts b/packages/auth/__tests__/providers/cognito/utils/handleMFAChallenge.test.ts new file mode 100644 index 00000000000..835ffa12c14 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/handleMFAChallenge.test.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { setUpGetConfig } from '../testUtils/setUpGetConfig'; +import { handleMFAChallenge } from '../../../../src/providers/cognito/utils/handleMFAChallenge'; +import { ChallengeName } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); + +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); + +const authConfig = { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', +}; + +const handleMFAChallengeTestCases: [ + Extract, + 'EMAIL_OTP_CODE' | 'SMS_MFA_CODE' | 'SOFTWARE_TOKEN_MFA_CODE', +][] = [ + ['EMAIL_OTP', 'EMAIL_OTP_CODE'], + ['SMS_MFA', 'SMS_MFA_CODE'], + ['SOFTWARE_TOKEN_MFA', 'SOFTWARE_TOKEN_MFA_CODE'], +]; + +describe('handleMFAChallenge', () => { + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateRespondToAuthChallengeClient = jest.mocked( + createRespondToAuthChallengeClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockCreateRespondToAuthChallengeClient.mockReturnValue( + mockRespondToAuthChallenge, + ); + }); + + it.each(handleMFAChallengeTestCases)( + 'should construct the appropriate challenge response based on challenge name', + async (challengeName, challengeResponseKey) => { + const username = 'james'; + const challengeResponse = '123456'; + await handleMFAChallenge({ + challengeName, + username, + challengeResponse: '123456', + config: authConfig, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + region: expect.any(String), + userAgentValue: expect.any(String), + }), + expect.objectContaining({ + ChallengeName: challengeName, + ChallengeResponses: expect.objectContaining({ + USERNAME: username, + [challengeResponseKey]: challengeResponse, + }), + Session: undefined, + ClientMetadata: undefined, + ClientId: authConfig.userPoolClientId, + UserContextData: undefined, + }), + ); + }, + ); +}); diff --git a/packages/auth/src/common/AuthErrorStrings.ts b/packages/auth/src/common/AuthErrorStrings.ts index ad4b8c261ef..98570457233 100644 --- a/packages/auth/src/common/AuthErrorStrings.ts +++ b/packages/auth/src/common/AuthErrorStrings.ts @@ -50,7 +50,7 @@ export const validationErrorMap: AmplifyErrorMap = { message: 'Incorrect MFA method was chosen. It should be either SMS, TOTP, or EMAIL', recoverySuggestion: - 'Try to pass SMS, TOTP, or EMAIL as the challengeResponse', + 'Confirm SMS, TOTP, or EMAIL is passed as the challengeResponse', }, [AuthValidationErrorCode.EmptyVerifyTOTPSetupCode]: { message: 'code is required to verifyTotpSetup', diff --git a/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts b/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts new file mode 100644 index 00000000000..3df0c9a7e1f --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + ChallengeName, + RespondToAuthChallengeCommandInput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { createCognitoUserPoolEndpointResolver } from '../factories'; + +import { getUserContextData } from './userContextData'; +import { HandleAuthChallengeRequest } from './types'; + +export async function handleMFAChallenge({ + challengeName, + challengeResponse, + clientMetadata, + session, + username, + config, +}: HandleAuthChallengeRequest & { + challengeName: Extract< + ChallengeName, + 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' + >; +}) { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + + const challengeResponses: Record = { + USERNAME: username, + }; + + if (challengeName === 'EMAIL_OTP') { + challengeResponses.EMAIL_OTP_CODE = challengeResponse; + } + + if (challengeName === 'SMS_MFA') { + challengeResponses.SMS_MFA_CODE = challengeResponse; + } + + if (challengeName === 'SOFTWARE_TOKEN_MFA') { + challengeResponses.SOFTWARE_TOKEN_MFA_CODE = challengeResponse; + } + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const jsonReq: RespondToAuthChallengeCommandInput = { + ChallengeName: challengeName, + ChallengeResponses: challengeResponses, + Session: session, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData: userContextData, + }; + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + jsonReq, + ); +} diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index d3bce2aa6f2..984299351d9 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -52,7 +52,7 @@ import { import { getRegionFromUserPoolId } from '../../../foundation/parsers'; import { signInStore } from './signInStore'; -import { assertDeviceMetadata } from './types'; +import { HandleAuthChallengeRequest, assertDeviceMetadata } from './types'; import { getAuthenticationHelper, getBytesFromHex, @@ -62,19 +62,10 @@ import { import { BigInteger } from './srp/BigInteger'; import { AuthenticationHelper } from './srp/AuthenticationHelper'; import { getUserContextData } from './userContextData'; +import { handleMFAChallenge } from './handleMFAChallenge'; const USER_ATTRIBUTES = 'userAttributes.'; -interface HandleAuthChallengeRequest { - challengeResponse: string; - username: string; - clientMetadata?: ClientMetadata; - session?: string; - deviceName?: string; - requiredAttributes?: AuthUserAttributes; - config: CognitoUserPoolConfig; -} - interface HandleDeviceSRPInput { username: string; config: CognitoUserPoolConfig; @@ -262,7 +253,7 @@ export async function handleMFASetupChallenge({ name: AuthErrorCodes.SignInException, message: `Cannot proceed with MFA setup using challengeResponse: ${challengeResponse}`, recoverySuggestion: - 'Try passing "EMAIL", "TOTP", a valid email, or OTP code as the challengeResponse.', + 'Confirm "EMAIL", "TOTP", a valid email, or OTP code is passed as the challengeResponse.', }); } @@ -274,19 +265,22 @@ export async function handleSelectMFATypeChallenge({ config, }: HandleAuthChallengeRequest): Promise { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + + const challengeResponses: Record = { + USERNAME: username, + }; + + const selectedMfaType = mapMfaType(challengeResponse); + assertValidationError( - challengeResponse === 'TOTP' || - challengeResponse === 'SMS' || - challengeResponse === 'EMAIL', + !!selectedMfaType && + ['SMS_MFA', 'SOFTWARE_TOKEN_MFA', 'EMAIL_OTP'].includes(selectedMfaType), AuthValidationErrorCode.IncorrectMFAMethod, ); - const challengeResponses = { - USERNAME: username, - ANSWER: mapMfaType(challengeResponse), - }; + challengeResponses.ANSWER = selectedMfaType; - const UserContextData = getUserContextData({ + const userContextData = getUserContextData({ username, userPoolId, userPoolClientId, @@ -298,7 +292,7 @@ export async function handleSelectMFATypeChallenge({ Session: session, ClientMetadata: clientMetadata, ClientId: userPoolClientId, - UserContextData, + UserContextData: userContextData, }; const respondToAuthChallenge = createRespondToAuthChallengeClient({ @@ -1089,12 +1083,10 @@ export async function handleChallengeName( }); } -export function mapMfaType(mfa: string): CognitoMFAType { - let mfaType: CognitoMFAType = 'SMS_MFA'; - if (mfa === 'TOTP') mfaType = 'SOFTWARE_TOKEN_MFA'; - if (mfa === 'EMAIL') mfaType = 'EMAIL_OTP'; - - return mfaType; +export function mapMfaType(mfa: string): CognitoMFAType | undefined { + if (mfa === 'SMS') return 'SMS_MFA'; + if (mfa === 'TOTP') return 'SOFTWARE_TOKEN_MFA'; + if (mfa === 'EMAIL') return 'EMAIL_OTP'; } export function getMFAType(type?: string): AuthMFAType | undefined { @@ -1116,8 +1108,8 @@ export function parseMFATypes(mfa?: string): CognitoMFAType[] { } export function getAllowedMfaSetupTypes(availableMfaSetupTypes: AuthMFAType[]) { - return availableMfaSetupTypes.filter( - authMfaType => authMfaType === 'EMAIL' || authMfaType === 'TOTP', + return availableMfaSetupTypes.filter(authMfaType => + ['EMAIL', 'TOTP'].includes(authMfaType), ); } @@ -1249,64 +1241,3 @@ export function getActiveSignInUsername(username: string): string { return state.username ?? username; } - -export async function handleMFAChallenge({ - challengeName, - challengeResponse, - clientMetadata, - session, - username, - config, -}: HandleAuthChallengeRequest & { - challengeName: Extract< - ChallengeName, - 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' - >; -}) { - const { userPoolId, userPoolClientId, userPoolEndpoint } = config; - - const challengeResponses: Record = { - USERNAME: username, - }; - - if (challengeName === 'EMAIL_OTP') { - challengeResponses.EMAIL_OTP_CODE = challengeResponse; - } - - if (challengeName === 'SMS_MFA') { - challengeResponses.SMS_MFA_CODE = challengeResponse; - } - - if (challengeName === 'SOFTWARE_TOKEN_MFA') { - challengeResponses.SOFTWARE_TOKEN_MFA_CODE = challengeResponse; - } - - const userContextData = getUserContextData({ - username, - userPoolId, - userPoolClientId, - }); - - const jsonReq: RespondToAuthChallengeCommandInput = { - ChallengeName: challengeName, - ChallengeResponses: challengeResponses, - Session: session, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - UserContextData: userContextData, - }; - - const respondToAuthChallenge = createRespondToAuthChallengeClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - - return respondToAuthChallenge( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), - }, - jsonReq, - ); -} diff --git a/packages/auth/src/providers/cognito/utils/types.ts b/packages/auth/src/providers/cognito/utils/types.ts index e6038366885..e06a5da0270 100644 --- a/packages/auth/src/providers/cognito/utils/types.ts +++ b/packages/auth/src/providers/cognito/utils/types.ts @@ -15,6 +15,8 @@ import { TOKEN_REFRESH_EXCEPTION, USER_UNAUTHENTICATED_EXCEPTION, } from '../../../errors/constants'; +import { AuthUserAttributes } from '../../../types'; +import { ClientMetadata } from '../types'; export function isTypeUserPoolConfig( authConfig?: AuthConfig, @@ -140,3 +142,13 @@ function isAuthenticatedWithImplicitOauthFlow( ) { return isAuthenticated(tokens) && !tokens?.refreshToken; } + +export interface HandleAuthChallengeRequest { + challengeResponse: string; + username: string; + clientMetadata?: ClientMetadata; + session?: string; + deviceName?: string; + requiredAttributes?: AuthUserAttributes; + config: CognitoUserPoolConfig; +} From 0980707d093e8247ec1076f9ea69596cb40de93f Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 29 Nov 2024 10:07:45 -0800 Subject: [PATCH 2/2] Merge branch 'main' into refactor/email-mfa --- .github/CODEOWNERS | 2 + .github/integ-config/integ-all.yml | 80 +- .github/workflows/callable-e2e-test.yml | 4 + docs/api/assets/navigation.js | 2 +- docs/api/assets/search.js | 2 +- ...atastore._Reference_Types_.AuthAction.html | 8 +- ...store._Reference_Types_.StorageAction.html | 4 +- ...lify.auth.associateWebAuthnCredential.html | 20 + ...amplify.auth.deleteWebAuthnCredential.html | 13 + ..._amplify.auth.listWebAuthnCredentials.html | 13 + ...ference_Types_.AuthIdentityPoolConfig.html | 4 +- ...xtjs.api._Reference_Types_.BucketInfo.html | 6 +- ...eference_Types_.CognitoUserPoolConfig.html | 5 +- ...e_Types_.AmplifyOutputsAuthProperties.html | 5 +- ...AmplifyOutputsStorageBucketProperties.html | 6 +- ...e_Types_.AmplifyOutputsAuthProperties.html | 5 +- ...AmplifyOutputsStorageBucketProperties.html | 6 +- ...ference_Types_.AuthIdentityPoolConfig.html | 4 +- ...eference_Types_.CognitoUserPoolConfig.html | 5 +- ...s_amplify.auth.AuthWebAuthnCredential.html | 9 + ...fy.auth.DeleteWebAuthnCredentialInput.html | 3 + ...ify.auth.ListWebAuthnCredentialsInput.html | 5 + ...fy.auth.ListWebAuthnCredentialsOutput.html | 5 + ...uth._Reference_Types_.AuthSignUpInput.html | 8 +- ...ence_Types_.ConfirmSignInWithPassword.html | 3 + ...ontinueSignInWithFirstFactorSelection.html | 4 + ...fy.index._Reference_Types_.BucketInfo.html | 6 +- ....storage._Reference_Types_.BucketInfo.html | 5 +- ...orage._Reference_Types_.CommonOptions.html | 8 +- ...Types_.CopyWithPathDestinationOptions.html | 4 + ...ence_Types_.CopyWithPathSourceOptions.html | 6 + ...rence_Types_.StorageCopyInputWithPath.html | 4 +- ..._adapter_nextjs.api._Reference_Types_.html | 1 + .../modules/_aws_amplify_adapter_nextjs.html | 4 +- ...dapter_nextjs.index._Reference_Types_.html | 1 + ...aws_amplify_datastore_storage_adapter.html | 4 +- docs/api/modules/_aws_amplify_geo.html | 4 +- .../modules/_aws_amplify_interactions.html | 4 +- .../api/modules/_aws_amplify_predictions.html | 4 +- docs/api/modules/_aws_amplify_pubsub.html | 4 +- ...mplify.adapter_core._Reference_Types_.html | 2 + .../aws_amplify.auth._Reference_Types_.html | 4 + docs/api/modules/aws_amplify.auth.html | 9 +- docs/api/modules/aws_amplify.html | 4 +- ...aws_amplify.storage._Reference_Types_.html | 1 + docs/api/modules/aws_amplify.storage.html | 1 + docs/api/modules/aws_amplify.storage_s3.html | 3 +- ...lify.storage_server._Reference_Types_.html | 4 +- ...xtjs.api._Reference_Types_.OAuthScope.html | 2 +- ..._Reference_Types_.UserGroupPrecedence.html | 2 + ..._Reference_Types_.UserGroupPrecedence.html | 2 + ...ter_core._Reference_Types_.OAuthScope.html | 2 +- ...eference_Types_.UserGroupPrecedence-1.html | 2 + ..._Reference_Types_.UserGroupPrecedence.html | 2 + ...auth._Reference_Types_.AuthFactorType.html | 3 + ...auth._Reference_Types_.AuthFlowType-1.html | 2 +- ...y.auth._Reference_Types_.AuthFlowType.html | 4 +- ...._Reference_Types_.AuthNextSignInStep.html | 2 +- ....auth._Reference_Types_.ChallengeName.html | 2 + ....auth._Reference_Types_.SignInOptions.html | 4 +- ..._Types_.CopyDestinationWithKeyOptions.html | 2 +- ...rence_Types_.CopySourceWithKeyOptions.html | 4 +- ...ce_Types_.UploadDataChecksumAlgorithm.html | 2 + ...e._Reference_Types_.UploadDataOptions.html | 9 +- ...aws_amplify.storage.DEFAULT_PART_SIZE.html | 3 + eslint.config.mjs | 7 + package.json | 6 +- packages/adapter-nextjs/CHANGELOG.md | 8 + packages/adapter-nextjs/package.json | 4 +- packages/analytics/CHANGELOG.md | 8 + packages/analytics/package.json | 4 +- packages/api-graphql/CHANGELOG.md | 8 + packages/api-graphql/package.json | 6 +- packages/api-rest/CHANGELOG.md | 8 + packages/api-rest/package.json | 4 +- packages/api/CHANGELOG.md | 8 + packages/api/package.json | 6 +- packages/auth/CHANGELOG.md | 12 + .../apis/associateWebAuthnCredential.test.ts | 197 +++ .../flows/shared/handlePasswordSRP.test.ts | 390 ++++++ .../userAuth/handleSelectChallenge.test.ts | 175 +++ .../handleSelectChallengeWithPassword.test.ts | 191 +++ ...ndleSelectChallengeWithPasswordSRP.test.ts | 262 ++++ .../flows/userAuth/handleUserAuthFlow.test.ts | 212 ++++ .../__tests__/client/utils/passkey.test.ts | 49 + .../apis/deleteWebAuthnCredential.test.ts | 62 + .../apis/listWebAuthnCredentials.test.ts | 150 +++ .../foundation/convert/base64url.test.ts | 32 + .../createSignUpClient.test.ts | 53 + .../cognitoIdentityProvider/index.test.ts | 4 +- .../cognitoIdentityProvider/testUtils/data.ts | 18 + packages/auth/__tests__/mockData.ts | 237 ++++ .../providers/cognito/autoSignIn.test.ts | 279 ++++- .../cognito/confirmSignInErrorCases.test.ts | 4 +- .../cognito/signInStateManagement.test.ts | 2 +- .../providers/cognito/signInWithSRP.test.ts | 4 +- .../cognito/signInWithUserAuth.test.ts | 189 +++ .../providers/cognito/signUp.test.ts | 29 +- .../signInHelpers/getSignInResult.test.ts | 92 ++ .../handleWebAuthnSignInResult.test.ts | 174 +++ .../autoSignInUserConfirmed.test.ts | 65 + packages/auth/package.json | 4 +- .../apis/associateWebAuthnCredential.ts | 94 ++ .../client/apis/deleteWebAuthnCredential.ts | 24 + packages/auth/src/client/apis/index.ts | 6 + .../client/apis/listWebAuthnCredentials.ts | 28 + .../client/flows/shared/handlePasswordSRP.ts | 123 ++ .../flows/userAuth/handleSelectChallenge.ts | 62 + .../handleSelectChallengeWithPassword.ts | 74 ++ .../handleSelectChallengeWithPasswordSRP.ts | 105 ++ .../flows/userAuth/handleUserAuthFlow.ts | 125 ++ .../userAuth/handleWebAuthnSignInResult.ts | 130 ++ .../src/client/utils}/index.ts | 2 +- .../auth/src/client/utils/passkey/errors.ts | 214 ++++ .../passkey/getIsPasskeySupported.native.ts | 8 + .../utils/passkey/getIsPasskeySupported.ts | 18 + .../client/utils/passkey/getPasskey.native.ts | 8 + .../src/client/utils/passkey/getPasskey.ts | 40 + .../auth/src/client/utils/passkey/index.ts | 5 + .../utils/passkey/registerPasskey.native.ts | 8 + .../client/utils/passkey/registerPasskey.ts | 48 + .../auth/src/client/utils/passkey/serde.ts | 151 +++ .../src/client/utils/passkey/types/index.ts | 61 + .../src/client/utils/passkey/types/shared.ts | 131 ++ .../src/client/utils/store/autoSignInStore.ts | 64 + packages/auth/src/client/utils/store/index.ts | 5 + .../utils/store}/signInStore.ts | 11 +- packages/auth/src/client/utils/store/types.ts | 9 + .../apis/deleteWebAuthnCredential.ts | 45 + packages/auth/src/foundation/apis/index.ts | 5 + .../apis/listWebAuthnCredentials.ts | 68 + .../convertArrayBufferToBase64Url.ts | 18 + .../convertBase64UrlToArrayBuffer.ts | 18 + .../src/foundation/convert/base64url/index.ts | 5 + packages/auth/src/foundation/convert/index.ts | 7 + ...reateCompleteWebAuthnRegistrationClient.ts | 31 + .../createDeleteWebAuthnCredentialClient.ts | 31 + .../createListWebAuthnCredentialsClient.ts | 31 + .../createSignUpClient.ts | 51 +- .../createStartWebAuthnRegistrationClient.ts | 31 + .../cognitoIdentityProvider/index.ts | 4 + .../shared/serde/createUserPoolSerializer.ts | 6 +- .../cognitoIdentityProvider/types/errors.ts | 43 + .../cognitoIdentityProvider/types/index.ts | 2 + .../cognitoIdentityProvider/types/sdk.ts | 146 ++- packages/auth/src/foundation/types/index.ts | 9 + packages/auth/src/foundation/types/inputs.ts | 14 + packages/auth/src/foundation/types/models.ts | 14 + packages/auth/src/foundation/types/outputs.ts | 12 + packages/auth/src/index.ts | 14 + .../src/providers/cognito/apis/autoSignIn.ts | 8 +- .../providers/cognito/apis/confirmSignIn.ts | 2 +- .../providers/cognito/apis/confirmSignUp.ts | 19 +- .../auth/src/providers/cognito/apis/signIn.ts | 10 + .../cognito/apis/signInWithCustomAuth.ts | 2 +- .../cognito/apis/signInWithCustomSRPAuth.ts | 2 +- .../providers/cognito/apis/signInWithSRP.ts | 7 +- .../cognito/apis/signInWithUserAuth.ts | 141 +++ .../cognito/apis/signInWithUserPassword.ts | 9 +- .../auth/src/providers/cognito/apis/signUp.ts | 153 +-- .../auth/src/providers/cognito/types/index.ts | 2 + .../src/providers/cognito/types/inputs.ts | 5 + .../src/providers/cognito/types/models.ts | 13 + .../src/providers/cognito/types/options.ts | 8 +- .../src/providers/cognito/types/outputs.ts | 5 + .../cognito/utils/handleMFAChallenge.ts | 6 +- .../providers/cognito/utils/signInHelpers.ts | 126 +- .../providers/cognito/utils/signUpHelpers.ts | 37 +- packages/auth/src/types/inputs.ts | 4 +- packages/auth/src/types/models.ts | 13 + packages/aws-amplify/CHANGELOG.md | 12 + .../aws-amplify/__tests__/exports.test.ts | 5 + packages/aws-amplify/package.json | 92 +- packages/core/CHANGELOG.md | 12 + .../retry/defaultRetryDecider.test.ts | 128 +- .../middleware/retry/middleware.test.ts | 58 +- .../middleware/signing/middleware.test.ts | 26 + .../__tests__/parseAmplifyOutputs.test.ts | 53 + .../utils/convert/base64Decoder.test.ts | 10 + packages/core/metadata | 2 +- packages/core/package.json | 2 +- packages/core/src/Platform/types.ts | 6 + packages/core/src/clients/index.ts | 7 +- .../src/clients/internal/composeServiceApi.ts | 72 +- .../middleware/retry/defaultRetryDecider.ts | 15 +- .../src/clients/middleware/retry/index.ts | 1 + .../clients/middleware/retry/middleware.ts | 18 +- .../src/clients/middleware/retry/types.ts | 7 + .../src/clients/middleware/signing/index.ts | 6 +- .../clients/middleware/signing/middleware.ts | 24 +- packages/core/src/clients/types/core.ts | 5 + packages/core/src/clients/types/index.ts | 1 + packages/core/src/parseAmplifyOutputs.ts | 29 +- .../src/singleton/AmplifyOutputs/types.ts | 6 +- packages/core/src/singleton/Auth/types.ts | 7 +- packages/core/src/singleton/Storage/types.ts | 2 + .../src/utils/convert/base64/base64Decoder.ts | 12 +- packages/core/src/utils/convert/types.ts | 8 +- .../datastore-storage-adapter/CHANGELOG.md | 8 + .../datastore-storage-adapter/package.json | 6 +- packages/datastore/CHANGELOG.md | 8 + packages/datastore/package.json | 6 +- packages/geo/CHANGELOG.md | 8 + packages/geo/package.json | 4 +- packages/interactions/CHANGELOG.md | 10 + packages/interactions/package.json | 10 +- packages/notifications/CHANGELOG.md | 8 + packages/notifications/package.json | 4 +- packages/predictions/CHANGELOG.md | 8 + packages/predictions/package.json | 6 +- packages/pubsub/CHANGELOG.md | 8 + packages/pubsub/package.json | 6 +- packages/storage/CHANGELOG.md | 10 + .../__tests__/internals/apis/copy.test.ts | 57 + .../internals/apis/downloadData.test.ts | 82 ++ .../internals/apis/getDataAccess.test.ts | 133 ++ .../internals/apis/getProperties.test.ts | 63 + .../__tests__/internals/apis/getUrl.test.ts | 81 ++ .../__tests__/internals/apis/list.test.ts | 60 + .../apis/listCallerAccessGrants.test.ts | 138 ++ .../getHighestPrecedenceUserGroup.test.ts | 57 + .../apis/listPaths/listPaths.test.ts | 202 +++ .../resolveLocationsForCurrentSession.test.ts | 141 +++ .../__tests__/internals/apis/remove.test.ts | 65 + .../internals/apis/uploadData.test.ts | 76 ++ .../__tests__/providers/s3/apis/copy.test.ts | 428 +------ .../providers/s3/apis/downloadData.test.ts | 506 +------- .../providers/s3/apis/getProperties.test.ts | 419 +------ .../providers/s3/apis/getUrl.test.ts | 496 +------- .../providers/s3/apis/internal/copy.test.ts | 533 ++++++++ .../s3/apis/internal/downloadData.test.ts | 547 ++++++++ .../s3/apis/internal/getProperties.test.ts | 500 ++++++++ .../providers/s3/apis/internal/getUrl.test.ts | 575 +++++++++ .../providers/s3/apis/internal/list.test.ts | 1107 +++++++++++++++++ .../providers/s3/apis/internal/remove.test.ts | 337 +++++ .../s3/apis/{ => internal}/testUtils.ts | 0 .../uploadData/byteLength.test.ts | 2 +- .../{ => internal}/uploadData/index.test.ts | 131 +- .../uploadData/multipartHandlers.test.ts | 699 +++++++++-- .../uploadData/putObjectJob.test.ts | 220 ++-- .../__tests__/providers/s3/apis/list.test.ts | 883 +------------ .../providers/s3/apis/remove.test.ts | 304 +---- .../providers/s3/apis/server/copy.test.ts | 54 + .../s3/apis/server/getProperties.test.ts | 61 + .../providers/s3/apis/server/getUrl.test.ts | 59 + .../providers/s3/apis/server/list.test.ts | 77 ++ .../providers/s3/apis/server/remove.test.ts | 48 + .../providers/s3/apis/uploadData.test.ts | 57 + .../utils/resolveS3ConfigAndInput.test.ts | 106 +- .../client/S3/cases/abortMultipartUpload.ts | 38 +- .../S3/cases/completeMultipartUpload.ts | 122 +- .../s3/utils/client/S3/cases/copyObject.ts | 38 +- .../client/S3/cases/createMultipartUpload.ts | 34 +- .../s3/utils/client/S3/cases/deleteObject.ts | 34 +- .../s3/utils/client/S3/cases/getDataAccess.ts | 172 +++ .../s3/utils/client/S3/cases/getObject.ts | 112 +- .../s3/utils/client/S3/cases/headObject.ts | 34 +- .../s3/utils/client/S3/cases/index.ts | 4 + .../client/S3/cases/listCallerAccessGrants.ts | 206 +++ .../s3/utils/client/S3/cases/listObjectsV2.ts | 479 ++++++- .../s3/utils/client/S3/cases/listParts.ts | 41 +- .../s3/utils/client/S3/cases/putObject.ts | 35 +- .../s3/utils/client/S3/cases/uploadPart.ts | 36 +- .../utils/client/S3/functional-apis.test.ts | 4 +- .../S3/getPresignedGetObjectUrl.test.ts | 2 +- .../client/s3Data/abortMutipartUpload.test.ts | 93 ++ .../s3Data/completeMultipartUpload.test.ts | 143 +++ .../s3/utils/client/s3Data/copyObject.test.ts | 193 +++ .../s3Data/createMultipartUpload.test.ts | 92 ++ .../utils/client/s3Data/deleteObject.test.ts | 92 ++ .../s3/utils/client/s3Data/getObject.test.ts | 98 ++ .../s3/utils/client/s3Data/headObject.test.ts | 93 ++ .../s3/utils/client/s3Data/putObject.test.ts | 93 ++ .../s3/utils/client/s3Data/uploadPart.test.ts | 96 ++ .../s3/utils/client/testUtils/types.ts | 2 +- .../client/utils/createRetryDecider.test.ts | 103 ++ .../client/utils/integrityHelpers.test.ts | 71 ++ .../providers/s3/utils/crc32.test.ts | 131 ++ .../s3/utils/getCombinedCrc32.native.test.ts | 108 ++ .../s3/utils/getCombinedCrc32.test.ts | 108 ++ .../providers/s3/utils/md5.native.test.ts | 131 -- .../s3/utils/readFile.native.test.ts | 119 ++ .../providers/s3/utils/readFile.test.ts | 90 ++ .../utils/validateMultipartUploadXML.test.ts | 186 +++ .../s3/utils/validateObjectUrl.test.ts | 174 +++ packages/storage/internals/package.json | 7 + packages/storage/package.json | 15 +- packages/storage/src/errors/IntegrityError.ts | 25 + packages/storage/src/errors/constants.ts | 4 + .../storage/src/errors/types/validation.ts | 28 + packages/storage/src/index.ts | 2 + packages/storage/src/internals/apis/copy.ts | 34 + .../src/internals/apis/downloadData.ts | 25 + .../src/internals/apis/getDataAccess.ts | 83 ++ .../src/internals/apis/getProperties.ts | 27 + packages/storage/src/internals/apis/getUrl.ts | 31 + packages/storage/src/internals/apis/list.ts | 47 + .../internals/apis/listCallerAccessGrants.ts | 104 ++ .../getHighestPrecedenceUserGroup.ts | 42 + .../src/internals/apis/listPaths/index.ts | 4 + .../src/internals/apis/listPaths/listPaths.ts | 37 + .../resolveLocationsForCurrentSession.ts | 80 ++ packages/storage/src/internals/apis/remove.ts | 25 + .../storage/src/internals/apis/uploadData.ts | 36 + packages/storage/src/internals/index.ts | 79 ++ .../storage/src/internals/types/common.ts | 27 + .../src/internals/types/credentials.ts | 102 ++ .../storage/src/internals/types/inputs.ts | 142 +++ .../storage/src/internals/types/options.ts | 2 + .../storage/src/internals/types/outputs.ts | 60 + .../storage/src/internals/utils/constants.ts | 8 + .../src/providers/s3/apis/downloadData.ts | 87 +- .../src/providers/s3/apis/internal/copy.ts | 87 +- .../s3/apis/internal/downloadData.ts | 96 ++ .../s3/apis/internal/getProperties.ts | 15 +- .../src/providers/s3/apis/internal/getUrl.ts | 18 +- .../src/providers/s3/apis/internal/list.ts | 109 +- .../src/providers/s3/apis/internal/remove.ts | 19 +- .../{ => internal}/uploadData/byteLength.ts | 12 +- .../s3/apis/internal/uploadData/index.ts | 59 + .../uploadData/multipart/calculatePartSize.ts | 5 +- .../uploadData/multipart/getDataChunker.ts | 6 +- .../internal/uploadData/multipart/index.ts | 7 + .../uploadData/multipart/initialUpload.ts | 86 +- .../uploadData/multipart/progressTracker.ts | 2 +- .../uploadData/multipart/uploadCache.ts | 44 +- .../uploadData/multipart/uploadHandlers.ts | 160 ++- .../multipart/uploadPartExecutor.ts | 39 +- .../apis/internal/uploadData/putObjectJob.ts | 115 ++ .../{uploadData/index.ts => uploadData.ts} | 55 +- .../s3/apis/uploadData/putObjectJob.ts | 81 -- packages/storage/src/providers/s3/index.ts | 2 + .../storage/src/providers/s3/types/options.ts | 57 +- .../s3/utils/client/s3control/base.ts | 112 ++ .../utils/client/s3control/getDataAccess.ts | 96 ++ .../s3/utils/client/s3control/index.ts | 13 + .../s3control/listCallerAccessGrants.ts | 115 ++ .../s3/utils/client/s3control/types.ts | 246 ++++ .../{ => s3data}/abortMultipartUpload.ts | 24 +- .../s3/utils/client/{ => s3data}/base.ts | 77 +- .../{ => s3data}/completeMultipartUpload.ts | 71 +- .../utils/client/{ => s3data}/copyObject.ts | 51 +- .../{ => s3data}/createMultipartUpload.ts | 31 +- .../utils/client/{ => s3data}/deleteObject.ts | 28 +- .../s3/utils/client/{ => s3data}/getObject.ts | 33 +- .../utils/client/{ => s3data}/headObject.ts | 25 +- .../s3/utils/client/{ => s3data}/index.ts | 0 .../client/{ => s3data}/listObjectsV2.ts | 42 +- .../s3/utils/client/{ => s3data}/listParts.ts | 23 +- .../s3/utils/client/{ => s3data}/putObject.ts | 24 +- .../s3/utils/client/{ => s3data}/types.ts | 14 + .../utils/client/{ => s3data}/uploadPart.ts | 31 +- .../utils/client/utils/createRetryDecider.ts | 96 ++ .../utils/client/utils/deserializeHelpers.ts | 56 + .../providers/s3/utils/client/utils/index.ts | 5 +- .../s3/utils/client/utils/integrityHelpers.ts | 62 + .../s3/utils/client/utils/parsePayload.ts | 54 +- .../src/providers/s3/utils/constants.ts | 5 + .../storage/src/providers/s3/utils/crc32.ts | 72 ++ .../s3/utils/getCombinedCrc32.native.ts | 46 + .../providers/s3/utils/getCombinedCrc32.ts | 34 + .../src/providers/s3/utils/hexUtils.ts | 13 + .../storage/src/providers/s3/utils/index.ts | 2 + .../storage/src/providers/s3/utils/md5.ts | 16 +- .../{md5.native.ts => readFile.native.ts} | 19 +- .../src/providers/s3/utils/readFile.ts | 17 + .../providers/s3/utils/resolveIdentityId.ts | 11 + .../s3/utils/resolveS3ConfigAndInput.ts | 127 +- .../src/providers/s3/utils/urlDecoder.ts | 13 + .../s3/utils/validateBucketOwnerID.ts | 18 + .../s3/utils/validateMultipartUploadXML.ts | 36 + .../providers/s3/utils/validateObjectUrl.ts | 32 + .../s3/utils/validateStorageOperationInput.ts | 6 +- ...validateStorageOperationInputWithPrefix.ts | 6 +- packages/storage/src/types/inputs.ts | 9 +- scripts/dts-bundler/README.md | 36 +- scripts/dts-bundler/dts-bundler.config.js | 10 +- scripts/dts-bundler/package.json | 4 +- scripts/dts-bundler/s3-control.d.ts | 13 + scripts/tsc-compliance-test/CHANGELOG.md | 8 + scripts/tsc-compliance-test/package.json | 4 +- tsconfig.json | 2 +- yarn.lock | 96 +- 383 files changed, 19567 insertions(+), 4362 deletions(-) create mode 100644 docs/api/functions/aws_amplify.auth.associateWebAuthnCredential.html create mode 100644 docs/api/functions/aws_amplify.auth.deleteWebAuthnCredential.html create mode 100644 docs/api/functions/aws_amplify.auth.listWebAuthnCredentials.html create mode 100644 docs/api/interfaces/aws_amplify.auth.AuthWebAuthnCredential.html create mode 100644 docs/api/interfaces/aws_amplify.auth.DeleteWebAuthnCredentialInput.html create mode 100644 docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsInput.html create mode 100644 docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsOutput.html create mode 100644 docs/api/interfaces/aws_amplify.auth._Reference_Types_.ConfirmSignInWithPassword.html create mode 100644 docs/api/interfaces/aws_amplify.auth._Reference_Types_.ContinueSignInWithFirstFactorSelection.html create mode 100644 docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathDestinationOptions.html create mode 100644 docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathSourceOptions.html create mode 100644 docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.UserGroupPrecedence.html create mode 100644 docs/api/types/_aws_amplify_adapter_nextjs.index._Reference_Types_.UserGroupPrecedence.html create mode 100644 docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence-1.html create mode 100644 docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence.html create mode 100644 docs/api/types/aws_amplify.auth._Reference_Types_.AuthFactorType.html create mode 100644 docs/api/types/aws_amplify.auth._Reference_Types_.ChallengeName.html create mode 100644 docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataChecksumAlgorithm.html create mode 100644 docs/api/variables/aws_amplify.storage.DEFAULT_PART_SIZE.html create mode 100644 packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts create mode 100644 packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts create mode 100644 packages/auth/__tests__/client/utils/passkey.test.ts create mode 100644 packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts create mode 100644 packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts create mode 100644 packages/auth/__tests__/foundation/convert/base64url.test.ts create mode 100644 packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts create mode 100644 packages/auth/src/client/apis/associateWebAuthnCredential.ts create mode 100644 packages/auth/src/client/apis/deleteWebAuthnCredential.ts create mode 100644 packages/auth/src/client/apis/index.ts create mode 100644 packages/auth/src/client/apis/listWebAuthnCredentials.ts create mode 100644 packages/auth/src/client/flows/shared/handlePasswordSRP.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts rename packages/{storage/src/providers/s3/apis/uploadData/multipart => auth/src/client/utils}/index.ts (63%) create mode 100644 packages/auth/src/client/utils/passkey/errors.ts create mode 100644 packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts create mode 100644 packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts create mode 100644 packages/auth/src/client/utils/passkey/getPasskey.native.ts create mode 100644 packages/auth/src/client/utils/passkey/getPasskey.ts create mode 100644 packages/auth/src/client/utils/passkey/index.ts create mode 100644 packages/auth/src/client/utils/passkey/registerPasskey.native.ts create mode 100644 packages/auth/src/client/utils/passkey/registerPasskey.ts create mode 100644 packages/auth/src/client/utils/passkey/serde.ts create mode 100644 packages/auth/src/client/utils/passkey/types/index.ts create mode 100644 packages/auth/src/client/utils/passkey/types/shared.ts create mode 100644 packages/auth/src/client/utils/store/autoSignInStore.ts create mode 100644 packages/auth/src/client/utils/store/index.ts rename packages/auth/src/{providers/cognito/utils => client/utils/store}/signInStore.ts (87%) create mode 100644 packages/auth/src/client/utils/store/types.ts create mode 100644 packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts create mode 100644 packages/auth/src/foundation/apis/index.ts create mode 100644 packages/auth/src/foundation/apis/listWebAuthnCredentials.ts create mode 100644 packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts create mode 100644 packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts create mode 100644 packages/auth/src/foundation/convert/base64url/index.ts create mode 100644 packages/auth/src/foundation/convert/index.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts create mode 100644 packages/auth/src/foundation/types/index.ts create mode 100644 packages/auth/src/foundation/types/inputs.ts create mode 100644 packages/auth/src/foundation/types/models.ts create mode 100644 packages/auth/src/foundation/types/outputs.ts create mode 100644 packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts create mode 100644 packages/core/src/clients/middleware/retry/types.ts create mode 100644 packages/storage/__tests__/internals/apis/copy.test.ts create mode 100644 packages/storage/__tests__/internals/apis/downloadData.test.ts create mode 100644 packages/storage/__tests__/internals/apis/getDataAccess.test.ts create mode 100644 packages/storage/__tests__/internals/apis/getProperties.test.ts create mode 100644 packages/storage/__tests__/internals/apis/getUrl.test.ts create mode 100644 packages/storage/__tests__/internals/apis/list.test.ts create mode 100644 packages/storage/__tests__/internals/apis/listCallerAccessGrants.test.ts create mode 100644 packages/storage/__tests__/internals/apis/listPaths/getHighestPrecedenceUserGroup.test.ts create mode 100644 packages/storage/__tests__/internals/apis/listPaths/listPaths.test.ts create mode 100644 packages/storage/__tests__/internals/apis/listPaths/resolveLocationsForCurrentSession.test.ts create mode 100644 packages/storage/__tests__/internals/apis/remove.test.ts create mode 100644 packages/storage/__tests__/internals/apis/uploadData.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/copy.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/getProperties.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/list.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/internal/remove.test.ts rename packages/storage/__tests__/providers/s3/apis/{ => internal}/testUtils.ts (100%) rename packages/storage/__tests__/providers/s3/apis/{ => internal}/uploadData/byteLength.test.ts (91%) rename packages/storage/__tests__/providers/s3/apis/{ => internal}/uploadData/index.test.ts (64%) rename packages/storage/__tests__/providers/s3/apis/{ => internal}/uploadData/multipartHandlers.test.ts (69%) rename packages/storage/__tests__/providers/s3/apis/{ => internal}/uploadData/putObjectJob.test.ts (59%) create mode 100644 packages/storage/__tests__/providers/s3/apis/server/copy.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/server/getProperties.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/server/getUrl.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/server/list.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/server/remove.test.ts create mode 100644 packages/storage/__tests__/providers/s3/apis/uploadData.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/S3/cases/getDataAccess.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/S3/cases/listCallerAccessGrants.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/abortMutipartUpload.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/copyObject.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/deleteObject.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/getObject.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/headObject.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/putObject.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/uploadPart.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/utils/createRetryDecider.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/crc32.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.native.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.test.ts delete mode 100644 packages/storage/__tests__/providers/s3/utils/md5.native.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/readFile.native.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/readFile.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/validateObjectUrl.test.ts create mode 100644 packages/storage/internals/package.json create mode 100644 packages/storage/src/errors/IntegrityError.ts create mode 100644 packages/storage/src/errors/constants.ts create mode 100644 packages/storage/src/internals/apis/copy.ts create mode 100644 packages/storage/src/internals/apis/downloadData.ts create mode 100644 packages/storage/src/internals/apis/getDataAccess.ts create mode 100644 packages/storage/src/internals/apis/getProperties.ts create mode 100644 packages/storage/src/internals/apis/getUrl.ts create mode 100644 packages/storage/src/internals/apis/list.ts create mode 100644 packages/storage/src/internals/apis/listCallerAccessGrants.ts create mode 100644 packages/storage/src/internals/apis/listPaths/getHighestPrecedenceUserGroup.ts create mode 100644 packages/storage/src/internals/apis/listPaths/index.ts create mode 100644 packages/storage/src/internals/apis/listPaths/listPaths.ts create mode 100644 packages/storage/src/internals/apis/listPaths/resolveLocationsForCurrentSession.ts create mode 100644 packages/storage/src/internals/apis/remove.ts create mode 100644 packages/storage/src/internals/apis/uploadData.ts create mode 100644 packages/storage/src/internals/index.ts create mode 100644 packages/storage/src/internals/types/common.ts create mode 100644 packages/storage/src/internals/types/credentials.ts create mode 100644 packages/storage/src/internals/types/inputs.ts create mode 100644 packages/storage/src/internals/types/options.ts create mode 100644 packages/storage/src/internals/types/outputs.ts create mode 100644 packages/storage/src/internals/utils/constants.ts create mode 100644 packages/storage/src/providers/s3/apis/internal/downloadData.ts rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/byteLength.ts (65%) create mode 100644 packages/storage/src/providers/s3/apis/internal/uploadData/index.ts rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/calculatePartSize.ts (83%) rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/getDataChunker.ts (90%) create mode 100644 packages/storage/src/providers/s3/apis/internal/uploadData/multipart/index.ts rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/initialUpload.ts (56%) rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/progressTracker.ts (94%) rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/uploadCache.ts (70%) rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/uploadHandlers.ts (60%) rename packages/storage/src/providers/s3/apis/{ => internal}/uploadData/multipart/uploadPartExecutor.ts (61%) create mode 100644 packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts rename packages/storage/src/providers/s3/apis/{uploadData/index.ts => uploadData.ts} (71%) delete mode 100644 packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts create mode 100644 packages/storage/src/providers/s3/utils/client/s3control/base.ts create mode 100644 packages/storage/src/providers/s3/utils/client/s3control/getDataAccess.ts create mode 100644 packages/storage/src/providers/s3/utils/client/s3control/index.ts create mode 100644 packages/storage/src/providers/s3/utils/client/s3control/listCallerAccessGrants.ts create mode 100644 packages/storage/src/providers/s3/utils/client/s3control/types.ts rename packages/storage/src/providers/s3/utils/client/{ => s3data}/abortMultipartUpload.ts (80%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/base.ts (57%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/completeMultipartUpload.ts (75%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/copyObject.ts (59%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/createMultipartUpload.ts (80%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/deleteObject.ts (81%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/getObject.ts (93%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/headObject.ts (81%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/index.ts (100%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/listObjectsV2.ts (83%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/listParts.ts (82%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/putObject.ts (81%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/types.ts (98%) rename packages/storage/src/providers/s3/utils/client/{ => s3data}/uploadPart.ts (78%) create mode 100644 packages/storage/src/providers/s3/utils/client/utils/createRetryDecider.ts create mode 100644 packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts create mode 100644 packages/storage/src/providers/s3/utils/crc32.ts create mode 100644 packages/storage/src/providers/s3/utils/getCombinedCrc32.native.ts create mode 100644 packages/storage/src/providers/s3/utils/getCombinedCrc32.ts create mode 100644 packages/storage/src/providers/s3/utils/hexUtils.ts rename packages/storage/src/providers/s3/utils/{md5.native.ts => readFile.native.ts} (68%) create mode 100644 packages/storage/src/providers/s3/utils/readFile.ts create mode 100644 packages/storage/src/providers/s3/utils/resolveIdentityId.ts create mode 100644 packages/storage/src/providers/s3/utils/urlDecoder.ts create mode 100644 packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts create mode 100644 packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts create mode 100644 packages/storage/src/providers/s3/utils/validateObjectUrl.ts create mode 100644 scripts/dts-bundler/s3-control.d.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 530cc248c40..a24f7b2ebbb 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,8 @@ /packages/geo @aws-amplify/amplify-js @aws-amplify/amplify-ui /packages/pubsub @aws-amplify/amplify-js @aws-amplify/amplify-data /packages/aws-amplify/package.json @aws-amplify/amplify-js-admins +/packages/storage/src/storageBrowser @aws-amplify/amplify-js @aws-amplify/amplify-ui +/packages/storage/storage-browser @aws-amplify/amplify-js @aws-amplify/amplify-ui /.circleci/ @aws-amplify/amplify-js @aws-amplify/amplify-devops /.github/ @aws-amplify/amplify-js-admins diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 94a2d85a157..b20a0cc45e1 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -827,6 +827,13 @@ tests: sample_name: [multi-part-copy] spec: multi-part-copy browser: *minimal_browser_list + - test_name: integ_react_storage_browser + desc: 'React Storage Browser' + framework: vite + category: storage + sample_name: [storage-browser] + spec: storage-browser + browser: *minimal_browser_list # GEN2 STORAGE - test_name: integ_react_storage @@ -836,6 +843,13 @@ tests: sample_name: [storage-gen2] spec: storage-gen2 browser: *minimal_browser_list + - test_name: integ_react_storage_internal + desc: 'React Storage Gen2 Internal APIs' + framework: react + category: storage + sample_name: [storage-gen2-internal] + spec: storage-gen2-internal + browser: *minimal_browser_list - test_name: integ_next_storage desc: 'Next Storage Auth' framework: next @@ -876,7 +890,7 @@ tests: category: auth sample_name: [mfa] spec: mfa-req-email - browser: [chrome] + browser: *minimal_browser_list env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-req-email - test_name: integ_next_mfa_req_phone @@ -885,7 +899,7 @@ tests: category: auth sample_name: [mfa] spec: mfa-req-phone - browser: [chrome] + browser: *minimal_browser_list env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-req-phone - test_name: integ_next_mfa_opt_email @@ -894,7 +908,7 @@ tests: category: auth sample_name: [mfa] spec: mfa-opt-email - browser: [chrome] + browser: *minimal_browser_list env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-opt-email - test_name: integ_next_mfa_opt_phone @@ -903,7 +917,7 @@ tests: category: auth sample_name: [mfa] spec: mfa-opt-phone - browser: [chrome] + browser: *minimal_browser_list env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-opt-phone - test_name: integ_next_mfa_setup @@ -912,6 +926,62 @@ tests: category: auth sample_name: [mfa] spec: mfa-setup - browser: [chrome] + browser: *minimal_browser_list env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-setup + - test_name: integ_next_passwordless_auto_sign_in + desc: 'passwordless auto sign in with session' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/auto-sign-in + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-autosignin + - test_name: integ_next_passwordless_first_factor_selection + desc: 'passwordless sign in with first factor selection' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/first-factor-selection + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-ffselect + - test_name: integ_next_passwordless_preferred_challenge + desc: 'passwordless sign in with preferred challenge' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/preferred-challenge + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-prefchal + - test_name: integ_next_passwordless_sign_up + desc: 'passwordless sign up' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/sign-up + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-signup + - test_name: integ_next_passwordless_misc + desc: 'passwordless miscellaneous flows' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/miscellaneous + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-misc + - test_name: integ_next_passwordless_webauthn + desc: 'passwordless webauthn sign in and lifecycle management' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/webauthn + # chrome only + # https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/ + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-webauthn diff --git a/.github/workflows/callable-e2e-test.yml b/.github/workflows/callable-e2e-test.yml index 7df6b042969..ee02150baa3 100644 --- a/.github/workflows/callable-e2e-test.yml +++ b/.github/workflows/callable-e2e-test.yml @@ -46,6 +46,10 @@ env: CYPRESS_GOOGLE_CLIENTID: ${{ secrets.CYPRESS_GOOGLE_CLIENTID }} CYPRESS_GOOGLE_CLIENT_SECRET: ${{ secrets.CYPRESS_GOOGLE_CLIENT_SECRET }} CYPRESS_GOOGLE_REFRESH_TOKEN: ${{ secrets.CYPRESS_GOOGLE_REFRESH_TOKEN }} + CYPRESS_AUTH0_CLIENTID: ${{ secrets.CYPRESS_AUTH0_CLIENTID }} + CYPRESS_AUTH0_SECRET: ${{ secrets.CYPRESS_AUTH0_SECRET }} + CYPRESS_AUTH0_AUDIENCE: ${{ secrets.CYPRESS_AUTH0_AUDIENCE }} + CYPRESS_AUTH0_DOMAIN: ${{ secrets.CYPRESS_AUTH0_DOMAIN }} jobs: e2e-test: diff --git a/docs/api/assets/navigation.js b/docs/api/assets/navigation.js index 5acdbc1baa8..82603c9da9b 100644 --- a/docs/api/assets/navigation.js +++ b/docs/api/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "data:application/octet-stream;base64,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" \ No newline at end of file +window.navigationData = "data:application/octet-stream;base64,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" \ No newline at end of file diff --git a/docs/api/assets/search.js b/docs/api/assets/search.js index 16ab23502d6..48d4843ab66 100644 --- a/docs/api/assets/search.js +++ b/docs/api/assets/search.js @@ -1 +1 @@ -window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file +window.searchData = "data:application/octet-stream;base64,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"; \ No newline at end of file diff --git a/docs/api/enums/aws_amplify.datastore._Reference_Types_.AuthAction.html b/docs/api/enums/aws_amplify.datastore._Reference_Types_.AuthAction.html index 19fd8055758..9693b268f89 100644 --- a/docs/api/enums/aws_amplify.datastore._Reference_Types_.AuthAction.html +++ b/docs/api/enums/aws_amplify.datastore._Reference_Types_.AuthAction.html @@ -1,15 +1,18 @@ AuthAction | Amplify JS API Documentation -

Enumeration Members

ConfirmResetPassword +

Enumeration Members

ConfirmResetPassword: "29"
ConfirmSignIn: "12"
ConfirmSignUp: "2"
ConfirmUserAttribute: "22"
DeleteUser: "16"
DeleteUserAttributes: "15"
FederatedSignIn: "30"
FetchDevices: "34"
FetchMFAPreference: "6"
FetchUserAttributes: "18"
ForgetDevice: "33"
RememberDevice: "32"
ResendSignUpCode: "3"
ResetPassword: "28"
SendUserAttributeVerificationCode: "35"
SetUpTOTP: "10"
SignIn: "4"
SignInWithRedirect: "36"
SignOut: "26"
SignUp: "1"
UpdateMFAPreference: "7"
UpdatePassword: "27"
UpdateUserAttributes: "17"
VerifyTOTPSetup: "11"
\ No newline at end of file +

Enumeration Members

CompleteWebAuthnRegistration: "38"
ConfirmResetPassword: "29"
ConfirmSignIn: "12"
ConfirmSignUp: "2"
ConfirmUserAttribute: "22"
DeleteUser: "16"
DeleteUserAttributes: "15"
DeleteWebAuthnCredential: "40"
FederatedSignIn: "30"
FetchDevices: "34"
FetchMFAPreference: "6"
FetchUserAttributes: "18"
ForgetDevice: "33"
ListWebAuthnCredentials: "39"
RememberDevice: "32"
ResendSignUpCode: "3"
ResetPassword: "28"
SendUserAttributeVerificationCode: "35"
SetUpTOTP: "10"
SignIn: "4"
SignInWithRedirect: "36"
SignOut: "26"
SignUp: "1"
StartWebAuthnRegistration: "37"
UpdateMFAPreference: "7"
UpdatePassword: "27"
UpdateUserAttributes: "17"
VerifyTOTPSetup: "11"
\ No newline at end of file diff --git a/docs/api/enums/aws_amplify.datastore._Reference_Types_.StorageAction.html b/docs/api/enums/aws_amplify.datastore._Reference_Types_.StorageAction.html index c49474f445b..714e8fefb2c 100644 --- a/docs/api/enums/aws_amplify.datastore._Reference_Types_.StorageAction.html +++ b/docs/api/enums/aws_amplify.datastore._Reference_Types_.StorageAction.html @@ -1,9 +1,11 @@ StorageAction | Amplify JS API Documentation

Enumeration Members

Copy: "4"
DownloadData: "2"
GetProperties: "6"
GetUrl: "7"
List: "3"
Remove: "5"
UploadData: "1"
\ No newline at end of file +

Enumeration Members

Copy: "4"
DownloadData: "2"
GetDataAccess: "8"
GetProperties: "6"
GetUrl: "7"
List: "3"
ListCallerAccessGrants: "9"
Remove: "5"
UploadData: "1"
\ No newline at end of file diff --git a/docs/api/functions/aws_amplify.auth.associateWebAuthnCredential.html b/docs/api/functions/aws_amplify.auth.associateWebAuthnCredential.html new file mode 100644 index 00000000000..149ff5ab849 --- /dev/null +++ b/docs/api/functions/aws_amplify.auth.associateWebAuthnCredential.html @@ -0,0 +1,20 @@ +associateWebAuthnCredential | Amplify JS API Documentation +
  • Registers a new passkey for an authenticated user

    +

    Returns Promise<void>

    Promise

    +

    Throws

      +
    • PasskeyError:
    • +
    • Thrown when intermediate state is invalid
    • +
    +

    Throws

      +
    • AuthError:
    • +
    • Thrown when user is unauthenticated
    • +
    +

    Throws

      +
    • StartWebAuthnRegistrationException
    • +
    • Thrown due to a service error retrieving WebAuthn registration options
    • +
    +

    Throws

      +
    • CompleteWebAuthnRegistrationException
    • +
    • Thrown due to a service error when verifying WebAuthn registration result
    • +
    +
\ No newline at end of file diff --git a/docs/api/functions/aws_amplify.auth.deleteWebAuthnCredential.html b/docs/api/functions/aws_amplify.auth.deleteWebAuthnCredential.html new file mode 100644 index 00000000000..6b0699d253b --- /dev/null +++ b/docs/api/functions/aws_amplify.auth.deleteWebAuthnCredential.html @@ -0,0 +1,13 @@ +deleteWebAuthnCredential | Amplify JS API Documentation +
  • Delete a registered credential for an authenticated user by credentialId

    +

    Parameters

    Returns Promise<void>

    Promise

    +

    Throws

      +
    • AuthError:
    • +
    • Thrown when user is unauthenticated
    • +
    +

    Throws

      +
    • DeleteWebAuthnCredentialException
    • +
    • Thrown due to a service error when deleting a WebAuthn credential
    • +
    +
\ No newline at end of file diff --git a/docs/api/functions/aws_amplify.auth.listWebAuthnCredentials.html b/docs/api/functions/aws_amplify.auth.listWebAuthnCredentials.html new file mode 100644 index 00000000000..63c37b2ba82 --- /dev/null +++ b/docs/api/functions/aws_amplify.auth.listWebAuthnCredentials.html @@ -0,0 +1,13 @@ +listWebAuthnCredentials | Amplify JS API Documentation +
\ No newline at end of file diff --git a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.AuthIdentityPoolConfig.html b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.AuthIdentityPoolConfig.html index 3fc4de4ad56..e550eb3c550 100644 --- a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.AuthIdentityPoolConfig.html +++ b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.AuthIdentityPoolConfig.html @@ -1,3 +1,3 @@ AuthIdentityPoolConfig | Amplify JS API Documentation -
interface AuthIdentityPoolConfig {
    Cognito: CognitoIdentityPoolConfig & {
        loginWith?: undefined;
        mfa?: undefined;
        passwordFormat?: undefined;
        signUpVerificationMethod?: undefined;
        userAttributes?: undefined;
        userPoolClientId?: undefined;
        userPoolEndpoint?: undefined;
        userPoolId?: undefined;
    };
}

Properties

Properties

Cognito: CognitoIdentityPoolConfig & {
    loginWith?: undefined;
    mfa?: undefined;
    passwordFormat?: undefined;
    signUpVerificationMethod?: undefined;
    userAttributes?: undefined;
    userPoolClientId?: undefined;
    userPoolEndpoint?: undefined;
    userPoolId?: undefined;
}

Type declaration

  • Optional loginWith?: undefined
  • Optional mfa?: undefined
  • Optional passwordFormat?: undefined
  • Optional signUpVerificationMethod?: undefined
  • Optional userAttributes?: undefined
  • Optional userPoolClientId?: undefined
  • Optional userPoolEndpoint?: undefined
  • Optional userPoolId?: undefined
\ No newline at end of file +
interface AuthIdentityPoolConfig {
    Cognito: CognitoIdentityPoolConfig & {
        groups?: undefined;
        loginWith?: undefined;
        mfa?: undefined;
        passwordFormat?: undefined;
        signUpVerificationMethod?: undefined;
        userAttributes?: undefined;
        userPoolClientId?: undefined;
        userPoolEndpoint?: undefined;
        userPoolId?: undefined;
    };
}

Properties

Properties

Cognito: CognitoIdentityPoolConfig & {
    groups?: undefined;
    loginWith?: undefined;
    mfa?: undefined;
    passwordFormat?: undefined;
    signUpVerificationMethod?: undefined;
    userAttributes?: undefined;
    userPoolClientId?: undefined;
    userPoolEndpoint?: undefined;
    userPoolId?: undefined;
}

Type declaration

  • Optional groups?: undefined
  • Optional loginWith?: undefined
  • Optional mfa?: undefined
  • Optional passwordFormat?: undefined
  • Optional signUpVerificationMethod?: undefined
  • Optional userAttributes?: undefined
  • Optional userPoolClientId?: undefined
  • Optional userPoolEndpoint?: undefined
  • Optional userPoolId?: undefined
\ No newline at end of file diff --git a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.BucketInfo.html b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.BucketInfo.html index 348bb031620..9380e071d7b 100644 --- a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.BucketInfo.html +++ b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.BucketInfo.html @@ -1,7 +1,9 @@ BucketInfo | Amplify JS API Documentation

Information on bucket used to store files/objects

-
interface BucketInfo {
    bucketName: string;
    region: string;
}

Properties

interface BucketInfo {
    bucketName: string;
    paths?: Record<string, Record<string, undefined | string[]>>;
    region: string;
}

Properties

bucketName: string

Actual bucket name

+
paths?: Record<string, Record<string, undefined | string[]>>

Paths to object with access permissions

region: string

Region of the bucket

-
\ No newline at end of file +
\ No newline at end of file diff --git a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.CognitoUserPoolConfig.html b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.CognitoUserPoolConfig.html index 15628be2b3f..73a989fb48e 100644 --- a/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.CognitoUserPoolConfig.html +++ b/docs/api/interfaces/_aws_amplify_adapter_nextjs.api._Reference_Types_.CognitoUserPoolConfig.html @@ -1,5 +1,6 @@ CognitoUserPoolConfig | Amplify JS API Documentation -
interface CognitoUserPoolConfig {
    loginWith?: {
        email?: boolean;
        oauth?: OAuthConfig;
        phone?: boolean;
        username?: boolean;
    };
    mfa?: {
        smsEnabled?: boolean;
        status?: CognitoUserPoolConfigMfaStatus;
        totpEnabled?: boolean;
    };
    passwordFormat?: {
        minLength?: number;
        requireLowercase?: boolean;
        requireNumbers?: boolean;
        requireSpecialCharacters?: boolean;
        requireUppercase?: boolean;
    };
    signUpVerificationMethod?: "link" | "code";
    userAttributes?: Partial<Record<AuthStandardAttributeKey, {
        required: boolean;
    }>>;
    userPoolClientId: string;
    userPoolEndpoint?: string;
    userPoolId: string;
}

Properties

loginWith? +
interface CognitoUserPoolConfig {
    groups?: Record<string, UserGroupPrecedence>[];
    loginWith?: {
        email?: boolean;
        oauth?: OAuthConfig;
        phone?: boolean;
        username?: boolean;
    };
    mfa?: {
        smsEnabled?: boolean;
        status?: CognitoUserPoolConfigMfaStatus;
        totpEnabled?: boolean;
    };
    passwordFormat?: {
        minLength?: number;
        requireLowercase?: boolean;
        requireNumbers?: boolean;
        requireSpecialCharacters?: boolean;
        requireUppercase?: boolean;
    };
    signUpVerificationMethod?: "link" | "code";
    userAttributes?: Partial<Record<AuthStandardAttributeKey, {
        required: boolean;
    }>>;
    userPoolClientId: string;
    userPoolEndpoint?: string;
    userPoolId: string;
}

Properties

loginWith?: {
    email?: boolean;
    oauth?: OAuthConfig;
    phone?: boolean;
    username?: boolean;
}

Type declaration

  • Optional email?: boolean
  • Optional oauth?: OAuthConfig
  • Optional phone?: boolean
  • Optional username?: boolean
mfa?: {
    smsEnabled?: boolean;
    status?: CognitoUserPoolConfigMfaStatus;
    totpEnabled?: boolean;
}

Type declaration

passwordFormat?: {
    minLength?: number;
    requireLowercase?: boolean;
    requireNumbers?: boolean;
    requireSpecialCharacters?: boolean;
    requireUppercase?: boolean;
}

Type declaration

  • Optional minLength?: number
  • Optional requireLowercase?: boolean
  • Optional requireNumbers?: boolean
  • Optional requireSpecialCharacters?: boolean
  • Optional requireUppercase?: boolean
signUpVerificationMethod?: "link" | "code"
userAttributes?: Partial<Record<AuthStandardAttributeKey, {
    required: boolean;
}>>
userPoolClientId: string
userPoolEndpoint?: string
userPoolId: string
\ No newline at end of file +

Properties

groups?: Record<string, UserGroupPrecedence>[]
loginWith?: {
    email?: boolean;
    oauth?: OAuthConfig;
    phone?: boolean;
    username?: boolean;
}

Type declaration

  • Optional email?: boolean
  • Optional oauth?: OAuthConfig
  • Optional phone?: boolean
  • Optional username?: boolean
mfa?: {
    smsEnabled?: boolean;
    status?: CognitoUserPoolConfigMfaStatus;
    totpEnabled?: boolean;
}

Type declaration

passwordFormat?: {
    minLength?: number;
    requireLowercase?: boolean;
    requireNumbers?: boolean;
    requireSpecialCharacters?: boolean;
    requireUppercase?: boolean;
}

Type declaration

  • Optional minLength?: number
  • Optional requireLowercase?: boolean
  • Optional requireNumbers?: boolean
  • Optional requireSpecialCharacters?: boolean
  • Optional requireUppercase?: boolean
signUpVerificationMethod?: "link" | "code"
userAttributes?: Partial<Record<AuthStandardAttributeKey, {
    required: boolean;
}>>
userPoolClientId: string
userPoolEndpoint?: string
userPoolId: string
\ No newline at end of file diff --git a/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsAuthProperties.html b/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsAuthProperties.html index 5fcdbca8f40..13a2463e805 100644 --- a/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsAuthProperties.html +++ b/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsAuthProperties.html @@ -1,6 +1,7 @@ AmplifyOutputsAuthProperties | Amplify JS API Documentation -
interface AmplifyOutputsAuthProperties {
    authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH";
    aws_region: string;
    identity_pool_id?: string;
    mfa_configuration?: string;
    mfa_methods?: string[];
    oauth?: {
        domain: string;
        identity_providers: string[];
        redirect_sign_in_uri: string[];
        redirect_sign_out_uri: string[];
        response_type: string;
        scopes: string[];
    };
    password_policy?: {
        min_length: number;
        require_lowercase: boolean;
        require_numbers: boolean;
        require_symbols: boolean;
        require_uppercase: boolean;
    };
    standard_required_attributes?: string[];
    unauthenticated_identities_enabled?: boolean;
    user_pool_client_id: string;
    user_pool_id: string;
    user_verification_types?: string[];
    username_attributes?: string[];
}

Properties

authentication_flow_type? +
interface AmplifyOutputsAuthProperties {
    authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH";
    aws_region: string;
    groups?: Record<string, UserGroupPrecedence>[];
    identity_pool_id?: string;
    mfa_configuration?: string;
    mfa_methods?: string[];
    oauth?: {
        domain: string;
        identity_providers: string[];
        redirect_sign_in_uri: string[];
        redirect_sign_out_uri: string[];
        response_type: string;
        scopes: string[];
    };
    password_policy?: {
        min_length: number;
        require_lowercase: boolean;
        require_numbers: boolean;
        require_symbols: boolean;
        require_uppercase: boolean;
    };
    standard_required_attributes?: string[];
    unauthenticated_identities_enabled?: boolean;
    user_pool_client_id: string;
    user_pool_id: string;
    user_verification_types?: string[];
    username_attributes?: string[];
}

Properties

authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH"
aws_region: string
identity_pool_id?: string
mfa_configuration?: string
mfa_methods?: string[]
oauth?: {
    domain: string;
    identity_providers: string[];
    redirect_sign_in_uri: string[];
    redirect_sign_out_uri: string[];
    response_type: string;
    scopes: string[];
}

Type declaration

  • domain: string
  • identity_providers: string[]
  • redirect_sign_in_uri: string[]
  • redirect_sign_out_uri: string[]
  • response_type: string
  • scopes: string[]
password_policy?: {
    min_length: number;
    require_lowercase: boolean;
    require_numbers: boolean;
    require_symbols: boolean;
    require_uppercase: boolean;
}

Type declaration

  • min_length: number
  • require_lowercase: boolean
  • require_numbers: boolean
  • require_symbols: boolean
  • require_uppercase: boolean
standard_required_attributes?: string[]
unauthenticated_identities_enabled?: boolean
user_pool_client_id: string
user_pool_id: string
user_verification_types?: string[]
username_attributes?: string[]
\ No newline at end of file +

Properties

authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH"
aws_region: string
groups?: Record<string, UserGroupPrecedence>[]
identity_pool_id?: string
mfa_configuration?: string
mfa_methods?: string[]
oauth?: {
    domain: string;
    identity_providers: string[];
    redirect_sign_in_uri: string[];
    redirect_sign_out_uri: string[];
    response_type: string;
    scopes: string[];
}

Type declaration

  • domain: string
  • identity_providers: string[]
  • redirect_sign_in_uri: string[]
  • redirect_sign_out_uri: string[]
  • response_type: string
  • scopes: string[]
password_policy?: {
    min_length: number;
    require_lowercase: boolean;
    require_numbers: boolean;
    require_symbols: boolean;
    require_uppercase: boolean;
}

Type declaration

  • min_length: number
  • require_lowercase: boolean
  • require_numbers: boolean
  • require_symbols: boolean
  • require_uppercase: boolean
standard_required_attributes?: string[]
unauthenticated_identities_enabled?: boolean
user_pool_client_id: string
user_pool_id: string
user_verification_types?: string[]
username_attributes?: string[]
\ No newline at end of file diff --git a/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsStorageBucketProperties.html b/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsStorageBucketProperties.html index 2234ae583d3..b24d9601341 100644 --- a/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsStorageBucketProperties.html +++ b/docs/api/interfaces/_aws_amplify_adapter_nextjs.index._Reference_Types_.AmplifyOutputsStorageBucketProperties.html @@ -1,8 +1,10 @@ AmplifyOutputsStorageBucketProperties | Amplify JS API Documentation -
interface AmplifyOutputsStorageBucketProperties {
    aws_region: string;
    bucket_name: string;
    name: string;
}

Properties

aws_region +
interface AmplifyOutputsStorageBucketProperties {
    aws_region: string;
    bucket_name: string;
    name: string;
    paths?: Record<string, Record<string, undefined | string[]>>;
}

Properties

aws_region: string

Region for the bucket

bucket_name: string

Actual S3 bucket name given

name: string

Friendly bucket name provided in Amplify Outputs

-
\ No newline at end of file +
paths?: Record<string, Record<string, undefined | string[]>>

Paths to object with access permissions

+
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsAuthProperties.html b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsAuthProperties.html index ff2a1265fd0..e14cc2e901f 100644 --- a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsAuthProperties.html +++ b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsAuthProperties.html @@ -1,6 +1,7 @@ AmplifyOutputsAuthProperties | Amplify JS API Documentation -
interface AmplifyOutputsAuthProperties {
    authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH";
    aws_region: string;
    identity_pool_id?: string;
    mfa_configuration?: string;
    mfa_methods?: string[];
    oauth?: {
        domain: string;
        identity_providers: string[];
        redirect_sign_in_uri: string[];
        redirect_sign_out_uri: string[];
        response_type: string;
        scopes: string[];
    };
    password_policy?: {
        min_length: number;
        require_lowercase: boolean;
        require_numbers: boolean;
        require_symbols: boolean;
        require_uppercase: boolean;
    };
    standard_required_attributes?: string[];
    unauthenticated_identities_enabled?: boolean;
    user_pool_client_id: string;
    user_pool_id: string;
    user_verification_types?: string[];
    username_attributes?: string[];
}

Properties

authentication_flow_type? +
interface AmplifyOutputsAuthProperties {
    authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH";
    aws_region: string;
    groups?: Record<string, UserGroupPrecedence>[];
    identity_pool_id?: string;
    mfa_configuration?: string;
    mfa_methods?: string[];
    oauth?: {
        domain: string;
        identity_providers: string[];
        redirect_sign_in_uri: string[];
        redirect_sign_out_uri: string[];
        response_type: string;
        scopes: string[];
    };
    password_policy?: {
        min_length: number;
        require_lowercase: boolean;
        require_numbers: boolean;
        require_symbols: boolean;
        require_uppercase: boolean;
    };
    standard_required_attributes?: string[];
    unauthenticated_identities_enabled?: boolean;
    user_pool_client_id: string;
    user_pool_id: string;
    user_verification_types?: string[];
    username_attributes?: string[];
}

Properties

authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH"
aws_region: string
identity_pool_id?: string
mfa_configuration?: string
mfa_methods?: string[]
oauth?: {
    domain: string;
    identity_providers: string[];
    redirect_sign_in_uri: string[];
    redirect_sign_out_uri: string[];
    response_type: string;
    scopes: string[];
}

Type declaration

  • domain: string
  • identity_providers: string[]
  • redirect_sign_in_uri: string[]
  • redirect_sign_out_uri: string[]
  • response_type: string
  • scopes: string[]
password_policy?: {
    min_length: number;
    require_lowercase: boolean;
    require_numbers: boolean;
    require_symbols: boolean;
    require_uppercase: boolean;
}

Type declaration

  • min_length: number
  • require_lowercase: boolean
  • require_numbers: boolean
  • require_symbols: boolean
  • require_uppercase: boolean
standard_required_attributes?: string[]
unauthenticated_identities_enabled?: boolean
user_pool_client_id: string
user_pool_id: string
user_verification_types?: string[]
username_attributes?: string[]
\ No newline at end of file +

Properties

authentication_flow_type?: "USER_SRP_AUTH" | "CUSTOM_AUTH"
aws_region: string
groups?: Record<string, UserGroupPrecedence>[]
identity_pool_id?: string
mfa_configuration?: string
mfa_methods?: string[]
oauth?: {
    domain: string;
    identity_providers: string[];
    redirect_sign_in_uri: string[];
    redirect_sign_out_uri: string[];
    response_type: string;
    scopes: string[];
}

Type declaration

  • domain: string
  • identity_providers: string[]
  • redirect_sign_in_uri: string[]
  • redirect_sign_out_uri: string[]
  • response_type: string
  • scopes: string[]
password_policy?: {
    min_length: number;
    require_lowercase: boolean;
    require_numbers: boolean;
    require_symbols: boolean;
    require_uppercase: boolean;
}

Type declaration

  • min_length: number
  • require_lowercase: boolean
  • require_numbers: boolean
  • require_symbols: boolean
  • require_uppercase: boolean
standard_required_attributes?: string[]
unauthenticated_identities_enabled?: boolean
user_pool_client_id: string
user_pool_id: string
user_verification_types?: string[]
username_attributes?: string[]
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsStorageBucketProperties.html b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsStorageBucketProperties.html index b5cb87c8be1..0020a39f718 100644 --- a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsStorageBucketProperties.html +++ b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AmplifyOutputsStorageBucketProperties.html @@ -1,8 +1,10 @@ AmplifyOutputsStorageBucketProperties | Amplify JS API Documentation -
interface AmplifyOutputsStorageBucketProperties {
    aws_region: string;
    bucket_name: string;
    name: string;
}

Properties

aws_region +
interface AmplifyOutputsStorageBucketProperties {
    aws_region: string;
    bucket_name: string;
    name: string;
    paths?: Record<string, Record<string, undefined | string[]>>;
}

Properties

aws_region: string

Region for the bucket

bucket_name: string

Actual S3 bucket name given

name: string

Friendly bucket name provided in Amplify Outputs

-
\ No newline at end of file +
paths?: Record<string, Record<string, undefined | string[]>>

Paths to object with access permissions

+
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AuthIdentityPoolConfig.html b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AuthIdentityPoolConfig.html index 5d91b4f4b13..dbb905ec37d 100644 --- a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AuthIdentityPoolConfig.html +++ b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.AuthIdentityPoolConfig.html @@ -1,3 +1,3 @@ AuthIdentityPoolConfig | Amplify JS API Documentation -
interface AuthIdentityPoolConfig {
    Cognito: CognitoIdentityPoolConfig & {
        loginWith?: undefined;
        mfa?: undefined;
        passwordFormat?: undefined;
        signUpVerificationMethod?: undefined;
        userAttributes?: undefined;
        userPoolClientId?: undefined;
        userPoolEndpoint?: undefined;
        userPoolId?: undefined;
    };
}

Properties

Properties

Cognito: CognitoIdentityPoolConfig & {
    loginWith?: undefined;
    mfa?: undefined;
    passwordFormat?: undefined;
    signUpVerificationMethod?: undefined;
    userAttributes?: undefined;
    userPoolClientId?: undefined;
    userPoolEndpoint?: undefined;
    userPoolId?: undefined;
}

Type declaration

  • Optional loginWith?: undefined
  • Optional mfa?: undefined
  • Optional passwordFormat?: undefined
  • Optional signUpVerificationMethod?: undefined
  • Optional userAttributes?: undefined
  • Optional userPoolClientId?: undefined
  • Optional userPoolEndpoint?: undefined
  • Optional userPoolId?: undefined
\ No newline at end of file +
interface AuthIdentityPoolConfig {
    Cognito: CognitoIdentityPoolConfig & {
        groups?: undefined;
        loginWith?: undefined;
        mfa?: undefined;
        passwordFormat?: undefined;
        signUpVerificationMethod?: undefined;
        userAttributes?: undefined;
        userPoolClientId?: undefined;
        userPoolEndpoint?: undefined;
        userPoolId?: undefined;
    };
}

Properties

Properties

Cognito: CognitoIdentityPoolConfig & {
    groups?: undefined;
    loginWith?: undefined;
    mfa?: undefined;
    passwordFormat?: undefined;
    signUpVerificationMethod?: undefined;
    userAttributes?: undefined;
    userPoolClientId?: undefined;
    userPoolEndpoint?: undefined;
    userPoolId?: undefined;
}

Type declaration

  • Optional groups?: undefined
  • Optional loginWith?: undefined
  • Optional mfa?: undefined
  • Optional passwordFormat?: undefined
  • Optional signUpVerificationMethod?: undefined
  • Optional userAttributes?: undefined
  • Optional userPoolClientId?: undefined
  • Optional userPoolEndpoint?: undefined
  • Optional userPoolId?: undefined
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.CognitoUserPoolConfig.html b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.CognitoUserPoolConfig.html index 4b41daa5ad1..75f23f89f84 100644 --- a/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.CognitoUserPoolConfig.html +++ b/docs/api/interfaces/aws_amplify.adapter_core._Reference_Types_.CognitoUserPoolConfig.html @@ -1,5 +1,6 @@ CognitoUserPoolConfig | Amplify JS API Documentation -
interface CognitoUserPoolConfig {
    loginWith?: {
        email?: boolean;
        oauth?: OAuthConfig;
        phone?: boolean;
        username?: boolean;
    };
    mfa?: {
        smsEnabled?: boolean;
        status?: CognitoUserPoolConfigMfaStatus;
        totpEnabled?: boolean;
    };
    passwordFormat?: {
        minLength?: number;
        requireLowercase?: boolean;
        requireNumbers?: boolean;
        requireSpecialCharacters?: boolean;
        requireUppercase?: boolean;
    };
    signUpVerificationMethod?: "link" | "code";
    userAttributes?: Partial<Record<AuthStandardAttributeKey, {
        required: boolean;
    }>>;
    userPoolClientId: string;
    userPoolEndpoint?: string;
    userPoolId: string;
}

Properties

loginWith? +
interface CognitoUserPoolConfig {
    groups?: Record<string, UserGroupPrecedence>[];
    loginWith?: {
        email?: boolean;
        oauth?: OAuthConfig;
        phone?: boolean;
        username?: boolean;
    };
    mfa?: {
        smsEnabled?: boolean;
        status?: CognitoUserPoolConfigMfaStatus;
        totpEnabled?: boolean;
    };
    passwordFormat?: {
        minLength?: number;
        requireLowercase?: boolean;
        requireNumbers?: boolean;
        requireSpecialCharacters?: boolean;
        requireUppercase?: boolean;
    };
    signUpVerificationMethod?: "link" | "code";
    userAttributes?: Partial<Record<AuthStandardAttributeKey, {
        required: boolean;
    }>>;
    userPoolClientId: string;
    userPoolEndpoint?: string;
    userPoolId: string;
}

Properties

loginWith?: {
    email?: boolean;
    oauth?: OAuthConfig;
    phone?: boolean;
    username?: boolean;
}

Type declaration

  • Optional email?: boolean
  • Optional oauth?: OAuthConfig
  • Optional phone?: boolean
  • Optional username?: boolean
mfa?: {
    smsEnabled?: boolean;
    status?: CognitoUserPoolConfigMfaStatus;
    totpEnabled?: boolean;
}

Type declaration

passwordFormat?: {
    minLength?: number;
    requireLowercase?: boolean;
    requireNumbers?: boolean;
    requireSpecialCharacters?: boolean;
    requireUppercase?: boolean;
}

Type declaration

  • Optional minLength?: number
  • Optional requireLowercase?: boolean
  • Optional requireNumbers?: boolean
  • Optional requireSpecialCharacters?: boolean
  • Optional requireUppercase?: boolean
signUpVerificationMethod?: "link" | "code"
userAttributes?: Partial<Record<AuthStandardAttributeKey, {
    required: boolean;
}>>
userPoolClientId: string
userPoolEndpoint?: string
userPoolId: string
\ No newline at end of file +

Properties

groups?: Record<string, UserGroupPrecedence>[]
loginWith?: {
    email?: boolean;
    oauth?: OAuthConfig;
    phone?: boolean;
    username?: boolean;
}

Type declaration

  • Optional email?: boolean
  • Optional oauth?: OAuthConfig
  • Optional phone?: boolean
  • Optional username?: boolean
mfa?: {
    smsEnabled?: boolean;
    status?: CognitoUserPoolConfigMfaStatus;
    totpEnabled?: boolean;
}

Type declaration

passwordFormat?: {
    minLength?: number;
    requireLowercase?: boolean;
    requireNumbers?: boolean;
    requireSpecialCharacters?: boolean;
    requireUppercase?: boolean;
}

Type declaration

  • Optional minLength?: number
  • Optional requireLowercase?: boolean
  • Optional requireNumbers?: boolean
  • Optional requireSpecialCharacters?: boolean
  • Optional requireUppercase?: boolean
signUpVerificationMethod?: "link" | "code"
userAttributes?: Partial<Record<AuthStandardAttributeKey, {
    required: boolean;
}>>
userPoolClientId: string
userPoolEndpoint?: string
userPoolId: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth.AuthWebAuthnCredential.html b/docs/api/interfaces/aws_amplify.auth.AuthWebAuthnCredential.html new file mode 100644 index 00000000000..20df25103f6 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth.AuthWebAuthnCredential.html @@ -0,0 +1,9 @@ +AuthWebAuthnCredential | Amplify JS API Documentation +

Shape of a WebAuthn credential

+
interface AuthWebAuthnCredential {
    authenticatorAttachment?: string;
    authenticatorTransports: undefined | string[];
    createdAt: undefined | Date;
    credentialId: undefined | string;
    friendlyCredentialName: undefined | string;
    relyingPartyId: undefined | string;
}

Properties

authenticatorAttachment?: string
authenticatorTransports: undefined | string[]
createdAt: undefined | Date
credentialId: undefined | string
friendlyCredentialName: undefined | string
relyingPartyId: undefined | string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth.DeleteWebAuthnCredentialInput.html b/docs/api/interfaces/aws_amplify.auth.DeleteWebAuthnCredentialInput.html new file mode 100644 index 00000000000..4def10160b9 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth.DeleteWebAuthnCredentialInput.html @@ -0,0 +1,3 @@ +DeleteWebAuthnCredentialInput | Amplify JS API Documentation +
interface DeleteWebAuthnCredentialInput {
    credentialId: string;
}

Properties

Properties

credentialId: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsInput.html b/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsInput.html new file mode 100644 index 00000000000..804f165c7c2 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsInput.html @@ -0,0 +1,5 @@ +ListWebAuthnCredentialsInput | Amplify JS API Documentation +

Input type for Cognito listWebAuthnCredentials API.

+
interface ListWebAuthnCredentialsInput {
    nextToken?: string;
    pageSize?: number;
}

Properties

Properties

nextToken?: string
pageSize?: number
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsOutput.html b/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsOutput.html new file mode 100644 index 00000000000..7ae4ea9c784 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth.ListWebAuthnCredentialsOutput.html @@ -0,0 +1,5 @@ +ListWebAuthnCredentialsOutput | Amplify JS API Documentation +

Output type for Cognito listWebAuthnCredentials API.

+
interface ListWebAuthnCredentialsOutput {
    credentials: AuthWebAuthnCredential[];
    nextToken?: string;
}

Properties

Properties

credentials: AuthWebAuthnCredential[]
nextToken?: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth._Reference_Types_.AuthSignUpInput.html b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.AuthSignUpInput.html index 17f83b4e756..4fef9169615 100644 --- a/docs/api/interfaces/aws_amplify.auth._Reference_Types_.AuthSignUpInput.html +++ b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.AuthSignUpInput.html @@ -1,9 +1,9 @@ AuthSignUpInput | Amplify JS API Documentation

The parameters for constructing a Sign Up input.

Param: username

a standard username, potentially an email/phone number

-

Param: password

the user's password

+

Param: password

the user's password, may be required depending on your Cognito User Pool configuration

Param: options

optional parameters for the Sign Up process, including user attributes

-
interface AuthSignUpInput<ServiceOptions> {
    options?: ServiceOptions;
    password: string;
    username: string;
}

Type Parameters

Properties

interface AuthSignUpInput<ServiceOptions> {
    options?: ServiceOptions;
    password?: string;
    username: string;
}

Type Parameters

Properties

options?: ServiceOptions
password: string
username: string
\ No newline at end of file +

Properties

options?: ServiceOptions
password?: string
username: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ConfirmSignInWithPassword.html b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ConfirmSignInWithPassword.html new file mode 100644 index 00000000000..bf7442239af --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ConfirmSignInWithPassword.html @@ -0,0 +1,3 @@ +ConfirmSignInWithPassword | Amplify JS API Documentation +
interface ConfirmSignInWithPassword {
    signInStep: "CONFIRM_SIGN_IN_WITH_PASSWORD";
}

Properties

Properties

signInStep: "CONFIRM_SIGN_IN_WITH_PASSWORD"
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ContinueSignInWithFirstFactorSelection.html b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ContinueSignInWithFirstFactorSelection.html new file mode 100644 index 00000000000..c19642a3307 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.auth._Reference_Types_.ContinueSignInWithFirstFactorSelection.html @@ -0,0 +1,4 @@ +ContinueSignInWithFirstFactorSelection | Amplify JS API Documentation +
interface ContinueSignInWithFirstFactorSelection {
    availableChallenges?: ChallengeName[];
    signInStep: "CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION";
}

Properties

availableChallenges?: ChallengeName[]
signInStep: "CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION"
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.index._Reference_Types_.BucketInfo.html b/docs/api/interfaces/aws_amplify.index._Reference_Types_.BucketInfo.html index 21824ebd401..2b8c1f713dc 100644 --- a/docs/api/interfaces/aws_amplify.index._Reference_Types_.BucketInfo.html +++ b/docs/api/interfaces/aws_amplify.index._Reference_Types_.BucketInfo.html @@ -1,7 +1,9 @@ BucketInfo | Amplify JS API Documentation

Information on bucket used to store files/objects

-
interface BucketInfo {
    bucketName: string;
    region: string;
}

Properties

interface BucketInfo {
    bucketName: string;
    paths?: Record<string, Record<string, undefined | string[]>>;
    region: string;
}

Properties

bucketName: string

Actual bucket name

+
paths?: Record<string, Record<string, undefined | string[]>>

Paths to object with access permissions

region: string

Region of the bucket

-
\ No newline at end of file +
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.storage._Reference_Types_.BucketInfo.html b/docs/api/interfaces/aws_amplify.storage._Reference_Types_.BucketInfo.html index bf2f0b680c7..36ca3eb20a4 100644 --- a/docs/api/interfaces/aws_amplify.storage._Reference_Types_.BucketInfo.html +++ b/docs/api/interfaces/aws_amplify.storage._Reference_Types_.BucketInfo.html @@ -1,4 +1,5 @@ BucketInfo | Amplify JS API Documentation -
interface BucketInfo {
    bucketName: string;
    region: string;
}

Properties

bucketName +
interface BucketInfo {
    bucketName: string;
    paths?: Record<string, Record<string, undefined | string[]>>;
    region: string;
}

Properties

Properties

bucketName: string
region: string
\ No newline at end of file +

Properties

bucketName: string
paths?: Record<string, Record<string, undefined | string[]>>
region: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.storage._Reference_Types_.CommonOptions.html b/docs/api/interfaces/aws_amplify.storage._Reference_Types_.CommonOptions.html index 46a05fb9012..ad3c17e1a08 100644 --- a/docs/api/interfaces/aws_amplify.storage._Reference_Types_.CommonOptions.html +++ b/docs/api/interfaces/aws_amplify.storage._Reference_Types_.CommonOptions.html @@ -1,7 +1,9 @@ CommonOptions | Amplify JS API Documentation -
interface CommonOptions {
    bucket?: StorageBucket;
    useAccelerateEndpoint?: boolean;
}

Properties

bucket? +
interface CommonOptions {
    bucket?: StorageBucket;
    expectedBucketOwner?: string;
    useAccelerateEndpoint?: boolean;
}

Properties

bucket?: StorageBucket
useAccelerateEndpoint?: boolean

Whether to use accelerate endpoint.

+

Properties

bucket?: StorageBucket
expectedBucketOwner?: string

The expected owner of the target bucket.

+
useAccelerateEndpoint?: boolean

Whether to use accelerate endpoint.

Default

false
 
-
\ No newline at end of file +
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathDestinationOptions.html b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathDestinationOptions.html new file mode 100644 index 00000000000..0487152e0bf --- /dev/null +++ b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathDestinationOptions.html @@ -0,0 +1,4 @@ +CopyWithPathDestinationOptions | Amplify JS API Documentation +
interface CopyWithPathDestinationOptions {
    bucket?: StorageBucket;
    expectedBucketOwner?: string;
}

Properties

bucket?: StorageBucket
expectedBucketOwner?: string
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathSourceOptions.html b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathSourceOptions.html new file mode 100644 index 00000000000..4b780960ab9 --- /dev/null +++ b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.CopyWithPathSourceOptions.html @@ -0,0 +1,6 @@ +CopyWithPathSourceOptions | Amplify JS API Documentation +
interface CopyWithPathSourceOptions {
    bucket?: StorageBucket;
    eTag?: string;
    expectedBucketOwner?: string;
    notModifiedSince?: Date;
}

Properties

bucket?: StorageBucket
eTag?: string
expectedBucketOwner?: string
notModifiedSince?: Date
\ No newline at end of file diff --git a/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.StorageCopyInputWithPath.html b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.StorageCopyInputWithPath.html index 84b3c105173..940b367c471 100644 --- a/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.StorageCopyInputWithPath.html +++ b/docs/api/interfaces/aws_amplify.storage_server._Reference_Types_.StorageCopyInputWithPath.html @@ -1,4 +1,4 @@ StorageCopyInputWithPath | Amplify JS API Documentation -
interface StorageCopyInputWithPath {
    destination: StorageOperationInputWithPath & {
        bucket?: StorageBucket;
    };
    source: StorageOperationInputWithPath & {
        bucket?: StorageBucket;
    };
}

Properties

destination +

Properties

Properties

destination: StorageOperationInputWithPath & {
    bucket?: StorageBucket;
}

Type declaration

source: StorageOperationInputWithPath & {
    bucket?: StorageBucket;
}

Type declaration

\ No newline at end of file +

Properties

\ No newline at end of file diff --git a/docs/api/modules/_aws_amplify_adapter_nextjs.api._Reference_Types_.html b/docs/api/modules/_aws_amplify_adapter_nextjs.api._Reference_Types_.html index bf7db85458f..50f4736e2b3 100644 --- a/docs/api/modules/_aws_amplify_adapter_nextjs.api._Reference_Types_.html +++ b/docs/api/modules/_aws_amplify_adapter_nextjs.api._Reference_Types_.html @@ -203,6 +203,7 @@ TypeSystemExtensionNode UnionKeys UnknownGraphQLResponse +UserGroupPrecedence V6ClientSSRCookies V6ClientSSRRequest ValueNode diff --git a/docs/api/modules/_aws_amplify_adapter_nextjs.html b/docs/api/modules/_aws_amplify_adapter_nextjs.html index 1bd10844925..b9558791e44 100644 --- a/docs/api/modules/_aws_amplify_adapter_nextjs.html +++ b/docs/api/modules/_aws_amplify_adapter_nextjs.html @@ -1,5 +1,5 @@ -@aws-amplify/adapter-nextjs - v1.2.28 | Amplify JS API Documentation -

Module @aws-amplify/adapter-nextjs - v1.2.28

This package contains the AWS Amplify Next.js Adapter. For more information on using Next.js in your application please reference the Amplify Dev Center.

+@aws-amplify/adapter-nextjs - v1.2.30 | Amplify JS API Documentation +

Module @aws-amplify/adapter-nextjs - v1.2.30

This package contains the AWS Amplify Next.js Adapter. For more information on using Next.js in your application please reference the Amplify Dev Center.

Index

Modules

api index utils diff --git a/docs/api/modules/_aws_amplify_adapter_nextjs.index._Reference_Types_.html b/docs/api/modules/_aws_amplify_adapter_nextjs.index._Reference_Types_.html index 2c44be571a4..e17e9cd99d8 100644 --- a/docs/api/modules/_aws_amplify_adapter_nextjs.index._Reference_Types_.html +++ b/docs/api/modules/_aws_amplify_adapter_nextjs.index._Reference_Types_.html @@ -163,6 +163,7 @@ SocketConnectOpts SocketReadyState TypedArray +UserGroupPrecedence XMLHttpRequestBodyInit

Variables

Error Object diff --git a/docs/api/modules/_aws_amplify_datastore_storage_adapter.html b/docs/api/modules/_aws_amplify_datastore_storage_adapter.html index 41f76bb6b1c..b6611505bb0 100644 --- a/docs/api/modules/_aws_amplify_datastore_storage_adapter.html +++ b/docs/api/modules/_aws_amplify_datastore_storage_adapter.html @@ -1,5 +1,5 @@ -@aws-amplify/datastore-storage-adapter - v2.1.60 | Amplify JS API Documentation -

Module @aws-amplify/datastore-storage-adapter - v2.1.60

This package contains the AWS Amplify DataStore storage adapter. For more information on using the DataStore storage adapter in your application please reference the Amplify Dev Center.

+@aws-amplify/datastore-storage-adapter - v2.1.62 | Amplify JS API Documentation +

Module @aws-amplify/datastore-storage-adapter - v2.1.62

This package contains the AWS Amplify DataStore storage adapter. For more information on using the DataStore storage adapter in your application please reference the Amplify Dev Center.

Index

Modules

ExpoSQLiteAdapter/ExpoSQLiteAdapter SQLiteAdapter/SQLiteAdapter index diff --git a/docs/api/modules/_aws_amplify_geo.html b/docs/api/modules/_aws_amplify_geo.html index 72c076940ee..51a7310a873 100644 --- a/docs/api/modules/_aws_amplify_geo.html +++ b/docs/api/modules/_aws_amplify_geo.html @@ -1,5 +1,5 @@ -@aws-amplify/geo - v3.0.58 | Amplify JS API Documentation -

Module @aws-amplify/geo - v3.0.58

This package contains the AWS Amplify Geo category. For more information on using Geo in your application please reference the Amplify Dev Center.

+@aws-amplify/geo - v3.0.60 | Amplify JS API Documentation +

Module @aws-amplify/geo - v3.0.60

This package contains the AWS Amplify Geo category. For more information on using Geo in your application please reference the Amplify Dev Center.

Index

Modules

\ No newline at end of file diff --git a/docs/api/modules/_aws_amplify_interactions.html b/docs/api/modules/_aws_amplify_interactions.html index 8fe216185a7..7667cf6fdac 100644 --- a/docs/api/modules/_aws_amplify_interactions.html +++ b/docs/api/modules/_aws_amplify_interactions.html @@ -1,5 +1,5 @@ -@aws-amplify/interactions - v6.0.57 | Amplify JS API Documentation -

Module @aws-amplify/interactions - v6.0.57

This package contains the AWS Amplify Interactions category. For more information on using Interactions in your application please reference the Amplify Dev Center.

+@aws-amplify/interactions - v6.1.1 | Amplify JS API Documentation +

Module @aws-amplify/interactions - v6.1.1

This package contains the AWS Amplify Interactions category. For more information on using Interactions in your application please reference the Amplify Dev Center.

Index

Modules

index lex-v1 lex-v2 diff --git a/docs/api/modules/_aws_amplify_predictions.html b/docs/api/modules/_aws_amplify_predictions.html index f80a61b8614..8b557ee5ee2 100644 --- a/docs/api/modules/_aws_amplify_predictions.html +++ b/docs/api/modules/_aws_amplify_predictions.html @@ -1,5 +1,5 @@ -@aws-amplify/predictions - v6.1.33 | Amplify JS API Documentation -

Module @aws-amplify/predictions - v6.1.33

This package contains the AWS Amplify Predictions category. For more information on using Predictions in your application please reference the Amplify Dev Center.

+@aws-amplify/predictions - v6.1.35 | Amplify JS API Documentation +

Module @aws-amplify/predictions - v6.1.35

This package contains the AWS Amplify Predictions category. For more information on using Predictions in your application please reference the Amplify Dev Center.

Index

Modules

Interfaces

IdentifyEntitiesInput IdentifyEntitiesOutput diff --git a/docs/api/modules/_aws_amplify_pubsub.html b/docs/api/modules/_aws_amplify_pubsub.html index c184e6047d9..75a746b2269 100644 --- a/docs/api/modules/_aws_amplify_pubsub.html +++ b/docs/api/modules/_aws_amplify_pubsub.html @@ -1,5 +1,5 @@ -@aws-amplify/pubsub - v6.1.33 | Amplify JS API Documentation -

Module @aws-amplify/pubsub - v6.1.33

This package contains the AWS Amplify PubSub category. For more information on using PubSub in your application please reference the Amplify Dev Center.

+@aws-amplify/pubsub - v6.1.35 | Amplify JS API Documentation +

Module @aws-amplify/pubsub - v6.1.35

This package contains the AWS Amplify PubSub category. For more information on using PubSub in your application please reference the Amplify Dev Center.

Index

Modules

clients/iot clients/mqtt index diff --git a/docs/api/modules/aws_amplify.adapter_core._Reference_Types_.html b/docs/api/modules/aws_amplify.adapter_core._Reference_Types_.html index 0236c427ada..c890a83ac19 100644 --- a/docs/api/modules/aws_amplify.adapter_core._Reference_Types_.html +++ b/docs/api/modules/aws_amplify.adapter_core._Reference_Types_.html @@ -39,5 +39,7 @@ StrictUnion StrictUnionHelper UnionKeys +UserGroupPrecedence +UserGroupPrecedence ValidatorFunction
\ No newline at end of file diff --git a/docs/api/modules/aws_amplify.auth._Reference_Types_.html b/docs/api/modules/aws_amplify.auth._Reference_Types_.html index e10a5029571..39536049fcb 100644 --- a/docs/api/modules/aws_amplify.auth._Reference_Types_.html +++ b/docs/api/modules/aws_amplify.auth._Reference_Types_.html @@ -34,11 +34,13 @@ ConfirmSignInWithCustomChallenge ConfirmSignInWithEmailCode ConfirmSignInWithNewPasswordRequired +ConfirmSignInWithPassword ConfirmSignInWithSMSCode ConfirmSignInWithTOTPCode ConfirmSignUpSignUpStep ConfirmSignUpStep ContinueSignInWithEmailSetup +ContinueSignInWithFirstFactorSelection ContinueSignInWithMFASelection ContinueSignInWithMFASetupSelection ContinueSignInWithTOTPSetup @@ -54,6 +56,7 @@ AuthAllowedMFATypes AuthAnyAttribute AuthDeliveryMedium +AuthFactorType AuthFlowType AuthFlowType AuthMFAType @@ -66,6 +69,7 @@ AuthUpdateUserAttributesOutput AuthUserAttributeKey AuthUserAttributes +ChallengeName ClientMetadata ConfirmResetPasswordOptions ConfirmSignInOptions diff --git a/docs/api/modules/aws_amplify.auth.html b/docs/api/modules/aws_amplify.auth.html index f27f7ba85c4..998e07ebaec 100644 --- a/docs/api/modules/aws_amplify.auth.html +++ b/docs/api/modules/aws_amplify.auth.html @@ -4,11 +4,15 @@

Interfaces

Type Aliases

Functions

Functions

associateWebAuthnCredential +autoSignIn confirmResetPassword confirmSignIn confirmSignUp @@ -53,12 +58,14 @@ decodeJWT deleteUser deleteUserAttributes +deleteWebAuthnCredential fetchAuthSession fetchDevices fetchMFAPreference fetchUserAttributes forgetDevice getCurrentUser +listWebAuthnCredentials rememberDevice resendSignUpCode resetPassword diff --git a/docs/api/modules/aws_amplify.html b/docs/api/modules/aws_amplify.html index d9f234194a5..3f70a29360b 100644 --- a/docs/api/modules/aws_amplify.html +++ b/docs/api/modules/aws_amplify.html @@ -1,5 +1,5 @@ -aws-amplify - v6.8.2 | Amplify JS API Documentation -

Module aws-amplify - v6.8.2

AWS Amplify Package - aws-amplify

AWS Amplify is a JavaScript library for frontend and mobile developers building cloud-enabled applications. The library is a declarative interface across different categories of operations in order to make common tasks easier to add into your application. The default implementation works with Amazon Web Services (AWS) resources but is designed to be open and pluggable for usage with other cloud services that wish to provide an implementation or custom backends.

+aws-amplify - v6.10.0 | Amplify JS API Documentation +

Module aws-amplify - v6.10.0

AWS Amplify Package - aws-amplify

AWS Amplify is a JavaScript library for frontend and mobile developers building cloud-enabled applications. The library is a declarative interface across different categories of operations in order to make common tasks easier to add into your application. The default implementation works with Amazon Web Services (AWS) resources but is designed to be open and pluggable for usage with other cloud services that wish to provide an implementation or custom backends.

Documentation is available here.

Index

Modules

adapter-core analytics diff --git a/docs/api/modules/aws_amplify.storage._Reference_Types_.html b/docs/api/modules/aws_amplify.storage._Reference_Types_.html index 9d724ffc9bf..da46b250744 100644 --- a/docs/api/modules/aws_amplify.storage._Reference_Types_.html +++ b/docs/api/modules/aws_amplify.storage._Reference_Types_.html @@ -62,6 +62,7 @@ StorageUploadDataInputWithPath StorageUploadDataPayload TransferTaskState +UploadDataChecksumAlgorithm UploadDataOptions UploadDataWithKeyOptions UploadDataWithPathOptions diff --git a/docs/api/modules/aws_amplify.storage.html b/docs/api/modules/aws_amplify.storage.html index fd1f2d348c6..823f6861aae 100644 --- a/docs/api/modules/aws_amplify.storage.html +++ b/docs/api/modules/aws_amplify.storage.html @@ -34,6 +34,7 @@ UploadDataOutput UploadDataWithPathInput UploadDataWithPathOutput +

Variables

Functions

copy downloadData getProperties diff --git a/docs/api/modules/aws_amplify.storage_s3.html b/docs/api/modules/aws_amplify.storage_s3.html index be6137c0ae3..bf1027451b5 100644 --- a/docs/api/modules/aws_amplify.storage_s3.html +++ b/docs/api/modules/aws_amplify.storage_s3.html @@ -3,6 +3,7 @@ CopyOutput CopyWithPathInput CopyWithPathOutput +DEFAULT_PART_SIZE DownloadDataInput DownloadDataOutput DownloadDataWithPathInput @@ -38,4 +39,4 @@ list remove uploadData -

References

Re-exports CopyInput
Re-exports CopyOutput
Re-exports CopyWithPathInput
Re-exports CopyWithPathOutput
Re-exports DownloadDataInput
Re-exports DownloadDataOutput
Re-exports DownloadDataWithPathInput
Re-exports DownloadDataWithPathOutput
Re-exports GetPropertiesInput
Re-exports GetPropertiesOutput
Re-exports GetPropertiesWithPathInput
Re-exports GetPropertiesWithPathOutput
Re-exports GetUrlInput
Re-exports GetUrlOutput
Re-exports GetUrlWithPathInput
Re-exports GetUrlWithPathOutput
Re-exports ListAllInput
Re-exports ListAllOutput
Re-exports ListAllWithPathInput
Re-exports ListAllWithPathOutput
Re-exports ListPaginateInput
Re-exports ListPaginateOutput
Re-exports ListPaginateWithPathInput
Re-exports ListPaginateWithPathOutput
Re-exports RemoveInput
Re-exports RemoveOutput
Re-exports RemoveWithPathInput
Re-exports RemoveWithPathOutput
Re-exports UploadDataInput
Re-exports UploadDataOutput
Re-exports UploadDataWithPathInput
Re-exports UploadDataWithPathOutput
Re-exports copy
Re-exports downloadData
Re-exports getProperties
Re-exports getUrl
Re-exports list
Re-exports remove
Re-exports uploadData
\ No newline at end of file +

References

Re-exports CopyInput
Re-exports CopyOutput
Re-exports CopyWithPathInput
Re-exports CopyWithPathOutput
Re-exports DEFAULT_PART_SIZE
Re-exports DownloadDataInput
Re-exports DownloadDataOutput
Re-exports DownloadDataWithPathInput
Re-exports DownloadDataWithPathOutput
Re-exports GetPropertiesInput
Re-exports GetPropertiesOutput
Re-exports GetPropertiesWithPathInput
Re-exports GetPropertiesWithPathOutput
Re-exports GetUrlInput
Re-exports GetUrlOutput
Re-exports GetUrlWithPathInput
Re-exports GetUrlWithPathOutput
Re-exports ListAllInput
Re-exports ListAllOutput
Re-exports ListAllWithPathInput
Re-exports ListAllWithPathOutput
Re-exports ListPaginateInput
Re-exports ListPaginateOutput
Re-exports ListPaginateWithPathInput
Re-exports ListPaginateWithPathOutput
Re-exports RemoveInput
Re-exports RemoveOutput
Re-exports RemoveWithPathInput
Re-exports RemoveWithPathOutput
Re-exports UploadDataInput
Re-exports UploadDataOutput
Re-exports UploadDataWithPathInput
Re-exports UploadDataWithPathOutput
Re-exports copy
Re-exports downloadData
Re-exports getProperties
Re-exports getUrl
Re-exports list
Re-exports remove
Re-exports uploadData
\ No newline at end of file diff --git a/docs/api/modules/aws_amplify.storage_server._Reference_Types_.html b/docs/api/modules/aws_amplify.storage_server._Reference_Types_.html index 1c96022f903..0f9dbf4bf0c 100644 --- a/docs/api/modules/aws_amplify.storage_server._Reference_Types_.html +++ b/docs/api/modules/aws_amplify.storage_server._Reference_Types_.html @@ -1,3 +1,5 @@ <Reference Types> | Amplify JS API Documentation -

Index

Interfaces

StorageCopyInputWithPath +
\ No newline at end of file diff --git a/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.OAuthScope.html b/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.OAuthScope.html index 3cb504d44d6..7fa435e67d2 100644 --- a/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.OAuthScope.html +++ b/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.OAuthScope.html @@ -1,2 +1,2 @@ OAuthScope | Amplify JS API Documentation -
OAuthScope: "email" | "openid" | "phone" | "email" | "profile" | "aws.cognito.signin.user.admin" | CustomScope
\ No newline at end of file +
OAuthScope: "email" | "openid" | "phone" | "profile" | "aws.cognito.signin.user.admin" | CustomScope
\ No newline at end of file diff --git a/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.UserGroupPrecedence.html b/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.UserGroupPrecedence.html new file mode 100644 index 00000000000..0e131e0aac0 --- /dev/null +++ b/docs/api/types/_aws_amplify_adapter_nextjs.api._Reference_Types_.UserGroupPrecedence.html @@ -0,0 +1,2 @@ +UserGroupPrecedence | Amplify JS API Documentation +
UserGroupPrecedence: Record<string, number>
\ No newline at end of file diff --git a/docs/api/types/_aws_amplify_adapter_nextjs.index._Reference_Types_.UserGroupPrecedence.html b/docs/api/types/_aws_amplify_adapter_nextjs.index._Reference_Types_.UserGroupPrecedence.html new file mode 100644 index 00000000000..54c51568219 --- /dev/null +++ b/docs/api/types/_aws_amplify_adapter_nextjs.index._Reference_Types_.UserGroupPrecedence.html @@ -0,0 +1,2 @@ +UserGroupPrecedence | Amplify JS API Documentation +
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.adapter_core._Reference_Types_.OAuthScope.html b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.OAuthScope.html index 088d749dcb2..74aab823dad 100644 --- a/docs/api/types/aws_amplify.adapter_core._Reference_Types_.OAuthScope.html +++ b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.OAuthScope.html @@ -1,2 +1,2 @@ OAuthScope | Amplify JS API Documentation -
OAuthScope: "email" | "openid" | "phone" | "email" | "profile" | "aws.cognito.signin.user.admin" | CustomScope
\ No newline at end of file +
OAuthScope: "email" | "openid" | "phone" | "profile" | "aws.cognito.signin.user.admin" | CustomScope
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence-1.html b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence-1.html new file mode 100644 index 00000000000..324a15855d6 --- /dev/null +++ b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence-1.html @@ -0,0 +1,2 @@ +UserGroupPrecedence | Amplify JS API Documentation +
UserGroupPrecedence: Record<string, number>
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence.html b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence.html new file mode 100644 index 00000000000..4410bfe4878 --- /dev/null +++ b/docs/api/types/aws_amplify.adapter_core._Reference_Types_.UserGroupPrecedence.html @@ -0,0 +1,2 @@ +UserGroupPrecedence | Amplify JS API Documentation +
UserGroupPrecedence: Record<string, number>
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFactorType.html b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFactorType.html new file mode 100644 index 00000000000..bedc437ff96 --- /dev/null +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFactorType.html @@ -0,0 +1,3 @@ +AuthFactorType | Amplify JS API Documentation +
AuthFactorType: "WEB_AUTHN" | "EMAIL_OTP" | "SMS_OTP" | "PASSWORD" | "PASSWORD_SRP"

Allowed values for preferredChallenge

+
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType-1.html b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType-1.html index 354c67e77d9..fde987bef5c 100644 --- a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType-1.html +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType-1.html @@ -1,2 +1,2 @@ AuthFlowType | Amplify JS API Documentation -
AuthFlowType: "USER_SRP_AUTH" | "CUSTOM_WITH_SRP" | "CUSTOM_WITHOUT_SRP" | "USER_PASSWORD_AUTH"

Deprecated

\ No newline at end of file +
AuthFlowType: "USER_AUTH" | "USER_SRP_AUTH" | "CUSTOM_WITH_SRP" | "CUSTOM_WITHOUT_SRP" | "USER_PASSWORD_AUTH"

Deprecated

\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType.html b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType.html index e4ee5706210..26c9458d566 100644 --- a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType.html +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthFlowType.html @@ -1,3 +1,5 @@ AuthFlowType | Amplify JS API Documentation -
AuthFlowType: "USER_SRP_AUTH" | "CUSTOM_WITH_SRP" | "CUSTOM_WITHOUT_SRP" | "USER_PASSWORD_AUTH"

Cognito supported AuthFlowTypes that may be passed as part of the Sign In request.

+
AuthFlowType: "USER_AUTH" | "USER_SRP_AUTH" | "CUSTOM_WITH_SRP" | "CUSTOM_WITHOUT_SRP" | "USER_PASSWORD_AUTH"

Cognito supported AuthFlowTypes that may be passed as part of the Sign In request. +USER_AUTH is a superset that can handle both USER_SRP_AUTH and USER_PASSWORD_AUTH, +providing flexibility for future authentication methods.

\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthNextSignInStep.html b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthNextSignInStep.html index 0a2a6ab5fd3..ae6474c4fea 100644 --- a/docs/api/types/aws_amplify.auth._Reference_Types_.AuthNextSignInStep.html +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.AuthNextSignInStep.html @@ -1,2 +1,2 @@ AuthNextSignInStep | Amplify JS API Documentation -
\ No newline at end of file +
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.ChallengeName.html b/docs/api/types/aws_amplify.auth._Reference_Types_.ChallengeName.html new file mode 100644 index 00000000000..a338009c2e9 --- /dev/null +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.ChallengeName.html @@ -0,0 +1,2 @@ +ChallengeName | Amplify JS API Documentation +
ChallengeName: "SMS_MFA" | "SMS_OTP" | "SOFTWARE_TOKEN_MFA" | "EMAIL_OTP" | "SELECT_MFA_TYPE" | "SELECT_CHALLENGE" | "MFA_SETUP" | "PASSWORD" | "PASSWORD_SRP" | "PASSWORD_VERIFIER" | "CUSTOM_CHALLENGE" | "DEVICE_SRP_AUTH" | "DEVICE_PASSWORD_VERIFIER" | "ADMIN_NO_SRP_AUTH" | "NEW_PASSWORD_REQUIRED" | "WEB_AUTHN"
\ No newline at end of file diff --git a/docs/api/types/aws_amplify.auth._Reference_Types_.SignInOptions.html b/docs/api/types/aws_amplify.auth._Reference_Types_.SignInOptions.html index d7fbedd7a7c..cee1725ce68 100644 --- a/docs/api/types/aws_amplify.auth._Reference_Types_.SignInOptions.html +++ b/docs/api/types/aws_amplify.auth._Reference_Types_.SignInOptions.html @@ -1,3 +1,3 @@ SignInOptions | Amplify JS API Documentation -
SignInOptions: AuthServiceOptions & {
    authFlowType?: AuthFlowType;
    clientMetadata?: ClientMetadata;
}

Options specific to Cognito Sign In.

-

Type declaration

\ No newline at end of file +
SignInOptions: AuthServiceOptions & {
    authFlowType?: AuthFlowType;
    clientMetadata?: ClientMetadata;
    preferredChallenge?: AuthFactorType;
}

Options specific to Cognito Sign In.

+

Type declaration

\ No newline at end of file diff --git a/docs/api/types/aws_amplify.storage._Reference_Types_.CopyDestinationWithKeyOptions.html b/docs/api/types/aws_amplify.storage._Reference_Types_.CopyDestinationWithKeyOptions.html index 21b3a7d841c..64de127ae5a 100644 --- a/docs/api/types/aws_amplify.storage._Reference_Types_.CopyDestinationWithKeyOptions.html +++ b/docs/api/types/aws_amplify.storage._Reference_Types_.CopyDestinationWithKeyOptions.html @@ -1,4 +1,4 @@ CopyDestinationWithKeyOptions | Amplify JS API Documentation -
CopyDestinationWithKeyOptions: WriteOptions & {
    bucket?: StorageBucket;
    key: string;
}

Type declaration

  • Optional bucket?: StorageBucket
  • key: string

    Deprecated

    This may be removed in the next major version.

    +
    CopyDestinationWithKeyOptions: WriteOptions & {
        bucket?: StorageBucket;
        expectedBucketOwner?: string;
        key: string;
    }

    Type declaration

    • Optional bucket?: StorageBucket
    • Optional expectedBucketOwner?: string
    • key: string

      Deprecated

      This may be removed in the next major version.

    Deprecated

    This may be removed in the next major version.

    \ No newline at end of file diff --git a/docs/api/types/aws_amplify.storage._Reference_Types_.CopySourceWithKeyOptions.html b/docs/api/types/aws_amplify.storage._Reference_Types_.CopySourceWithKeyOptions.html index 3adcca522fe..284a6ec682a 100644 --- a/docs/api/types/aws_amplify.storage._Reference_Types_.CopySourceWithKeyOptions.html +++ b/docs/api/types/aws_amplify.storage._Reference_Types_.CopySourceWithKeyOptions.html @@ -1,4 +1,4 @@ CopySourceWithKeyOptions | Amplify JS API Documentation -
    CopySourceWithKeyOptions: ReadOptions & {
        bucket?: StorageBucket;
        key: string;
    }

    Type declaration

    • Optional bucket?: StorageBucket
    • key: string

      Deprecated

      This may be removed in the next major version.

      -

    Deprecated

    This may be removed in the next major version.

    +
    CopySourceWithKeyOptions: ReadOptions & {
        bucket?: StorageBucket;
        eTag?: string;
        expectedBucketOwner?: string;
        key: string;
        notModifiedSince?: Date;
    }

    Type declaration

    • Optional bucket?: StorageBucket
    • Optional eTag?: string
    • Optional expectedBucketOwner?: string
    • key: string

      Deprecated

      This may be removed in the next major version.

      +
    • Optional notModifiedSince?: Date

    Deprecated

    This may be removed in the next major version.

    \ No newline at end of file diff --git a/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataChecksumAlgorithm.html b/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataChecksumAlgorithm.html new file mode 100644 index 00000000000..443df4982d3 --- /dev/null +++ b/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataChecksumAlgorithm.html @@ -0,0 +1,2 @@ +UploadDataChecksumAlgorithm | Amplify JS API Documentation +
    UploadDataChecksumAlgorithm: "crc-32"
    \ No newline at end of file diff --git a/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataOptions.html b/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataOptions.html index decf8e0f46f..6e85614e282 100644 --- a/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataOptions.html +++ b/docs/api/types/aws_amplify.storage._Reference_Types_.UploadDataOptions.html @@ -1,5 +1,9 @@ UploadDataOptions | Amplify JS API Documentation -
    UploadDataOptions: CommonOptions & TransferOptions & {
        contentDisposition?: ContentDisposition | string;
        contentEncoding?: string;
        contentType?: string;
        metadata?: Record<string, string>;
    }

    Type declaration

    • Optional contentDisposition?: ContentDisposition | string

      The default content-disposition header value of the file when downloading it. +

      UploadDataOptions: CommonOptions & TransferOptions & {
          checksumAlgorithm?: UploadDataChecksumAlgorithm;
          contentDisposition?: ContentDisposition | string;
          contentEncoding?: string;
          contentType?: string;
          metadata?: Record<string, string>;
          preventOverwrite?: boolean;
      }

      Type declaration

      \ No newline at end of file diff --git a/docs/api/variables/aws_amplify.storage.DEFAULT_PART_SIZE.html b/docs/api/variables/aws_amplify.storage.DEFAULT_PART_SIZE.html new file mode 100644 index 00000000000..58b952ecba9 --- /dev/null +++ b/docs/api/variables/aws_amplify.storage.DEFAULT_PART_SIZE.html @@ -0,0 +1,3 @@ +DEFAULT_PART_SIZE | Amplify JS API Documentation +
      DEFAULT_PART_SIZE: number

      Default part size in MB that is used to determine if an upload task is single part or multi part.

      +
      \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 079c9406be4..4d4f9c7e3ac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ import tsParser from '@typescript-eslint/parser'; import js from '@eslint/js'; import { FlatCompat } from '@eslint/eslintrc'; +import customClientDtsBundlerConfig from './scripts/dts-bundler/dts-bundler.config.js'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ @@ -19,6 +21,10 @@ const compat = new FlatCompat({ recommendedConfig: js.configs.recommended, allConfig: js.configs.all, }); +const customClientDtsFiles = customClientDtsBundlerConfig.entries + .map(clientBundlerConfig => clientBundlerConfig.outFile) + .filter(outFile => outFile?.length > 0) + .map(outFile => outFile.replace(__dirname + path.sep, '')) // Convert absolute path to relative path export default [ { @@ -39,6 +45,7 @@ export default [ 'packages/interactions/__tests__', 'packages/predictions/__tests__', 'packages/pubsub/__tests__', + ...customClientDtsFiles, ], }, ...fixupConfigRules( diff --git a/package.json b/package.json index b50ecce61d2..dadc2b31ccf 100644 --- a/package.json +++ b/package.json @@ -137,9 +137,11 @@ "**/glob/minipass": "6.0.2", "nx": "16.7.0", "xml2js": "0.5.0", - "tar": "6.2.1" + "tar": "6.2.1", + "**/cross-spawn": "7.0.5" }, "overrides": { - "tar": "6.2.1" + "tar": "6.2.1", + "cross-spawn": "7.0.5" } } diff --git a/packages/adapter-nextjs/CHANGELOG.md b/packages/adapter-nextjs/CHANGELOG.md index 76e5eb6f3b2..7de41d4c49b 100644 --- a/packages/adapter-nextjs/CHANGELOG.md +++ b/packages/adapter-nextjs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.2.30](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/adapter-nextjs@1.2.29...@aws-amplify/adapter-nextjs@1.2.30) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/adapter-nextjs + +## [1.2.29](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/adapter-nextjs@1.2.28...@aws-amplify/adapter-nextjs@1.2.29) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/adapter-nextjs + ## [1.2.28](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/adapter-nextjs@1.2.27...@aws-amplify/adapter-nextjs@1.2.28) (2024-11-13) **Note:** Version bump only for package @aws-amplify/adapter-nextjs diff --git a/packages/adapter-nextjs/package.json b/packages/adapter-nextjs/package.json index 817cc953dc9..6ed2e63cd6e 100644 --- a/packages/adapter-nextjs/package.json +++ b/packages/adapter-nextjs/package.json @@ -1,7 +1,7 @@ { "author": "Amazon Web Services", "name": "@aws-amplify/adapter-nextjs", - "version": "1.2.28", + "version": "1.2.30", "description": "The adapter for the supporting of using Amplify APIs in Next.js.", "peerDependencies": { "aws-amplify": "^6.0.7", @@ -16,7 +16,7 @@ "@types/node": "^20.3.1", "@types/react": "^18.2.13", "@types/react-dom": "^18.2.6", - "aws-amplify": "6.8.2", + "aws-amplify": "6.10.0", "jest-fetch-mock": "3.0.3", "next": ">= 13.5.0 < 15.0.0", "typescript": "5.0.2" diff --git a/packages/analytics/CHANGELOG.md b/packages/analytics/CHANGELOG.md index bd5c316ce8f..bfb0cf16198 100644 --- a/packages/analytics/CHANGELOG.md +++ b/packages/analytics/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [7.0.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/analytics@7.0.59...@aws-amplify/analytics@7.0.60) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/analytics + +## [7.0.59](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/analytics@7.0.58...@aws-amplify/analytics@7.0.59) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/analytics + ## [7.0.58](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/analytics@7.0.57...@aws-amplify/analytics@7.0.58) (2024-11-13) **Note:** Version bump only for package @aws-amplify/analytics diff --git a/packages/analytics/package.json b/packages/analytics/package.json index d1841990828..c02d5982cd5 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/analytics", - "version": "7.0.58", + "version": "7.0.60", "description": "Analytics category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -103,7 +103,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", "@aws-sdk/types": "3.398.0", "typescript": "5.0.2" diff --git a/packages/api-graphql/CHANGELOG.md b/packages/api-graphql/CHANGELOG.md index db3c781dbbd..67be517962f 100644 --- a/packages/api-graphql/CHANGELOG.md +++ b/packages/api-graphql/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.6.3](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-graphql@4.6.2...@aws-amplify/api-graphql@4.6.3) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/api-graphql + +## [4.6.2](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-graphql@4.6.1...@aws-amplify/api-graphql@4.6.2) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/api-graphql + ## [4.6.1](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-graphql@4.6.0...@aws-amplify/api-graphql@4.6.1) (2024-11-13) **Note:** Version bump only for package @aws-amplify/api-graphql diff --git a/packages/api-graphql/package.json b/packages/api-graphql/package.json index aec2e00ff54..d9dc18d94d1 100644 --- a/packages/api-graphql/package.json +++ b/packages/api-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/api-graphql", - "version": "4.6.1", + "version": "4.6.3", "description": "Api-graphql category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -84,8 +84,8 @@ "server" ], "dependencies": { - "@aws-amplify/api-rest": "4.0.58", - "@aws-amplify/core": "6.5.3", + "@aws-amplify/api-rest": "4.0.60", + "@aws-amplify/core": "6.7.0", "@aws-amplify/data-schema": "^1.7.0", "@aws-sdk/types": "3.387.0", "graphql": "15.8.0", diff --git a/packages/api-rest/CHANGELOG.md b/packages/api-rest/CHANGELOG.md index af0959cdb9e..91e042c6824 100644 --- a/packages/api-rest/CHANGELOG.md +++ b/packages/api-rest/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [4.0.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-rest@4.0.59...@aws-amplify/api-rest@4.0.60) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/api-rest + +## [4.0.59](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-rest@4.0.58...@aws-amplify/api-rest@4.0.59) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/api-rest + ## [4.0.58](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api-rest@4.0.57...@aws-amplify/api-rest@4.0.58) (2024-11-13) **Note:** Version bump only for package @aws-amplify/api-rest diff --git a/packages/api-rest/package.json b/packages/api-rest/package.json index 1a444c119fc..ddda5645b2d 100644 --- a/packages/api-rest/package.json +++ b/packages/api-rest/package.json @@ -1,7 +1,7 @@ { "name": "@aws-amplify/api-rest", "private": false, - "version": "4.0.58", + "version": "4.0.60", "description": "Api-rest category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -87,7 +87,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", "typescript": "5.0.2" }, diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index f6726327d0a..97f7ba6b2d4 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.1.5](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api@6.1.4...@aws-amplify/api@6.1.5) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/api + +## [6.1.4](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api@6.1.3...@aws-amplify/api@6.1.4) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/api + ## [6.1.3](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/api@6.1.2...@aws-amplify/api@6.1.3) (2024-11-13) **Note:** Version bump only for package @aws-amplify/api diff --git a/packages/api/package.json b/packages/api/package.json index 5fe2429ee6f..68cd3762dd8 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/api", - "version": "6.1.3", + "version": "6.1.5", "description": "Api category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -79,8 +79,8 @@ "server" ], "dependencies": { - "@aws-amplify/api-graphql": "4.6.1", - "@aws-amplify/api-rest": "4.0.58", + "@aws-amplify/api-graphql": "4.6.3", + "@aws-amplify/api-rest": "4.0.60", "tslib": "^2.5.0" } } diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index cae4c9caec1..85df22b1e5b 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [6.8.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/auth@6.7.0...@aws-amplify/auth@6.8.0) (2024-11-25) + +### Features + +- **auth:** passwordless ([#14032](https://github.com/aws-amplify/amplify-js/issues/14032)) ([68c7f6f](https://github.com/aws-amplify/amplify-js/commit/68c7f6fbaa903ac8e45035bc25a71321a6240aec)), closes [#1](https://github.com/aws-amplify/amplify-js/issues/1) [#3](https://github.com/aws-amplify/amplify-js/issues/3) [#6](https://github.com/aws-amplify/amplify-js/issues/6) [#8](https://github.com/aws-amplify/amplify-js/issues/8) [#2](https://github.com/aws-amplify/amplify-js/issues/2) [#11](https://github.com/aws-amplify/amplify-js/issues/11) [#7](https://github.com/aws-amplify/amplify-js/issues/7) [#14](https://github.com/aws-amplify/amplify-js/issues/14) [#15](https://github.com/aws-amplify/amplify-js/issues/15) [#16](https://github.com/aws-amplify/amplify-js/issues/16) [#18](https://github.com/aws-amplify/amplify-js/issues/18) [#17](https://github.com/aws-amplify/amplify-js/issues/17) [#19](https://github.com/aws-amplify/amplify-js/issues/19) [#22](https://github.com/aws-amplify/amplify-js/issues/22) + +# [6.7.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/auth@6.6.2...@aws-amplify/auth@6.7.0) (2024-11-20) + +### Features + +- **storage:** Integrity changes for storage browser ([#13909](https://github.com/aws-amplify/amplify-js/issues/13909)) ([ec7bf6f](https://github.com/aws-amplify/amplify-js/commit/ec7bf6ff2fb4af84425eca4f2d68c2bef7f49d03)), closes [#13478](https://github.com/aws-amplify/amplify-js/issues/13478) [#13474](https://github.com/aws-amplify/amplify-js/issues/13474) + ## [6.6.2](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/auth@6.6.1...@aws-amplify/auth@6.6.2) (2024-11-13) **Note:** Version bump only for package @aws-amplify/auth diff --git a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts new file mode 100644 index 00000000000..bae6e6ec77f --- /dev/null +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -0,0 +1,197 @@ +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, +} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + PasskeyError, + PasskeyErrorCode, +} from '../../../src/client/utils/passkey/errors'; +import { associateWebAuthnCredential } from '../../../src/client/apis/associateWebAuthnCredential'; +import { + passkeyCredentialCreateOptions, + passkeyRegistrationResult, +} from '../../mockData'; +import { serializePkcWithAttestationToJson } from '../../../src/client/utils/passkey/serde'; +import * as utils from '../../../src/client/utils'; +import { getIsPasskeySupported } from '../../../src/client/utils/passkey/getIsPasskeySupported'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { + assertCredentialIsPkcWithAuthenticatorAssertionResponse, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from '../../../src/client/utils/passkey/types'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +jest.mock('../../../src/client/utils/passkey/getIsPasskeySupported'); +jest.mock('../../../src/client/utils/passkey/types', () => ({ + ...jest.requireActual('../../../src/client/utils/passkey/types'), + assertCredentialIsPkcWithAuthenticatorAssertionResponse: jest.fn(), + assertCredentialIsPkcWithAuthenticatorAttestationResponse: jest.fn(), +})); + +Object.assign(navigator, { + credentials: { + create: jest.fn(), + }, +}); + +describe('associateWebAuthnCredential', () => { + const navigatorCredentialsCreateSpy = jest.spyOn( + navigator.credentials, + 'create', + ); + const registerPasskeySpy = jest.spyOn(utils, 'registerPasskey'); + + const mockFetchAuthSession = jest.mocked(fetchAuthSession); + + const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported); + + const mockStartWebAuthnRegistration = jest.fn(); + const mockCreateStartWebAuthnRegistrationClient = jest.mocked( + createStartWebAuthnRegistrationClient, + ); + + const mockCompleteWebAuthnRegistration = jest.fn(); + const mockCreateCompleteWebAuthnRegistrationClient = jest.mocked( + createCompleteWebAuthnRegistrationClient, + ); + + const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAssertionResponse); + const mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAttestationResponse); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + }); + mockCreateStartWebAuthnRegistrationClient.mockReturnValue( + mockStartWebAuthnRegistration, + ); + mockCreateCompleteWebAuthnRegistrationClient.mockReturnValue( + mockCompleteWebAuthnRegistration, + ); + mockCompleteWebAuthnRegistration.mockImplementation(() => ({ + CredentialId: '12345', + })); + + navigatorCredentialsCreateSpy.mockResolvedValue(passkeyRegistrationResult); + + mockGetIsPasskeySupported.mockReturnValue(true); + mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse.mockImplementation( + () => undefined, + ); + mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse.mockImplementation( + () => undefined, + ); + }); + + afterEach(() => { + mockFetchAuthSession.mockClear(); + mockStartWebAuthnRegistration.mockClear(); + navigatorCredentialsCreateSpy.mockClear(); + }); + + it('should pass the correct service options when retrieving credential creation options', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockStartWebAuthnRegistration).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + }, + ); + }); + + it('should pass the correct service options when verifying a credential', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockCompleteWebAuthnRegistration).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + Credential: serializePkcWithAttestationToJson( + passkeyRegistrationResult, + ), + }, + ); + }); + + it('should call the registerPasskey function with correct input', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(registerPasskeySpy).toHaveBeenCalledWith( + passkeyCredentialCreateOptions, + ); + + expect(navigatorCredentialsCreateSpy).toHaveBeenCalled(); + }); + + it('should throw an error when service returns empty credential creation options', async () => { + expect.assertions(2); + + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: undefined, + })); + + try { + await associateWebAuthnCredential(); + } catch (error: any) { + expect(error).toBeInstanceOf(PasskeyError); + expect(error.name).toBe( + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, + ); + } + }); + + it('should throw an error when passkeys are not supported', async () => { + expect.assertions(2); + + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + mockGetIsPasskeySupported.mockReturnValue(false); + + try { + await associateWebAuthnCredential(); + } catch (error: any) { + expect(error).toBeInstanceOf(PasskeyError); + expect(error.name).toBe(PasskeyErrorCode.PasskeyNotSupported); + } + }); +}); diff --git a/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts b/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts new file mode 100644 index 00000000000..de71d7a071b --- /dev/null +++ b/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts @@ -0,0 +1,390 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createInitiateAuthClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { getAuthenticationHelper } from '../../../../src/providers/cognito/utils/srp'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handlePasswordSRP } from '../../../../src/client/flows/shared/handlePasswordSRP'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/srp'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), + handlePasswordVerifierChallenge: jest.fn(), + retryOnResourceNotFoundException: jest.fn(), +})); + +describe('handlePasswordSRP', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockInitiateAuth = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + const mockAuthenticationHelper = { + A: { toString: () => '123456' }, + }; + const mockTokenOrchestrator = { + getDeviceMetadata: jest.fn(), + clearDeviceMetadata: jest.fn(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + (createInitiateAuthClient as jest.Mock).mockReturnValue(mockInitiateAuth); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getAuthenticationHelper as jest.Mock).mockResolvedValue( + mockAuthenticationHelper, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + ( + signInHelpers.retryOnResourceNotFoundException as jest.Mock + ).mockImplementation((fn, args) => fn(...args)); + mockInitiateAuth.mockResolvedValue({ + ChallengeParameters: { USERNAME: 'testuser' }, + Session: 'test-session', + }); + }); + + test('should handle USER_SRP_AUTH flow without preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AuthFlow: 'USER_SRP_AUTH', + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle USER_AUTH flow with preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const preferredChallenge = 'PASSWORD_SRP'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + preferredChallenge, + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthFlow: 'USER_AUTH', + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + PREFERRED_CHALLENGE: preferredChallenge, + }, + }), + ); + }); + + test('should not add PREFERRED_CHALLENGE for USER_SRP_AUTH even if provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const preferredChallenge = 'PASSWORD_SRP'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + preferredChallenge, + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + }, + }), + ); + }); + + test('should handle PASSWORD_VERIFIER challenge response', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const challengeParameters = { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeParameters: challengeParameters, + Session: session, + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + challengeParameters, + undefined, + session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should return response directly when not PASSWORD_VERIFIER challenge', async () => { + const username = 'testuser'; + const mockResponse = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { USERNAME: username }, + }; + mockInitiateAuth.mockResolvedValueOnce(mockResponse); + + const result = await handlePasswordSRP({ + username, + password: 'testpassword', + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(result).toEqual(mockResponse); + expect( + signInHelpers.retryOnResourceNotFoundException, + ).not.toHaveBeenCalled(); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const clientMetadata = { client: 'test' }; + + await handlePasswordSRP({ + username, + password, + clientMetadata, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeParameters: { USERNAME: challengeUsername }, + Session: 'test-session', + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should call handlePasswordVerifierChallenge with correct parameters', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const challengeParameters = { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeParameters: challengeParameters, + Session: session, + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + challengeParameters, + undefined, + session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should handle userPoolId without second part after underscore', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + const configWithEmptyPool = { + ...mockConfig, + userPoolId: 'us-west-2_', // Valid region format but empty after underscore + }; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: configWithEmptyPool, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(getAuthenticationHelper).toHaveBeenCalledWith(''); + }); + + test('should use original username when ChallengeParameters is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + Session: 'test-session', + // ChallengeParameters is undefined + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should not add PREFERRED_CHALLENGE for USER_AUTH when preferredChallenge is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + // preferredChallenge is undefined + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + // Should not include PREFERRED_CHALLENGE + }, + }), + ); + }); + + test('should throw error when initiateAuth fails', async () => { + const error = new Error('Auth failed'); + mockInitiateAuth.mockRejectedValueOnce(error); + + await expect( + handlePasswordSRP({ + username: 'testuser', + password: 'testpassword', + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }), + ).rejects.toThrow('Auth failed'); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts new file mode 100644 index 00000000000..8447bbb6963 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts @@ -0,0 +1,175 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { initiateSelectedChallenge } from '../../../../src/client/flows/userAuth/handleSelectChallenge'; +import { RespondToAuthChallengeCommandOutput } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); + +describe('initiateSelectedChallenge', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic challenge selection', async () => { + const username = 'testuser'; + const session = 'test-session'; + const selectedChallenge = 'EMAIL_OTP'; + + await initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config: mockConfig, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + USERNAME: username, + ANSWER: selectedChallenge, + }, + ClientId: mockConfig.userPoolClientId, + Session: session, + ClientMetadata: undefined, + }, + ); + }); + + test('should include client metadata when provided', async () => { + const username = 'testuser'; + const session = 'test-session'; + const selectedChallenge = 'EMAIL_OTP'; + const clientMetadata = { client: 'test' }; + + await initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config: mockConfig, + clientMetadata, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should return the response from respondToAuthChallenge', async () => { + const mockResponse: RespondToAuthChallengeCommandOutput = { + ChallengeName: 'EMAIL_OTP', + Session: 'new-session', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'test@example.com', + }, + $metadata: {}, + }; + mockRespondToAuthChallenge.mockResolvedValueOnce(mockResponse); + + const result = await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: mockConfig, + }); + + expect(result).toEqual(mockResponse); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: mockConfig, + }), + ).rejects.toThrow('Auth challenge failed'); + }); + + test('should support different challenge types', async () => { + const testCases = ['EMAIL_OTP', 'SMS_OTP', 'PASSWORD', 'TOTP']; + + for (const challengeType of testCases) { + await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: challengeType, + config: mockConfig, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + ChallengeResponses: { + USERNAME: 'testuser', + ANSWER: challengeType, + }, + }), + ); + } + }); + + test('should use correct endpoint and region from config', async () => { + const customConfig = { + userPoolId: 'eu-west-1_custompool', + userPoolClientId: 'custom-client-id', + userPoolEndpoint: 'custom-endpoint', + }; + + await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: customConfig, + }); + + expect(createCognitoUserPoolEndpointResolver).toHaveBeenCalledWith({ + endpointOverride: customConfig.userPoolEndpoint, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'eu-west-1', + }), + expect.anything(), + ); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts new file mode 100644 index 00000000000..78322b59536 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleSelectChallengeWithPassword } from '../../../../src/client/flows/userAuth/handleSelectChallengeWithPassword'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), +})); + +describe('handlePasswordChallenge', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic password challenge flow', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + ANSWER: 'PASSWORD', + USERNAME: username, + PASSWORD: password, + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + Session: session, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const clientMetadata = { client: 'test' }; + + await handleSelectChallengeWithPassword( + username, + password, + clientMetadata, + mockConfig, + session, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters when available', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: challengeUsername, + }, + }); + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should set active username as original username when challenge parameters are missing', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: {}, + }); + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + handleSelectChallengeWithPassword( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + ), + ).rejects.toThrow('Auth challenge failed'); + }); + + test('should return the response from respondToAuthChallenge', async () => { + const mockResponse = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'new-session', + ChallengeParameters: { + USERNAME: 'testuser', + }, + }; + mockRespondToAuthChallenge.mockResolvedValueOnce(mockResponse); + + const result = await handleSelectChallengeWithPassword( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + ); + + expect(result).toEqual(mockResponse); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts new file mode 100644 index 00000000000..b89414c3ae1 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts @@ -0,0 +1,262 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { getAuthenticationHelper } from '../../../../src/providers/cognito/utils/srp'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleSelectChallengeWithPasswordSRP } from '../../../../src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/srp'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), + handlePasswordVerifierChallenge: jest.fn(), + retryOnResourceNotFoundException: jest.fn(), +})); + +describe('handleSelectChallengeWithPasswordSRP', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockTokenOrchestrator = { + getDeviceMetadata: jest.fn(), + clearDeviceMetadata: jest.fn(), + } as any; + + const mockRespondToAuthChallenge = jest.fn(); + const mockAuthenticationHelper = { + A: { toString: () => '123456' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (getAuthenticationHelper as jest.Mock).mockResolvedValue( + mockAuthenticationHelper, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic SRP challenge flow', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + ANSWER: 'PASSWORD_SRP', + USERNAME: username, + SRP_A: '123456', + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + Session: session, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle PASSWORD_VERIFIER challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + const verifierResponse = { + ChallengeName: 'PASSWORD_VERIFIER', + Session: 'new-session', + ChallengeParameters: { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }, + }; + + mockRespondToAuthChallenge.mockResolvedValueOnce(verifierResponse); + ( + signInHelpers.retryOnResourceNotFoundException as jest.Mock + ).mockImplementation((fn, args) => fn(...args)); + ( + signInHelpers.handlePasswordVerifierChallenge as jest.Mock + ).mockResolvedValue({ + AuthenticationResult: { AccessToken: 'token' }, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + verifierResponse.ChallengeParameters, + undefined, + verifierResponse.Session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const clientMetadata = { client: 'test' }; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + clientMetadata, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters when available', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: challengeUsername, + }, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should use original username when ChallengeParameters is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + // Mock response without ChallengeParameters + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: undefined, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + // Verify it falls back to the original username + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should handle userPoolId without second part after underscore', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + // Create a new config with a userPoolId that has the region but nothing after underscore + const invalidPoolConfig = { + ...mockConfig, + userPoolId: 'us-west-2_', // Valid region format but empty after underscore + }; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + invalidPoolConfig, + session, + mockTokenOrchestrator, + ); + + // Verify getAuthenticationHelper was called with empty string + expect(getAuthenticationHelper).toHaveBeenCalledWith(''); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + handleSelectChallengeWithPasswordSRP( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + mockTokenOrchestrator, + ), + ).rejects.toThrow('Auth challenge failed'); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts new file mode 100644 index 00000000000..6e8185b3051 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts @@ -0,0 +1,212 @@ +import { Amplify } from '@aws-amplify/core'; + +import { createInitiateAuthClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { InitiateAuthCommandOutput } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleUserAuthFlow } from '../../../../src/client/flows/userAuth/handleUserAuthFlow'; + +// Mock dependencies +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock('../../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/userContextData', () => ({ + getUserContextData: jest.fn(), +})); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => { + return jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ); +}); + +const authConfig = { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + }, +}; + +Amplify.configure({ + Auth: authConfig, +}); + +describe('handleUserAuthFlow', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockInitiateAuth = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createInitiateAuthClient as jest.Mock).mockReturnValue(mockInitiateAuth); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockInitiateAuth.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic auth flow without preferred challenge', async () => { + const username = 'testuser'; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + }); + + // Verify initiateAuth was called with correct parameters + expect(mockInitiateAuth).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AuthFlow: 'USER_AUTH', + AuthParameters: { USERNAME: username }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle PASSWORD preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handleUserAuthFlow({ + username, + password, + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD', + }); + + // Verify initiateAuth was called with password + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + PASSWORD: password, + PREFERRED_CHALLENGE: 'PASSWORD', + }, + }), + ); + }); + + test('should handle EMAIL_OTP preferred challenge', async () => { + const username = 'testuser'; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'EMAIL_OTP', + }); + + // Verify initiateAuth was called with EMAIL_OTP challenge + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + PREFERRED_CHALLENGE: 'EMAIL_OTP', + }, + }), + ); + }); + + test('should include client metadata when provided', async () => { + const username = 'testuser'; + const clientMetadata = { client: 'test' }; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + clientMetadata, + }); + + // Verify client metadata was passed + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should handle auth response with challenges', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: 'testuser', + }, + $metadata: {}, + }; + mockInitiateAuth.mockResolvedValueOnce(mockResponse); + + const result = await handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + }); + + expect(result).toEqual(mockResponse); + }); + + test('should throw validation error for PASSWORD_SRP challenge without password', async () => { + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD_SRP', + // password is undefined + }), + ).rejects.toThrow('password is required to signIn'); + }); + + test('should throw validation error for PASSWORD challenge without password', async () => { + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD', + // password is undefined + }), + ).rejects.toThrow('password is required to signIn'); + }); + + test('should throw error when initiateAuth fails', async () => { + const error = new Error('Auth failed'); + mockInitiateAuth.mockRejectedValueOnce(error); + + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + }), + ).rejects.toThrow('Auth failed'); + }); +}); diff --git a/packages/auth/__tests__/client/utils/passkey.test.ts b/packages/auth/__tests__/client/utils/passkey.test.ts new file mode 100644 index 00000000000..c4fff5f891a --- /dev/null +++ b/packages/auth/__tests__/client/utils/passkey.test.ts @@ -0,0 +1,49 @@ +import { + deserializeJsonToPkcCreationOptions, + serializePkcWithAttestationToJson, +} from '../../../src/client/utils/passkey/serde'; +import { + passkeyRegistrationRequest, + passkeyRegistrationRequestJson, + passkeyRegistrationResult, + passkeyRegistrationResultJson, +} from '../../mockData'; + +describe('passkey', () => { + it('serializes pkc into correct json format', () => { + expect( + JSON.stringify( + serializePkcWithAttestationToJson(passkeyRegistrationResult), + ), + ).toBe(JSON.stringify(passkeyRegistrationResultJson)); + }); + + it('deserializes json into correct pkc format', () => { + const deserialized = deserializeJsonToPkcCreationOptions( + passkeyRegistrationRequestJson, + ); + + expect(deserialized.challenge.byteLength).toEqual( + passkeyRegistrationRequest.challenge.byteLength, + ); + expect(deserialized.user.id.byteLength).toEqual( + passkeyRegistrationRequest.user.id.byteLength, + ); + + expect(deserialized).toEqual( + expect.objectContaining({ + rp: expect.any(Object), + user: { + id: expect.any(ArrayBuffer), + name: expect.any(String), + displayName: expect.any(String), + }, + challenge: expect.any(ArrayBuffer), + pubKeyCredParams: expect.any(Array), + timeout: expect.any(Number), + excludeCredentials: expect.any(Array), + authenticatorSelection: expect.any(Object), + }), + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts new file mode 100644 index 00000000000..c4726e93692 --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts @@ -0,0 +1,62 @@ +import { Amplify } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createDeleteWebAuthnCredentialClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { DeleteWebAuthnCredentialInput } from '../../../src'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { deleteWebAuthnCredential } from '../../../src/foundation/apis'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(() => ({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + })), + }, + }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +describe('deleteWebAuthnCredential', () => { + const mockDeleteWebAuthnCredential = jest.fn(); + const mockCreateDeleteWebAuthnCredentialClient = jest.mocked( + createDeleteWebAuthnCredentialClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + + mockCreateDeleteWebAuthnCredentialClient.mockReturnValue( + mockDeleteWebAuthnCredential, + ); + }); + + it('should pass correct service options when deleting a credential', async () => { + const input: DeleteWebAuthnCredentialInput = { + credentialId: 'dummyId', + }; + + await deleteWebAuthnCredential(Amplify, input); + + expect(mockDeleteWebAuthnCredential).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + CredentialId: input.credentialId, + }, + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts new file mode 100644 index 00000000000..f0708aa06e2 --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts @@ -0,0 +1,150 @@ +import { Amplify } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createListWebAuthnCredentialsClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { ListWebAuthnCredentialsInput } from '../../../src'; +import { mockUserCredentials } from '../../mockData'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { listWebAuthnCredentials } from '../../../src/foundation/apis'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(() => ({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + })), + }, + }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +describe('listWebAuthnCredentials', () => { + const mockListWebAuthnCredentials = jest.fn(); + const mockCreateListWebAuthnCredentialsClient = jest.mocked( + createListWebAuthnCredentialsClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + + mockCreateListWebAuthnCredentialsClient.mockReturnValue( + mockListWebAuthnCredentials, + ); + + mockListWebAuthnCredentials.mockImplementation((in1, in2) => { + return Promise.resolve({ + Credentials: mockUserCredentials.slice(0, in2.MaxResults), + NextToken: + in2.MaxResults < mockUserCredentials.length + ? 'dummyNextToken' + : undefined, + }); + }); + }); + + it('should pass correct service options when listing credentials', async () => { + await listWebAuthnCredentials(Amplify); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + }, + ); + }); + + it('should pass correct service options and output correctly with input', async () => { + const input: ListWebAuthnCredentialsInput = { + pageSize: 3, + }; + + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + MaxResults: 3, + }, + ); + + expect(credentials.length).toEqual(2); + expect(credentials).toMatchObject([ + { + credentialId: '12345', + friendlyCredentialName: 'mycred', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2024-02-29T01:23:45.000Z'), + }, + { + credentialId: '22345', + friendlyCredentialName: 'mycred2', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2020-02-29T01:23:45.000Z'), + }, + ]); + + expect(nextToken).toBe(undefined); + }); + + it('should pass correct service options and output correctly with input that requires nextToken', async () => { + const input: ListWebAuthnCredentialsInput = { + pageSize: 1, + nextToken: 'exampleToken', + }; + + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + MaxResults: 1, + NextToken: 'exampleToken', + }, + ); + + expect(credentials.length).toEqual(1); + expect(credentials).toMatchObject([ + { + credentialId: '12345', + friendlyCredentialName: 'mycred', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2024-02-29T01:23:45.000Z'), + }, + ]); + + expect(nextToken).toBe('dummyNextToken'); + }); +}); diff --git a/packages/auth/__tests__/foundation/convert/base64url.test.ts b/packages/auth/__tests__/foundation/convert/base64url.test.ts new file mode 100644 index 00000000000..72bebbf590a --- /dev/null +++ b/packages/auth/__tests__/foundation/convert/base64url.test.ts @@ -0,0 +1,32 @@ +import { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from '../../../src/foundation/convert'; + +describe('base64url', () => { + it('converts ArrayBuffer values to base64url', () => { + expect(convertArrayBufferToBase64Url(new Uint8Array([]))).toBe(''); + expect(convertArrayBufferToBase64Url(new Uint8Array([0]))).toBe('AA'); + expect(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3]))).toBe( + 'AQID', + ); + }); + it('converts base64url values to ArrayBuffer', () => { + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([]))); + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AA')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([0]))); + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AQID')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3]))); + }); + + it('converts base64url to ArrayBuffer and back without data loss', () => { + const input = '_h7NMedx8qUAz_yHKhgHt74P2UrTU_qcB4_ToULz12M'; + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer(input)), + ).toBe(input); + }); +}); diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts new file mode 100644 index 00000000000..4c949522c97 --- /dev/null +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts @@ -0,0 +1,53 @@ +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/constants'; +import { createSignUpClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createSignUpClientDeserializer } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient'; +import { AuthError } from '../../../../../src/errors/AuthError'; +import { AuthValidationErrorCode } from '../../../../../src/errors/types/validation'; +import { validationErrorMap } from '../../../../../src/common/AuthErrorStrings'; + +import { + mockServiceClientAPIConfig, + mockSignUpClientEmptySignUpPasswordResponse, +} from './testUtils/data'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils/composers', () => ({ + ...jest.requireActual( + '@aws-amplify/core/internals/aws-client-utils/composers', + ), + composeServiceApi: jest.fn(), +})); + +describe('createSignUpClient', () => { + const mockComposeServiceApi = jest.mocked(composeServiceApi); + + it('factory should invoke composeServiceApi with expected parameters', () => { + createSignUpClient(mockServiceClientAPIConfig); + + expect(mockComposeServiceApi).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + expect.any(Function), + expect.objectContaining({ + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...mockServiceClientAPIConfig, + }), + ); + }); + + it('createSignUpDeserializer should throw expected error when', () => { + const deserializer = createSignUpClientDeserializer(); + + expect( + deserializer(mockSignUpClientEmptySignUpPasswordResponse), + ).rejects.toThrow( + new AuthError({ + name: AuthValidationErrorCode.EmptySignUpPassword, + message: + validationErrorMap[AuthValidationErrorCode.EmptySignUpPassword] + .message, + }), + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts index f9105f3a43d..8cf31b2cbd3 100644 --- a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts @@ -24,7 +24,9 @@ describe('service clients', () => { test.each(serviceClientFactories)( 'factory `%s` should invoke composeServiceApi with expected parameters', serviceClientFactory => { - serviceClients[serviceClientFactory](mockServiceClientAPIConfig); + serviceClients[serviceClientFactory as keyof typeof serviceClients]( + mockServiceClientAPIConfig, + ); expect(mockComposeServiceApi).toHaveBeenCalledWith( expect.any(Function), diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts index 33a9a3d5534..0cea5ec340d 100644 --- a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts @@ -1,3 +1,5 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + import { ServiceClientFactoryInput } from '../../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; export const mockServiceClientAPIConfig: ServiceClientFactoryInput = { @@ -5,3 +7,19 @@ export const mockServiceClientAPIConfig: ServiceClientFactoryInput = { ServiceClientFactoryInput['endpointResolver'] >, }; + +export const mockSignUpClientEmptySignUpPasswordResponse: HttpResponse = { + statusCode: 400, + body: { + json: () => + Promise.resolve({ + message: + "1 validation error detected: Value at 'password'failed to satisfy constraint: Member must not be null", + }), + blob: () => Promise.resolve(new Blob()), + text: () => Promise.resolve(''), + }, + headers: { + 'x-amzn-errortype': 'InvalidParameterException', + }, +}; diff --git a/packages/auth/__tests__/mockData.ts b/packages/auth/__tests__/mockData.ts index 9edfd45a197..8fa6834ec5f 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -1,3 +1,12 @@ +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, +} from '../src/client/utils/passkey/types'; + // device tracking mock device data export const mockDeviceArray = [ { @@ -180,3 +189,231 @@ export const mockAuthConfigWithOAuth = { }, }, }; + +export const passkeyCredentialCreateOptions = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: 'M2M0NjMyMGItYzYwZS00YTIxLTlkNjQtNTgyOWJmZWRlMWM0', + name: 'james', + displayName: '', + }, + challenge: 'zsBch6DlNLUb6SgRdzHysw', + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key', + id: 'VWxodmRFMUtjbEJZVWs1NE9IaHhOblZUTTBsUVJWSXRTbWhhUkdwZldHaDBSbVpmUmxKamFWRm5XUQ', + }, + { + type: 'public-key', + id: 'WDJnM1RrMWxaSGc0Y1ZWQmVsOTVTRXRvWjBoME56UlFNbFZ5VkZWZmNXTkNORjlVYjFWTWVqRXlUUQ', + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, +}; + +export const passkeyRegistrationResultJson: PasskeyCreateResultJson = { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE', + rawId: 'vJCit9S2cglAvvW3txQ-OQ', + clientExtensionResults: {}, + response: { + clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ', + attestationObject: 'vJCit9S2cglAvvW3txQ-OQ', + transports: ['internal'], + publicKeyAlgorithm: -7, + authenticatorData: 'vJCit9S2cglAvvW3txQ-OQ', + publicKey: 'vJCit9S2cglAvvW3txQ-OQ', + }, + authenticatorAttachment: 'platform', +}; +export const passkeyRegistrationResult: PkcWithAuthenticatorAttestationResponse = + { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE', + rawId: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + getClientExtensionResults: () => ({}), + authenticatorAttachment: 'platform', + response: { + clientDataJSON: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + attestationObject: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getPublicKey: () => + new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getPublicKeyAlgorithm: () => -7, + getAuthenticatorData: () => + new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getTransports: () => ['internal'], + }, + }; + +export const passkeyRegistrationRequest: PublicKeyCredentialCreationOptions = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + name: 'james', + displayName: '', + }, + challenge: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + pubKeyCredParams: [ + { type: 'public-key' as any, alg: -7 }, + { type: 'public-key' as any, alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key' as any, + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required' as any, + userVerification: 'required' as any, + }, +}; + +export const passkeyRegistrationRequestJson: PasskeyCreateOptionsJson = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: 'vJCit9S2cglAvvW3txQ-OQ', + name: 'james', + displayName: '', + }, + challenge: 'vJCit9S2cglAvvW3txQ-OQ', + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OQ', + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, +}; + +export const passkeyCredentialRequestOptions = + '{"hints":[],"attestation":"none","attestationFormats":[],"challenge":"9DAxgg4vPiaxvAxc-JbMuw","timeout":180000,"rpId":"localhost","allowCredentials":[{"id":"1oG8PrTycHFuWdHAjIelCnsVx7XsrGIL44Whwr_8F8k","type":"public-key"}],"userVerification":"required"}'; + +export const passkeyGetOptionsJson: PasskeyGetOptionsJson = { + challenge: 'vJCit9S2cglAvvW3txQ-OQ', + rpId: 'localhost', + timeout: 180000, + allowCredentials: [ + { + id: 'vJCit9S2cglAvvW3txQ-OQ', + type: 'public-key', + }, + ], + userVerification: 'required', +}; + +export const passkeyGetOptions: PublicKeyCredentialRequestOptions = { + challenge: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + rpId: 'localhost', + timeout: 180000, + allowCredentials: [ + { + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + type: 'public-key', + }, + ], + userVerification: 'required', +}; + +export const passkeyGetResultJson: PasskeyGetResultJson = { + id: 'vJCit9S2cglAvvW3txQ-OQ', + rawId: 'vJCit9S2cglAvvW3txQ-OQ', + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ', + authenticatorData: 'vJCit9S2cglAvvW3txQ-OQ', + signature: 'vJCit9S2cglAvvW3txQ-OQ', + userHandle: 'vJCit9S2cglAvvW3txQ-OQ', + }, + authenticatorAttachment: 'platform', +}; + +export const passkeyGetResult: PkcWithAuthenticatorAssertionResponse = { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OQ', + rawId: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + getClientExtensionResults: () => ({}), + authenticatorAttachment: 'platform', + response: { + authenticatorData: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + clientDataJSON: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + signature: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + userHandle: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + }, +}; + +export const mockUserCredentials = [ + { + CredentialId: '12345', + FriendlyCredentialName: 'mycred', + RelyingPartyId: '11111', + AuthenticatorAttachment: 'platform', + AuthenticatorTransports: ['usb', 'nfc'], + CreatedAt: 1709169825, + }, + { + CredentialId: '22345', + FriendlyCredentialName: 'mycred2', + RelyingPartyId: '11111', + AuthenticatorAttachment: 'platform', + AuthenticatorTransports: ['usb', 'nfc'], + CreatedAt: 1582939425, + }, +]; diff --git a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts index d787c2cdedf..05389b40773 100644 --- a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts +++ b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts @@ -5,13 +5,25 @@ import { Amplify } from 'aws-amplify'; import { cognitoUserPoolsTokenProvider, + confirmSignUp, signUp, } from '../../../src/providers/cognito'; -import { autoSignIn } from '../../../src/providers/cognito/apis/autoSignIn'; +import { + autoSignIn, + resetAutoSignIn, +} from '../../../src/providers/cognito/apis/autoSignIn'; import * as initiateAuthHelpers from '../../../src/providers/cognito/utils/signInHelpers'; -import { AuthError } from '../../../src/errors/AuthError'; -import { createSignUpClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + createConfirmSignUpClient, + createSignUpClient, +} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { autoSignInStore } from '../../../src/client/utils/store'; +import { AuthError } from '../../../src'; +import { cacheCognitoTokens } from '../../../src/providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../src/providers/cognito/utils/dispatchSignedInHubEvent'; +import { handleUserAuthFlow } from '../../../src/client/flows/userAuth/handleUserAuthFlow'; +import { AUTO_SIGN_IN_EXCEPTION } from '../../../src/errors/constants'; import { authAPITestParams } from './testUtils/authApiTestParams'; @@ -23,6 +35,9 @@ jest.mock('@aws-amplify/core/internals/utils', () => ({ jest.mock( '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', ); +jest.mock('../../../src/providers/cognito/tokenProvider/cacheTokens'); +jest.mock('../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock('../../../src/client/flows/userAuth/handleUserAuthFlow'); const authConfig = { Cognito: { @@ -34,63 +49,233 @@ cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); Amplify.configure({ Auth: authConfig, }); -describe('Auto sign-in API Happy Path Cases:', () => { - let handleUserSRPAuthFlowSpy: jest.SpyInstance; +const { user1 } = authAPITestParams; + +describe('autoSignIn()', () => { const mockSignUp = jest.fn(); const mockCreateSignUpClient = jest.mocked(createSignUpClient); - const { user1 } = authAPITestParams; - beforeEach(async () => { - mockSignUp.mockResolvedValueOnce({ UserConfirmed: true }); - mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + const mockConfirmSignUp = jest.fn(); + const mockCreateConfirmSignUpClient = jest.mocked(createConfirmSignUpClient); + + const mockCacheCognitoTokens = jest.mocked(cacheCognitoTokens); + const mockDispatchSignedInHubEvent = jest.mocked(dispatchSignedInHubEvent); + + const handleUserSRPAuthFlowSpy = jest + .spyOn(initiateAuthHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const mockHandleUserAuthFlow = jest.mocked(handleUserAuthFlow); + // to get around debounce on autoSignIn() APIs + jest.useFakeTimers(); + + describe('handleUserSRPAuthFlow', () => { + beforeEach(() => { + mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + mockSignUp.mockReturnValueOnce({ UserConfirmed: true }); + }); + + afterEach(() => { + mockSignUp.mockClear(); + mockCreateSignUpClient.mockClear(); + handleUserSRPAuthFlowSpy.mockClear(); + + resetAutoSignIn(); + }); + + afterAll(() => { + mockSignUp.mockReset(); + mockCreateSignUpClient.mockReset(); + handleUserSRPAuthFlowSpy.mockReset(); + jest.runAllTimers(); + }); + + it('autoSignIn() should throw an error when not enabled', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + expect(autoSignIn()).rejects.toThrow( + new AuthError({ + name: AUTO_SIGN_IN_EXCEPTION, + message: + 'The autoSignIn flow has not started, or has been cancelled/completed.', + }), + ); + }); - handleUserSRPAuthFlowSpy = jest - .spyOn(initiateAuthHelpers, 'handleUserSRPAuthFlow') - .mockImplementationOnce( + it('signUp should enable autoSignIn and return COMPLETE_AUTO_SIGN_IN step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + const resp = await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: true, + }, + }); + expect(resp).toEqual({ + isSignUpComplete: true, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + }, + }); + expect(mockSignUp).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState().username).toBe(user1.username); + }); + + it('autoSignIn() should resolve to a SignInOutput', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: true, + }, + }); + const signInOutput = await autoSignIn(); + expect(signInOutput).toEqual(authAPITestParams.signInResult()); + expect(handleUserSRPAuthFlowSpy).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + }); + }); + + describe('handleUserAuthFlow', () => { + beforeEach(() => { + mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + mockSignUp.mockReturnValueOnce({ UserConfirmed: false }); + + mockCreateConfirmSignUpClient.mockReturnValueOnce(mockConfirmSignUp); + mockConfirmSignUp.mockReturnValueOnce({ Session: 'ASDFGHJKL' }); + + mockHandleUserAuthFlow.mockImplementationOnce( async (): Promise => authAPITestParams.RespondToAuthChallengeCommandOutput, ); - }); + }); - afterEach(() => { - mockSignUp.mockClear(); - mockCreateSignUpClient.mockClear(); - handleUserSRPAuthFlowSpy.mockClear(); - }); + afterEach(() => { + mockSignUp.mockClear(); + mockConfirmSignUp.mockClear(); + mockCreateSignUpClient.mockClear(); + mockHandleUserAuthFlow.mockClear(); + mockCreateConfirmSignUpClient.mockClear(); - test('signUp should enable autoSignIn and return COMPLETE_AUTO_SIGN_IN step', async () => { - const resp = await signUp({ - username: user1.username, - password: user1.password, - options: { - userAttributes: { email: user1.email }, - autoSignIn: true, - }, + resetAutoSignIn(); }); - expect(resp).toEqual({ - isSignUpComplete: true, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - }, + + afterAll(() => { + mockSignUp.mockReset(); + mockConfirmSignUp.mockReset(); + mockCreateSignUpClient.mockReset(); + mockCreateConfirmSignUpClient.mockReset(); + mockHandleUserAuthFlow.mockReset(); + jest.runAllTimers(); }); - expect(mockSignUp).toHaveBeenCalledTimes(1); - }); - test('Auto sign-in should resolve to a signIn output', async () => { - const signInOutput = await autoSignIn(); - expect(signInOutput).toEqual(authAPITestParams.signInResult()); - expect(handleUserSRPAuthFlowSpy).toHaveBeenCalledTimes(1); - }); -}); + it('autoSignIn() should throw an error when not enabled', async () => { + expect(autoSignIn()).rejects.toThrow( + new AuthError({ + name: AUTO_SIGN_IN_EXCEPTION, + message: + 'The autoSignIn flow has not started, or has been cancelled/completed.', + }), + ); + }); + + it('signUp() should begin autoSignIn flow and return CONFIRM_SIGN_UP next step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + const signUpResult = await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); + + expect(signUpResult.nextStep.signUpStep).toBe('CONFIRM_SIGN_UP'); + expect(mockSignUp).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + }); + }); + + it('signUp() & confirmSignUp() should populate autoSignIn flow state and return COMPLETE_AUTO_SIGN_IN next step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); -describe('Auto sign-in API Error Path Cases:', () => { - test('autoSignIn should throw an error when autoSignIn is not enabled', async () => { - try { - await autoSignIn(); - } catch (error: any) { - expect(error).toBeInstanceOf(AuthError); - expect(error.name).toBe('AutoSignInException'); - } + const confirmSignUpResult = await confirmSignUp({ + username: user1.username, + confirmationCode: '123456', + }); + + expect(confirmSignUpResult.nextStep.signUpStep).toBe( + 'COMPLETE_AUTO_SIGN_IN', + ); + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + session: 'ASDFGHJKL', + }); + }); + + it('autoSignIn() should resolve to SignInOutput', async () => { + mockCacheCognitoTokens.mockResolvedValue(undefined); + mockDispatchSignedInHubEvent.mockResolvedValue(undefined); + + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); + + await confirmSignUp({ + username: user1.username, + confirmationCode: '123456', + }); + + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + session: 'ASDFGHJKL', + }); + + const autoSignInResult = await autoSignIn(); + + expect(mockHandleUserAuthFlow).toHaveBeenCalledTimes(1); + expect(mockHandleUserAuthFlow).toHaveBeenCalledWith( + expect.objectContaining({ + username: user1.username, + session: 'ASDFGHJKL', + }), + ); + expect(autoSignInResult.isSignedIn).toBe(true); + expect(autoSignInResult.nextStep.signInStep).toBe('DONE'); + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + }); }); }); diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts index 39e4fdd8c81..ce786ece3cb 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts @@ -4,7 +4,7 @@ import { AuthError } from '../../../src/errors/AuthError'; import { AuthValidationErrorCode } from '../../../src/errors/types/validation'; import { confirmSignIn } from '../../../src/providers/cognito/apis/confirmSignIn'; import { RespondToAuthChallengeException } from '../../../src/providers/cognito/types/errors'; -import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { signInStore } from '../../../src/client/utils/store'; import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; import { createRespondToAuthChallengeClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; @@ -16,7 +16,7 @@ jest.mock('@aws-amplify/core', () => ({ ...(jest.createMockFromModule('@aws-amplify/core') as object), Amplify: { getConfig: jest.fn(() => ({})) }, })); -jest.mock('../../../src/providers/cognito/utils/signInStore'); +jest.mock('../../../src/client/utils/store'); jest.mock( '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', ); diff --git a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts index 80006cbf675..73e3cdc6eea 100644 --- a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts @@ -5,7 +5,7 @@ import { Amplify } from '@aws-amplify/core'; import { getCurrentUser, signIn } from '../../../src/providers/cognito'; import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers'; -import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { signInStore } from '../../../src/client/utils/store'; import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; diff --git a/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts b/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts index 36c8d3c118a..9dd1b2dd606 100644 --- a/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts @@ -210,7 +210,7 @@ describe('signIn API happy path cases', () => { setDeviceKeys(); handleUserSRPAuthflowSpy.mockRestore(); mockInitiateAuth.mockResolvedValueOnce({ - ChallengeName: 'SRP_AUTH', + ChallengeName: 'PASSWORD_VERIFIER', Session: '1234234232', $metadata: {}, ChallengeParameters: { @@ -279,7 +279,7 @@ describe('Cognito ASF', () => { beforeEach(() => { mockInitiateAuth.mockResolvedValueOnce({ - ChallengeName: 'SRP_AUTH', + ChallengeName: 'PASSWORD_VERIFIER', Session: '1234234232', $metadata: {}, ChallengeParameters: { diff --git a/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts b/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts new file mode 100644 index 00000000000..66a080ecebd --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts @@ -0,0 +1,189 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Amplify } from '@aws-amplify/core'; +import { AmplifyErrorCode } from '@aws-amplify/core/internals/utils'; + +import { signInWithUserAuth } from '../../../src/providers/cognito/apis/signInWithUserAuth'; +import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; +import { InitiateAuthCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +jest.mock('../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual('../../../src/providers/cognito/utils/signInHelpers'), + cleanActiveSignInState: jest.fn(), + setActiveSignInState: jest.fn(), + getNewDeviceMetadata: jest.fn(), + getActiveSignInUsername: jest.fn(username => username), +})); +jest.mock('../../../src/providers/cognito/tokenProvider/cacheTokens', () => ({ + cacheCognitoTokens: jest.fn(), +})); +jest.mock('../../../src/client/flows/userAuth/handleUserAuthFlow'); +jest.mock('../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock('../../../src/providers/cognito/utils/srp', () => { + return { + ...jest.requireActual('../../../src/providers/cognito/utils/srp'), + getAuthenticationHelper: jest.fn(() => ({ + A: { toString: jest.fn() }, + getPasswordAuthenticationKey: jest.fn(), + })), + getSignatureString: jest.fn(), + }; +}); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); + +const authConfig = { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + }, +}; + +cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); +Amplify.configure({ + Auth: authConfig, +}); + +describe('signInWithUserAuth API tests', () => { + // Update how we get the mock + const { handleUserAuthFlow } = jest.requireMock( + '../../../src/client/flows/userAuth/handleUserAuthFlow', + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('signInWithUserAuth should return a SignInResult when SELECT_CHALLENGE is returned', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'SELECT_CHALLENGE', + Session: 'mockSession', + ChallengeParameters: {}, + AvailableChallenges: ['EMAIL_OTP', 'SMS_OTP'] as any, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION', + availableChallenges: ['EMAIL_OTP', 'SMS_OTP'], + }, + }); + expect(handleUserAuthFlow).toHaveBeenCalledWith({ + username: 'testuser', + clientMetadata: undefined, + config: authConfig.Cognito, + tokenOrchestrator: expect.anything(), + preferredChallenge: undefined, + password: undefined, + }); + }); + + test('signInWithUserAuth should handle preferred challenge', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'EMAIL_OTP', + Session: 'mockSession', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'y*****.com', + }, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + options: { preferredChallenge: 'EMAIL_OTP' }, + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + codeDeliveryDetails: { + deliveryMedium: 'EMAIL', + destination: 'y*****.com', + }, + }, + }); + expect(handleUserAuthFlow).toHaveBeenCalledWith({ + username: 'testuser', + clientMetadata: undefined, + config: authConfig.Cognito, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'EMAIL_OTP', + password: undefined, + }); + }); + + test('should throw validation error for empty username', async () => { + await expect( + signInWithUserAuth({ + username: '', // empty username + }), + ).rejects.toThrow('username is required to signIn'); + }); + + test('should handle successful authentication result', async () => { + const mockResponse: InitiateAuthCommandOutput = { + AuthenticationResult: { + AccessToken: 'mockAccessToken', + RefreshToken: 'mockRefreshToken', + IdToken: 'mockIdToken', + NewDeviceMetadata: { + DeviceKey: 'deviceKey', + DeviceGroupKey: 'deviceGroupKey', + }, + }, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }); + }); + + test('should handle service error with sign in result', async () => { + const error = new Error('PasswordResetRequiredException'); + error.name = 'PasswordResetRequiredException'; + handleUserAuthFlow.mockRejectedValue(error); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { signInStep: 'RESET_PASSWORD' }, + }); + }); + + test('should throw error when service error has no sign in result', async () => { + const error = new Error('Unknown error'); + error.name = 'UnknownError'; + handleUserAuthFlow.mockRejectedValue(error); + + await expect( + signInWithUserAuth({ + username: 'testuser', + }), + ).rejects.toThrow(AmplifyErrorCode.Unknown); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/signUp.test.ts b/packages/auth/__tests__/providers/cognito/signUp.test.ts index cb2b9b84d64..3b3f9bab4c5 100644 --- a/packages/auth/__tests__/providers/cognito/signUp.test.ts +++ b/packages/auth/__tests__/providers/cognito/signUp.test.ts @@ -244,6 +244,25 @@ describe('signUp', () => { expect(mockSignUp).toHaveBeenCalledTimes(1); (window as any).AmazonCognitoAdvancedSecurityData = undefined; }); + + it('should not throw an error when password is empty', async () => { + await signUp({ username: user1.username, password: '' }); + expect(mockSignUp).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ClientMetadata: undefined, + Password: undefined, + UserAttributes: undefined, + Username: user1.username, + ValidationData: undefined, + ClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + }, + ); + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); }); describe('Error Path Cases:', () => { @@ -265,16 +284,6 @@ describe('signUp', () => { } }); - it('should throw an error when password is empty', async () => { - expect.assertions(2); - try { - await signUp({ username: user1.username, password: '' }); - } catch (error: any) { - expect(error).toBeInstanceOf(AuthError); - expect(error.name).toBe(AuthValidationErrorCode.EmptySignUpPassword); - } - }); - it('should throw an error when service returns an error response', async () => { expect.assertions(2); mockSignUp.mockImplementation(() => { diff --git a/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts new file mode 100644 index 00000000000..366b925bffd --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { ChallengeName } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getSignInResult } from '../../../../../src/providers/cognito/utils/signInHelpers'; +import { AuthSignInOutput } from '../../../../../src/types'; +import { setUpGetConfig } from '../../testUtils/setUpGetConfig'; +import { createAssociateSoftwareTokenClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock( + '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +const basicGetSignInResultTestCases: [ + ChallengeName, + AuthSignInOutput['nextStep']['signInStep'], +][] = [ + ['CUSTOM_CHALLENGE', 'CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE'], + ['SELECT_CHALLENGE', 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION'], + ['PASSWORD', 'CONFIRM_SIGN_IN_WITH_PASSWORD'], + ['PASSWORD_SRP', 'CONFIRM_SIGN_IN_WITH_PASSWORD'], + ['SOFTWARE_TOKEN_MFA', 'CONFIRM_SIGN_IN_WITH_TOTP_CODE'], + ['SMS_MFA', 'CONFIRM_SIGN_IN_WITH_SMS_CODE'], + ['SMS_OTP', 'CONFIRM_SIGN_IN_WITH_SMS_CODE'], + ['SELECT_MFA_TYPE', 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION'], + ['NEW_PASSWORD_REQUIRED', 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED'], +]; + +describe('getSignInResult', () => { + const mockCreateAssociateSoftwareTokenClient = jest.mocked( + createAssociateSoftwareTokenClient, + ); + const mockAssociateSoftwareToken = jest.fn(() => + Promise.resolve({ Session: '123456', SecretCode: 'TEST', $metadata: {} }), + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockCreateAssociateSoftwareTokenClient.mockReturnValue( + mockAssociateSoftwareToken, + ); + }); + + it.each(basicGetSignInResultTestCases)( + 'should return the correct sign in step for challenge %s', + async (challengeName, signInStep) => { + const { nextStep } = await getSignInResult({ + challengeName, + challengeParameters: {}, + }); + + expect(nextStep.signInStep).toBe(signInStep); + }, + ); + + it('should return the correct sign in step for challenge MFA_SETUP when multiple available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', + }, + }); + expect(nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION', + ); + }); + + it('should return the correct sign in step for challenge MFA_SETUP when only totp available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA"]', + }, + }); + expect(nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_TOTP_SETUP'); + }); + + it('should return the correct sign in step for challenge MFA_SETUP when only email available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["EMAIL_OTP"]', + }, + }); + expect(nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_EMAIL_SETUP'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts new file mode 100644 index 00000000000..dc9a7c2296a --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts @@ -0,0 +1,174 @@ +import { Amplify } from '@aws-amplify/core'; + +import { signInStore } from '../../../../../src/client/utils/store'; +import { authAPITestParams } from '../../testUtils/authApiTestParams'; +import { setUpGetConfig } from '../../testUtils/setUpGetConfig'; +import { createRespondToAuthChallengeClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { handleWebAuthnSignInResult } from '../../../../../src/client/flows/userAuth/handleWebAuthnSignInResult'; +import { + passkeyCredentialRequestOptions, + passkeyGetResult, + passkeyGetResultJson, +} from '../../../../mockData'; +import { AuthError } from '../../../../../src/errors/AuthError'; +import { AuthErrorCodes } from '../../../../../src/common/AuthErrorStrings'; +import { cacheCognitoTokens } from '../../../../../src/providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../../../src/providers/cognito/utils/dispatchSignedInHubEvent'; +import { getIsPasskeySupported } from '../../../../../src/client/utils/passkey/getIsPasskeySupported'; +import { + assertCredentialIsPkcWithAuthenticatorAssertionResponse, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from '../../../../../src/client/utils/passkey/types'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock('../../../../../src/client/utils/store'); +jest.mock( + '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../../src/providers/cognito/factories'); +jest.mock('../../../../../src/providers/cognito/tokenProvider/cacheTokens'); +jest.mock( + '../../../../../src/providers/cognito/utils/dispatchSignedInHubEvent', +); +jest.mock('../../../../../src/client/utils/passkey/getIsPasskeySupported'); +jest.mock('../../../../../src/client/utils/passkey/types'); + +Object.assign(navigator, { + credentials: { + get: jest.fn(), + }, +}); +describe('handleWebAuthnSignInResult', () => { + const navigatorCredentialsGetSpy = jest.spyOn(navigator.credentials, 'get'); + const mockStoreGetState = jest.mocked(signInStore.getState); + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateRespondToAuthChallengeClient = jest.mocked( + createRespondToAuthChallengeClient, + ); + const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported); + + const mockCacheCognitoTokens = jest.mocked(cacheCognitoTokens); + const mockDispatchSignedInHubEvent = jest.mocked(dispatchSignedInHubEvent); + + const challengeName = 'WEB_AUTHN'; + const signInSession = '123456'; + const { username } = authAPITestParams.user1; + const challengeParameters: Record = { + CREDENTIAL_REQUEST_OPTIONS: passkeyCredentialRequestOptions, + }; + + const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAssertionResponse); + const mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAttestationResponse); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockGetIsPasskeySupported.mockReturnValue(true); + mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse.mockImplementation( + () => undefined, + ); + mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse.mockImplementation( + () => undefined, + ); + }); + + beforeEach(() => { + mockCreateRespondToAuthChallengeClient.mockReturnValueOnce( + mockRespondToAuthChallenge, + ); + navigatorCredentialsGetSpy.mockResolvedValue(passkeyGetResult); + }); + + afterEach(() => { + mockRespondToAuthChallenge.mockReset(); + mockCreateRespondToAuthChallengeClient.mockClear(); + }); + + it('should throw an error when username is not available in state', async () => { + mockStoreGetState.mockReturnValue({ + challengeName, + signInSession, + }); + expect.assertions(2); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + it('should throw an error when CREDENTIAL_REQUEST_OPTIONS is empty', async () => { + expect.assertions(2); + try { + await handleWebAuthnSignInResult({}); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + + it('should throw an error when challenge name is not WEB_AUTHN', async () => { + mockStoreGetState.mockReturnValue({ + signInSession, + username, + challengeName: 'SMS_MFA', + }); + expect.assertions(2); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + + it('should call RespondToAuthChallenge with correct values', async () => { + mockStoreGetState.mockReturnValue({ + username, + challengeName, + signInSession, + }); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + // __ we don't care about this error + } + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'WEB_AUTHN', + ChallengeResponses: { + USERNAME: username, + CREDENTIAL: JSON.stringify(passkeyGetResultJson), + }, + ClientId: expect.any(String), + Session: signInSession, + }, + ); + }); + + it('should return nextStep DONE after authentication', async () => { + mockStoreGetState.mockReturnValue({ + username, + challengeName, + signInSession, + }); + mockRespondToAuthChallenge.mockResolvedValue( + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + mockCacheCognitoTokens.mockResolvedValue(undefined); + mockDispatchSignedInHubEvent.mockResolvedValue(undefined); + + const result = await handleWebAuthnSignInResult(challengeParameters); + + expect(result.isSignedIn).toBe(true); + expect(result.nextStep.signInStep).toBe('DONE'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts b/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts new file mode 100644 index 00000000000..98c02e16e5f --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts @@ -0,0 +1,65 @@ +import { autoSignInUserConfirmed } from '../../../../../src/providers/cognito/utils/signUpHelpers'; +import { authAPITestParams } from '../../testUtils/authApiTestParams'; +import { signInWithUserAuth } from '../../../../../src/providers/cognito/apis/signInWithUserAuth'; +import { signIn } from '../../../../../src/providers/cognito/apis/signIn'; +import { SignInInput } from '../../../../../src/providers/cognito/types/inputs'; + +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); + +const { user1 } = authAPITestParams; + +jest.mock('../../../../../src/providers/cognito/apis/signInWithUserAuth'); +jest.mock('../../../../../src/providers/cognito/apis/signIn'); + +describe('autoSignInUserConfirmed()', () => { + const mockSignInWithUserAuth = jest.mocked(signInWithUserAuth); + const mockSignIn = jest.mocked(signIn); + + jest.useFakeTimers(); + + afterEach(() => { + jest.runAllTimers(); + }); + + beforeEach(() => { + mockSignInWithUserAuth.mockReset(); + mockSignIn.mockReset(); + }); + + beforeAll(() => { + mockSignInWithUserAuth.mockImplementation(jest.fn()); + mockSignIn.mockImplementation(jest.fn()); + }); + + it('should call the correct API with authFlowType USER_AUTH', () => { + const signInInput: SignInInput = { + username: user1.username, + options: { + authFlowType: 'USER_AUTH', + }, + }; + + autoSignInUserConfirmed(signInInput)(); + + expect(mockSignInWithUserAuth).toHaveBeenCalledTimes(1); + expect(mockSignInWithUserAuth).toHaveBeenCalledWith(signInInput); + + expect(mockSignIn).not.toHaveBeenCalled(); + }); + + it('should call the correct API with default authFlowType', () => { + const signInInput: SignInInput = { + username: user1.username, + }; + + autoSignInUserConfirmed(signInInput)(); + + expect(mockSignInWithUserAuth).not.toHaveBeenCalled(); + + expect(mockSignIn).toHaveBeenCalledTimes(1); + expect(mockSignIn).toHaveBeenCalledWith(signInInput); + }); +}); diff --git a/packages/auth/package.json b/packages/auth/package.json index 42771efb06b..d7551e9cbe2 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/auth", - "version": "6.6.2", + "version": "6.8.0", "description": "Auth category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -97,7 +97,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", "@jest/test-sequencer": "^29.7.0", "typescript": "5.0.2" diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts new file mode 100644 index 00000000000..caf8307f447 --- /dev/null +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { + CompleteWebAuthnRegistrationException, + StartWebAuthnRegistrationException, +} from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { registerPasskey } from '../utils'; +import { + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, +} from '../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { PasskeyError } from '../utils/passkey/errors'; +import { AuthError } from '../../errors/AuthError'; +import { assertValidCredentialCreationOptions } from '../utils/passkey/types'; + +/** + * Registers a new passkey for an authenticated user + * + * @returns Promise + * @throws - {@link PasskeyError}: + * - Thrown when intermediate state is invalid + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link StartWebAuthnRegistrationException} + * - Thrown due to a service error retrieving WebAuthn registration options + * @throws - {@link CompleteWebAuthnRegistrationException} + * - Thrown due to a service error when verifying WebAuthn registration result + */ +export async function associateWebAuthnCredential(): Promise { + const authConfig = Amplify.getConfig().Auth?.Cognito; + + assertTokenProviderConfig(authConfig); + + const { userPoolEndpoint, userPoolId } = authConfig; + + const { tokens } = await fetchAuthSession(); + + assertAuthTokens(tokens); + + const startWebAuthnRegistration = createStartWebAuthnRegistrationClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { CredentialCreationOptions: credentialCreationOptions } = + await startWebAuthnRegistration( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.StartWebAuthnRegistration, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + }, + ); + + assertValidCredentialCreationOptions(credentialCreationOptions); + + const cred = await registerPasskey(credentialCreationOptions); + + const completeWebAuthnRegistration = createCompleteWebAuthnRegistrationClient( + { + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }, + ); + + await completeWebAuthnRegistration( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.CompleteWebAuthnRegistration, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + Credential: cred, + }, + ); +} diff --git a/packages/auth/src/client/apis/deleteWebAuthnCredential.ts b/packages/auth/src/client/apis/deleteWebAuthnCredential.ts new file mode 100644 index 00000000000..5e17d71fe38 --- /dev/null +++ b/packages/auth/src/client/apis/deleteWebAuthnCredential.ts @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { DeleteWebAuthnCredentialException } from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { DeleteWebAuthnCredentialInput } from '../../foundation/types'; +import { AuthError } from '../../errors/AuthError'; +import { deleteWebAuthnCredential as deleteWebAuthnCredentialFoundation } from '../../foundation/apis'; + +/** + * Delete a registered credential for an authenticated user by credentialId + * @param {DeleteWebAuthnCredentialInput} input The delete input parameters including the credentialId + * @returns Promise + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link DeleteWebAuthnCredentialException} + * - Thrown due to a service error when deleting a WebAuthn credential + */ +export async function deleteWebAuthnCredential( + input: DeleteWebAuthnCredentialInput, +): Promise { + return deleteWebAuthnCredentialFoundation(Amplify, input); +} diff --git a/packages/auth/src/client/apis/index.ts b/packages/auth/src/client/apis/index.ts new file mode 100644 index 00000000000..dd3d1acb548 --- /dev/null +++ b/packages/auth/src/client/apis/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { associateWebAuthnCredential } from './associateWebAuthnCredential'; +export { listWebAuthnCredentials } from './listWebAuthnCredentials'; +export { deleteWebAuthnCredential } from './deleteWebAuthnCredential'; diff --git a/packages/auth/src/client/apis/listWebAuthnCredentials.ts b/packages/auth/src/client/apis/listWebAuthnCredentials.ts new file mode 100644 index 00000000000..91ee2b2310f --- /dev/null +++ b/packages/auth/src/client/apis/listWebAuthnCredentials.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { ListWebAuthnCredentialsException } from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from '../../foundation/types'; +import { AuthError } from '../../errors/AuthError'; +import { listWebAuthnCredentials as listWebAuthnCredentialsFoundation } from '../../foundation/apis'; + +/** + * Lists registered credentials for an authenticated user + * + * @param {ListWebAuthnCredentialsInput} input The list input parameters including page size and next token. + * @returns Promise + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link ListWebAuthnCredentialsException} + * - Thrown due to a service error when listing WebAuthn credentials + */ +export async function listWebAuthnCredentials( + input?: ListWebAuthnCredentialsInput, +): Promise { + return listWebAuthnCredentialsFoundation(Amplify, input); +} diff --git a/packages/auth/src/client/flows/shared/handlePasswordSRP.ts b/packages/auth/src/client/flows/shared/handlePasswordSRP.ts new file mode 100644 index 00000000000..77e298867df --- /dev/null +++ b/packages/auth/src/client/flows/shared/handlePasswordSRP.ts @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { AuthFlowType, ClientMetadata } from '../../../providers/cognito/types'; +import { + ChallengeParameters, + InitiateAuthCommandInput, + RespondToAuthChallengeCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getAuthenticationHelper } from '../../../providers/cognito/utils/srp'; +import { + handlePasswordVerifierChallenge, + retryOnResourceNotFoundException, + setActiveSignInUsername, +} from '../../../providers/cognito/utils/signInHelpers'; +import { createInitiateAuthClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { AuthFactorType } from '../../../providers/cognito/types/models'; + +interface HandlePasswordSRPInput { + username: string; + password: string; + clientMetadata: ClientMetadata | undefined; + config: CognitoUserPoolConfig; + tokenOrchestrator: AuthTokenOrchestrator; + authFlow: AuthFlowType; + preferredChallenge?: AuthFactorType; +} + +/** + * Handles the Password SRP (Secure Remote Password) authentication flow. + * This function can be used with both USER_SRP_AUTH and USER_AUTH flows. + * + * @param {Object} params - The parameters for the Password SRP authentication + * @param {string} params.username - The username for authentication + * @param {string} params.password - The user's password + * @param {ClientMetadata} [params.clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {AuthTokenOrchestrator} params.tokenOrchestrator - Token orchestrator for managing auth tokens + * @param {AuthFlowType} params.authFlow - The type of authentication flow ('USER_SRP_AUTH' or 'USER_AUTH') + * @param {AuthFactorType} [params.preferredChallenge] - Optional preferred challenge type when using USER_AUTH flow + * + * @returns {Promise} The authentication response + */ +export async function handlePasswordSRP({ + username, + password, + clientMetadata, + config, + tokenOrchestrator, + authFlow, + preferredChallenge, +}: HandlePasswordSRPInput): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const userPoolName = userPoolId?.split('_')[1] || ''; + const authenticationHelper = await getAuthenticationHelper(userPoolName); + + const authParameters: Record = { + USERNAME: username, + SRP_A: authenticationHelper.A.toString(16), + }; + + if (authFlow === 'USER_AUTH' && preferredChallenge) { + authParameters.PREFERRED_CHALLENGE = preferredChallenge; + } + + const UserContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const jsonReq: InitiateAuthCommandInput = { + AuthFlow: authFlow, + AuthParameters: authParameters, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData, + }; + + const initiateAuth = createInitiateAuthClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const resp = await initiateAuth( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), + }, + jsonReq, + ); + + const { ChallengeParameters: challengeParameters, Session: session } = resp; + const activeUsername = challengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + if (resp.ChallengeName === 'PASSWORD_VERIFIER') { + return retryOnResourceNotFoundException( + handlePasswordVerifierChallenge, + [ + password, + challengeParameters as ChallengeParameters, + clientMetadata, + session, + authenticationHelper, + config, + tokenOrchestrator, + ], + activeUsername, + tokenOrchestrator, + ); + } + + return resp; +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts new file mode 100644 index 00000000000..3ea0af6efd2 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { RespondToAuthChallengeCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +/** + * Handles the SELECT_CHALLENGE response for authentication. + * Initiates the selected authentication challenge based on user choice. + * + * @param {Object} params - The parameters for handling the selected challenge + * @param {string} params.username - The username for authentication + * @param {string} params.session - The current authentication session token + * @param {string} params.selectedChallenge - The challenge type selected by the user + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {ClientMetadata} [params.clientMetadata] - Optional metadata to be sent with auth requests + * + * @returns {Promise} The challenge response + */ +export async function initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config, + clientMetadata, +}: { + username: string; + session: string; + selectedChallenge: string; + config: CognitoUserPoolConfig; + clientMetadata?: ClientMetadata; +}): Promise { + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: config.userPoolEndpoint, + }), + }); + + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(config.userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + USERNAME: username, + ANSWER: selectedChallenge, + }, + ClientId: config.userPoolClientId, + Session: session, + ClientMetadata: clientMetadata, + }, + ); +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts new file mode 100644 index 00000000000..50858764c79 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { RespondToAuthChallengeCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { setActiveSignInUsername } from '../../../providers/cognito/utils/signInHelpers'; + +/** + * Handles the SELECT_CHALLENGE response specifically for Password authentication. + * This function combines the SELECT_CHALLENGE flow with standard password authentication. + * + * @param {string} username - The username for authentication + * @param {string} password - The user's password + * @param {ClientMetadata} [clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} config - Cognito User Pool configuration + * @param {string} session - The current authentication session token + * + * @returns {Promise} The challenge response + */ +export async function handleSelectChallengeWithPassword( + username: string, + password: string, + clientMetadata: ClientMetadata | undefined, + config: CognitoUserPoolConfig, + session: string, +): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + + const authParameters: Record = { + ANSWER: 'PASSWORD', + USERNAME: username, + PASSWORD: password, + }; + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: authParameters, + ClientId: userPoolClientId, + ClientMetadata: clientMetadata, + Session: session, + UserContextData: userContextData, + }, + ); + + const activeUsername = response.ChallengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts new file mode 100644 index 00000000000..1a463e60a68 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { + ChallengeParameters, + RespondToAuthChallengeCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getAuthenticationHelper } from '../../../providers/cognito/utils/srp'; +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { + handlePasswordVerifierChallenge, + retryOnResourceNotFoundException, + setActiveSignInUsername, +} from '../../../providers/cognito/utils/signInHelpers'; + +/** + * Handles the SELECT_CHALLENGE response specifically for Password SRP authentication. + * This function combines the SELECT_CHALLENGE flow with Password SRP protocol. + * + * @param {string} username - The username for authentication + * @param {string} password - The user's password + * @param {ClientMetadata} [clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} config - Cognito User Pool configuration + * @param {string} session - The current authentication session token + * @param {AuthTokenOrchestrator} tokenOrchestrator - Token orchestrator for managing auth tokens + * + * @returns {Promise} The challenge response + */ +export async function handleSelectChallengeWithPasswordSRP( + username: string, + password: string, + clientMetadata: ClientMetadata | undefined, + config: CognitoUserPoolConfig, + session: string, + tokenOrchestrator: AuthTokenOrchestrator, +): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const userPoolName = userPoolId.split('_')[1] || ''; + + const authenticationHelper = await getAuthenticationHelper(userPoolName); + + const authParameters: Record = { + ANSWER: 'PASSWORD_SRP', + USERNAME: username, + SRP_A: authenticationHelper.A.toString(16), + }; + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: authParameters, + ClientId: userPoolClientId, + ClientMetadata: clientMetadata, + Session: session, + UserContextData: userContextData, + }, + ); + + const activeUsername = response.ChallengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + + if (response.ChallengeName === 'PASSWORD_VERIFIER') { + return retryOnResourceNotFoundException( + handlePasswordVerifierChallenge, + [ + password, + response.ChallengeParameters as ChallengeParameters, + clientMetadata, + response.Session, + authenticationHelper, + config, + tokenOrchestrator, + ], + activeUsername, + tokenOrchestrator, + ); + } + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts new file mode 100644 index 00000000000..753ac66db04 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts @@ -0,0 +1,125 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { AuthFactorType } from '../../../providers/cognito/types/models'; +import { + InitiateAuthCommandInput, + InitiateAuthCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { createInitiateAuthClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { handlePasswordSRP } from '../shared/handlePasswordSRP'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; +import { AuthValidationErrorCode } from '../../../errors/types/validation'; +import { setActiveSignInUsername } from '../../../providers/cognito/utils/signInHelpers'; + +export interface HandleUserAuthFlowInput { + username: string; + config: CognitoUserPoolConfig; + tokenOrchestrator: AuthTokenOrchestrator; + clientMetadata?: Record; + preferredChallenge?: AuthFactorType; + password?: string; + session?: string; +} + +/** + * Handles user authentication flow with configurable challenge preferences. + * Supports AuthFactorType challenges through the USER_AUTH flow. + * + * @param {HandleUserAuthFlowInput} params - Authentication flow parameters + * @param {string} params.username - The username for authentication + * @param {Record} [params.clientMetadata] - Optional metadata to pass to authentication service + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {AuthTokenOrchestrator} params.tokenOrchestrator - Manages authentication tokens and device tracking + * @param {AuthFactorType} [params.preferredChallenge] - Optional preferred authentication method + * @param {string} [params.password] - Required when preferredChallenge is 'PASSWORD' or 'PASSWORD_SRP' + * + * @returns {Promise} The authentication response from Cognito + */ +export async function handleUserAuthFlow({ + username, + clientMetadata, + config, + tokenOrchestrator, + preferredChallenge, + password, + session, +}: HandleUserAuthFlowInput) { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const UserContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + const authParameters: Record = { USERNAME: username }; + + if (preferredChallenge) { + if (preferredChallenge === 'PASSWORD_SRP') { + assertValidationError( + !!password, + AuthValidationErrorCode.EmptySignInPassword, + ); + + return handlePasswordSRP({ + username, + password, + clientMetadata, + config, + tokenOrchestrator, + authFlow: 'USER_AUTH', + preferredChallenge, + }); + } + + if (preferredChallenge === 'PASSWORD') { + assertValidationError( + !!password, + AuthValidationErrorCode.EmptySignInPassword, + ); + authParameters.PASSWORD = password; + } + + authParameters.PREFERRED_CHALLENGE = preferredChallenge; + } + + const jsonReq: InitiateAuthCommandInput = { + AuthFlow: 'USER_AUTH', + AuthParameters: authParameters, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData, + }; + + if (session) { + jsonReq.Session = session; + } + + const initiateAuth = createInitiateAuthClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await initiateAuth( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), + }, + jsonReq, + ); + + // Set the active username immediately after successful authentication attempt + // If a user starts a new sign-in while another sign-in is incomplete, + // this ensures we're tracking the correct user for subsequent auth challenges. + setActiveSignInUsername(username); + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts new file mode 100644 index 00000000000..2e67a52a5ab --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts @@ -0,0 +1,130 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { AuthErrorCodes } from '../../../common/AuthErrorStrings'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + ChallengeName, + ChallengeParameters, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { cacheCognitoTokens } from '../../../providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../providers/cognito/utils/dispatchSignedInHubEvent'; +import { + getNewDeviceMetadata, + getSignInResult, +} from '../../../providers/cognito/utils/signInHelpers'; +import { + cleanActiveSignInState, + setActiveSignInState, + signInStore, +} from '../../../client/utils/store'; +import { AuthSignInOutput } from '../../../types'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getPasskey } from '../../utils/passkey'; +import { + PasskeyErrorCode, + assertPasskeyError, +} from '../../utils/passkey/errors'; +import { AuthError } from '../../../errors/AuthError'; + +export async function handleWebAuthnSignInResult( + challengeParameters: ChallengeParameters, +): Promise { + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { username, signInSession, signInDetails, challengeName } = + signInStore.getState(); + + if (challengeName !== 'WEB_AUTHN' || !username) { + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: 'Unable to proceed due to invalid sign in state.', + }); + } + + const { CREDENTIAL_REQUEST_OPTIONS: credentialRequestOptions } = + challengeParameters; + + assertPasskeyError( + !!credentialRequestOptions, + PasskeyErrorCode.InvalidPasskeyAuthenticationOptions, + ); + + const cred = await getPasskey(JSON.parse(credentialRequestOptions)); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: authConfig.userPoolEndpoint, + }), + }); + + const { + ChallengeName: nextChallengeName, + ChallengeParameters: nextChallengeParameters, + AuthenticationResult: authenticationResult, + Session: nextSession, + } = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(authConfig.userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'WEB_AUTHN', + ChallengeResponses: { + USERNAME: username, + CREDENTIAL: JSON.stringify(cred), + }, + ClientId: authConfig.userPoolClientId, + Session: signInSession, + }, + ); + + setActiveSignInState({ + signInSession: nextSession, + username, + challengeName: nextChallengeName as ChallengeName, + signInDetails, + }); + + if (authenticationResult) { + await cacheCognitoTokens({ + ...authenticationResult, + username, + NewDeviceMetadata: await getNewDeviceMetadata({ + userPoolId: authConfig.userPoolId, + userPoolEndpoint: authConfig.userPoolEndpoint, + newDeviceMetadata: authenticationResult.NewDeviceMetadata, + accessToken: authenticationResult.AccessToken, + }), + signInDetails, + }); + cleanActiveSignInState(); + await dispatchSignedInHubEvent(); + + return { + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }; + } + + if (nextChallengeName === 'WEB_AUTHN') { + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: + 'Sequential WEB_AUTHN challenges returned from underlying service cannot be handled.', + }); + } + + return getSignInResult({ + challengeName: nextChallengeName as ChallengeName, + challengeParameters: nextChallengeParameters as ChallengeParameters, + }); +} diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/index.ts b/packages/auth/src/client/utils/index.ts similarity index 63% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/index.ts rename to packages/auth/src/client/utils/index.ts index 1f7db0aabb3..ef0913b2b8d 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/index.ts +++ b/packages/auth/src/client/utils/index.ts @@ -1,4 +1,4 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { getMultipartUploadHandlers } from './uploadHandlers'; +export { registerPasskey } from './passkey'; diff --git a/packages/auth/src/client/utils/passkey/errors.ts b/packages/auth/src/client/utils/passkey/errors.ts new file mode 100644 index 00000000000..288cb14e810 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -0,0 +1,214 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyError, + AmplifyErrorCode, + AmplifyErrorMap, + AmplifyErrorParams, + AssertionFunction, + createAssertionFunction, +} from '@aws-amplify/core/internals/utils'; + +export class PasskeyError extends AmplifyError { + constructor(params: AmplifyErrorParams) { + super(params); + + // Hack for making the custom error class work when transpiled to es5 + // TODO: Delete the following 2 lines after we change the build target to >= es2015 + this.constructor = PasskeyError; + Object.setPrototypeOf(this, PasskeyError.prototype); + } +} + +export enum PasskeyErrorCode { + // not supported + PasskeyNotSupported = 'PasskeyNotSupported', + // duplicate passkey + PasskeyAlreadyExists = 'PasskeyAlreadyExists', + // misconfigurations + InvalidPasskeyRegistrationOptions = 'InvalidPasskeyRegistrationOptions', + InvalidPasskeyAuthenticationOptions = 'InvalidPasskeyAuthenticationOptions', + RelyingPartyMismatch = 'RelyingPartyMismatch', + // failed credential creation / retrieval + PasskeyRegistrationFailed = 'PasskeyRegistrationFailed', + PasskeyRetrievalFailed = 'PasskeyRetrievalFailed', + // cancel / aborts + PasskeyRegistrationCanceled = 'PasskeyRegistrationCanceled', + PasskeyAuthenticationCanceled = 'PasskeyAuthenticationCanceled', + PasskeyOperationAborted = 'PasskeyOperationAborted', +} + +const notSupportedRecoverySuggestion = + 'Passkeys may not be supported on this device. Ensure your application is running in a secure context (HTTPS) and Web Authentication API is supported.'; +const abortOrCancelRecoverySuggestion = + 'User may have canceled the ceremony or another interruption has occurred. Check underlying error for details.'; +const misconfigurationRecoverySuggestion = + 'Ensure your user pool is configured to support the WEB_AUTHN as an authentication factor.'; + +const passkeyErrorMap: AmplifyErrorMap = { + [PasskeyErrorCode.PasskeyNotSupported]: { + message: 'Passkeys may not be supported on this device.', + recoverySuggestion: notSupportedRecoverySuggestion, + }, + [PasskeyErrorCode.InvalidPasskeyRegistrationOptions]: { + message: 'Invalid passkey registration options.', + recoverySuggestion: misconfigurationRecoverySuggestion, + }, + [PasskeyErrorCode.InvalidPasskeyAuthenticationOptions]: { + message: 'Invalid passkey authentication options.', + recoverySuggestion: misconfigurationRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyRegistrationFailed]: { + message: 'Device failed to create passkey.', + recoverySuggestion: notSupportedRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyRetrievalFailed]: { + message: 'Device failed to retrieve passkey.', + recoverySuggestion: + 'Passkeys may not be available on this device. Try an alternative authentication factor like PASSWORD, EMAIL_OTP, or SMS_OTP.', + }, + [PasskeyErrorCode.PasskeyAlreadyExists]: { + message: 'Passkey already exists in authenticator.', + recoverySuggestion: + 'Proceed with existing passkey or try again after deleting the credential.', + }, + [PasskeyErrorCode.PasskeyRegistrationCanceled]: { + message: 'Passkey registration ceremony has been canceled.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyAuthenticationCanceled]: { + message: 'Passkey authentication ceremony has been canceled.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyOperationAborted]: { + message: 'Passkey operation has been aborted.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.RelyingPartyMismatch]: { + message: 'Relying party does not match current domain.', + recoverySuggestion: + 'Ensure relying party identifier matches current domain.', + }, +}; + +export const assertPasskeyError: AssertionFunction = + createAssertionFunction(passkeyErrorMap, PasskeyError); + +/** + * Handle Passkey Authentication Errors + * https://w3c.github.io/webauthn/#sctn-get-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ + +export const handlePasskeyAuthenticationError = ( + err: unknown, +): PasskeyError => { + if (err instanceof PasskeyError) { + return err; + } + + if (err instanceof Error) { + if (err.name === 'NotAllowedError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyAuthenticationCanceled]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyAuthenticationCanceled, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return handlePasskeyError(err); +}; + +/** + * Handle Passkey Registration Errors + * https://w3c.github.io/webauthn/#sctn-create-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ +export const handlePasskeyRegistrationError = (err: unknown): PasskeyError => { + if (err instanceof PasskeyError) { + return err; + } + + if (err instanceof Error) { + // Duplicate Passkey + if (err.name === 'InvalidStateError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyAlreadyExists]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyAlreadyExists, + message, + recoverySuggestion, + underlyingError: err, + }); + } + + // User Cancels Ceremony / Generic Catch All + if (err.name === 'NotAllowedError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyRegistrationCanceled]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyRegistrationCanceled, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return handlePasskeyError(err); +}; + +/** + * Handles Overlapping Passkey Errors Between Registration & Authentication + * https://w3c.github.io/webauthn/#sctn-create-request-exceptions + * https://w3c.github.io/webauthn/#sctn-get-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ +const handlePasskeyError = (err: unknown): PasskeyError => { + if (err instanceof Error) { + // Passkey Operation Aborted + if (err.name === 'AbortError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyOperationAborted]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyOperationAborted, + message, + recoverySuggestion, + underlyingError: err, + }); + } + // Relying Party / Domain Mismatch + if (err.name === 'SecurityError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.RelyingPartyMismatch]; + + return new PasskeyError({ + name: PasskeyErrorCode.RelyingPartyMismatch, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return new PasskeyError({ + name: AmplifyErrorCode.Unknown, + message: 'An unknown error has occurred.', + underlyingError: err, + }); +}; diff --git a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts new file mode 100644 index 00000000000..a6090da47ce --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const getIsPasskeySupported = () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts new file mode 100644 index 00000000000..c0d3674a8a4 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isBrowser } from '@aws-amplify/core/internals/utils'; + +/** + * Determines if passkey is supported in current context + * Will return false if executed in non-secure context + * @returns boolean + */ +export const getIsPasskeySupported = (): boolean => { + return ( + isBrowser() && + window.isSecureContext && + 'credentials' in navigator && + typeof window.PublicKeyCredential === 'function' + ); +}; diff --git a/packages/auth/src/client/utils/passkey/getPasskey.native.ts b/packages/auth/src/client/utils/passkey/getPasskey.native.ts new file mode 100644 index 00000000000..96f6662b590 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getPasskey.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const getPasskey = async () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/getPasskey.ts b/packages/auth/src/client/utils/passkey/getPasskey.ts new file mode 100644 index 00000000000..8a3ee7f3d6e --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getPasskey.ts @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyAuthenticationError, +} from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; +import { + deserializeJsonToPkcGetOptions, + serializePkcWithAssertionToJson, +} from './serde'; +import { + PasskeyGetOptionsJson, + assertCredentialIsPkcWithAuthenticatorAssertionResponse, +} from './types'; + +export const getPasskey = async (input: PasskeyGetOptionsJson) => { + try { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); + + const passkeyGetOptions = deserializeJsonToPkcGetOptions(input); + + const credential = await navigator.credentials.get({ + publicKey: passkeyGetOptions, + }); + + assertCredentialIsPkcWithAuthenticatorAssertionResponse(credential); + + return serializePkcWithAssertionToJson(credential); + } catch (err: unknown) { + throw handlePasskeyAuthenticationError(err); + } +}; diff --git a/packages/auth/src/client/utils/passkey/index.ts b/packages/auth/src/client/utils/passkey/index.ts new file mode 100644 index 00000000000..7f7d12728b7 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { registerPasskey } from './registerPasskey'; +export { getPasskey } from './getPasskey'; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.native.ts b/packages/auth/src/client/utils/passkey/registerPasskey.native.ts new file mode 100644 index 00000000000..15ab00dc290 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/registerPasskey.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const registerPasskey = async () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.ts b/packages/auth/src/client/utils/passkey/registerPasskey.ts new file mode 100644 index 00000000000..88ae3eacf2e --- /dev/null +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from './types'; +import { + deserializeJsonToPkcCreationOptions, + serializePkcWithAttestationToJson, +} from './serde'; +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyRegistrationError, +} from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; + +/** + * Registers a new passkey for user + * @param input - PasskeyCreateOptionsJson + * @returns serialized PasskeyCreateResult + */ +export const registerPasskey = async ( + input: PasskeyCreateOptionsJson, +): Promise => { + try { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); + + const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); + + const credential = await navigator.credentials.create({ + publicKey: passkeyCreationOptions, + }); + + assertCredentialIsPkcWithAuthenticatorAttestationResponse(credential); + + return serializePkcWithAttestationToJson(credential); + } catch (err) { + throw handlePasskeyRegistrationError(err); + } +}; diff --git a/packages/auth/src/client/utils/passkey/serde.ts b/packages/auth/src/client/utils/passkey/serde.ts new file mode 100644 index 00000000000..ae672a22a06 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/serde.ts @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from '../../../foundation/convert'; + +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcAssertionResponse, + PkcAttestationResponse, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, +} from './types'; + +/** + * Deserializes Public Key Credential Creation Options JSON + * @param input PasskeyCreateOptionsJson + * @returns PublicKeyCredentialCreationOptions + */ +export const deserializeJsonToPkcCreationOptions = ( + input: PasskeyCreateOptionsJson, +): PublicKeyCredentialCreationOptions => { + const userIdBuffer = convertBase64UrlToArrayBuffer(input.user.id); + const challengeBuffer = convertBase64UrlToArrayBuffer(input.challenge); + const excludeCredentialsWithBuffer = (input.excludeCredentials || []).map( + excludeCred => ({ + ...excludeCred, + id: convertBase64UrlToArrayBuffer(excludeCred.id), + }), + ); + + return { + ...input, + excludeCredentials: excludeCredentialsWithBuffer, + challenge: challengeBuffer, + user: { + ...input.user, + id: userIdBuffer, + }, + }; +}; + +/** + * Serializes a Public Key Credential With Attestation to JSON + * @param input PasskeyCreateResult + * @returns PasskeyCreateResultJson + */ +export const serializePkcWithAttestationToJson = ( + input: PkcWithAuthenticatorAttestationResponse, +): PasskeyCreateResultJson => { + const response: PkcAttestationResponse = { + clientDataJSON: convertArrayBufferToBase64Url( + input.response.clientDataJSON, + ), + attestationObject: convertArrayBufferToBase64Url( + input.response.attestationObject, + ), + transports: input.response.getTransports(), + publicKeyAlgorithm: input.response.getPublicKeyAlgorithm(), + authenticatorData: convertArrayBufferToBase64Url( + input.response.getAuthenticatorData(), + ), + }; + + const publicKey = input.response.getPublicKey(); + + if (publicKey) { + response.publicKey = convertArrayBufferToBase64Url(publicKey); + } + + const resultJson: PasskeyCreateResultJson = { + type: input.type, + id: input.id, + rawId: convertArrayBufferToBase64Url(input.rawId), + clientExtensionResults: input.getClientExtensionResults(), + response, + }; + + if (input.authenticatorAttachment) { + resultJson.authenticatorAttachment = input.authenticatorAttachment; + } + + return resultJson; +}; + +/** + * Deserializes Public Key Credential Get Options JSON + * @param input PasskeyGetOptionsJson + * @returns PublicKeyCredentialRequestOptions + */ +export const deserializeJsonToPkcGetOptions = ( + input: PasskeyGetOptionsJson, +): PublicKeyCredentialRequestOptions => { + const challengeBuffer = convertBase64UrlToArrayBuffer(input.challenge); + const allowedCredentialsWithBuffer = (input.allowCredentials || []).map( + allowedCred => ({ + ...allowedCred, + id: convertBase64UrlToArrayBuffer(allowedCred.id), + }), + ); + + return { + ...input, + challenge: challengeBuffer, + allowCredentials: allowedCredentialsWithBuffer, + }; +}; + +/** + * Serializes a Public Key Credential With Attestation to JSON + * @param input PasskeyGetResult + * @returns PasskeyGetResultJson + */ +export const serializePkcWithAssertionToJson = ( + input: PkcWithAuthenticatorAssertionResponse, +): PasskeyGetResultJson => { + const response: PkcAssertionResponse = { + clientDataJSON: convertArrayBufferToBase64Url( + input.response.clientDataJSON, + ), + authenticatorData: convertArrayBufferToBase64Url( + input.response.authenticatorData, + ), + signature: convertArrayBufferToBase64Url(input.response.signature), + }; + + if (input.response.userHandle) { + response.userHandle = convertArrayBufferToBase64Url( + input.response.userHandle, + ); + } + + const resultJson: PasskeyGetResultJson = { + id: input.id, + rawId: convertArrayBufferToBase64Url(input.rawId), + type: input.type, + clientExtensionResults: input.getClientExtensionResults(), + response, + }; + + if (input.authenticatorAttachment) { + resultJson.authenticatorAttachment = input.authenticatorAttachment; + } + + return resultJson; +}; diff --git a/packages/auth/src/client/utils/passkey/types/index.ts b/packages/auth/src/client/utils/passkey/types/index.ts new file mode 100644 index 00000000000..2c9df968218 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/index.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyErrorCode, assertPasskeyError } from '../errors'; + +/** + * Passkey Create Types + */ + +export { + PkcAttestationResponse, + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + assertValidCredentialCreationOptions, +} from './shared'; + +export type PkcWithAuthenticatorAttestationResponse = Omit< + PublicKeyCredential, + 'response' +> & { + response: AuthenticatorAttestationResponse; +}; + +export function assertCredentialIsPkcWithAuthenticatorAttestationResponse( + credential: any, +): asserts credential is PkcWithAuthenticatorAttestationResponse { + assertPasskeyError( + credential && + credential instanceof PublicKeyCredential && + credential.response instanceof AuthenticatorAttestationResponse, + PasskeyErrorCode.PasskeyRegistrationFailed, + ); +} + +/** + * Passkey Get Types + */ + +export { + PkcAssertionResponse, + PasskeyGetOptionsJson, + PasskeyGetResultJson, +} from './shared'; + +export type PkcWithAuthenticatorAssertionResponse = Omit< + PublicKeyCredential, + 'response' +> & { + response: AuthenticatorAssertionResponse; +}; + +export function assertCredentialIsPkcWithAuthenticatorAssertionResponse( + credential: any, +): asserts credential is PkcWithAuthenticatorAssertionResponse { + assertPasskeyError( + credential && + credential instanceof PublicKeyCredential && + credential.response instanceof AuthenticatorAssertionResponse, + PasskeyErrorCode.PasskeyRetrievalFailed, + ); +} diff --git a/packages/auth/src/client/utils/passkey/types/shared.ts b/packages/auth/src/client/utils/passkey/types/shared.ts new file mode 100644 index 00000000000..847118d7e25 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyErrorCode, assertPasskeyError } from '../errors'; + +type PasskeyTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'; +type UserVerificationRequirement = 'discouraged' | 'preferred' | 'required'; +type AttestationConveyancePreference = + | 'direct' + | 'enterprise' + | 'indirect' + | 'none'; + +interface PkcDescriptor { + type: 'public-key'; + id: T; + transports?: PasskeyTransport[]; +} + +/** + * Passkey Create Types + */ +export interface PasskeyCreateOptionsJson { + challenge: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + pubKeyCredParams: { + alg: number; + type: 'public-key'; + }[]; + timeout?: number; + excludeCredentials?: PkcDescriptor[]; + authenticatorSelection?: { + requireResidentKey: boolean; + residentKey: UserVerificationRequirement; + userVerification: UserVerificationRequirement; + }; + attestation?: AttestationConveyancePreference; + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + }; +} + +export interface PkcAttestationResponse { + clientDataJSON: T; + attestationObject: T; + transports: string[]; + publicKey?: string; + publicKeyAlgorithm: number; + authenticatorData: T; +} +export interface PasskeyCreateResult { + id: string; + rawId: ArrayBuffer; + type: 'public-key'; + response: PkcAttestationResponse; +} + +export interface PasskeyCreateResultJson { + id: string; + rawId: string; + type: string; + clientExtensionResults: { + appId?: boolean; + credProps?: { rk?: boolean }; + hmacCreateSecret?: boolean; + }; + authenticatorAttachment?: string; + response: PkcAttestationResponse; +} + +export function assertValidCredentialCreationOptions( + credentialCreationOptions: any, +): asserts credentialCreationOptions is PasskeyCreateOptionsJson { + assertPasskeyError( + [ + !!credentialCreationOptions, + !!credentialCreationOptions?.challenge, + !!credentialCreationOptions?.user, + !!credentialCreationOptions?.rp, + !!credentialCreationOptions?.pubKeyCredParams, + ].every(Boolean), + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, + ); +} + +/** + * Passkey Get Types + */ +export interface PasskeyGetOptionsJson { + challenge: string; + rpId: string; + timeout: number; + allowCredentials: PkcDescriptor[]; + userVerification: UserVerificationRequirement; +} + +export interface PkcAssertionResponse { + authenticatorData: T; + clientDataJSON: T; + signature: T; + userHandle?: T; +} + +export interface PasskeyGetResult { + id: string; + rawId: ArrayBuffer; + type: 'public-key'; + response: PkcAssertionResponse; +} +export interface PasskeyGetResultJson { + id: string; + rawId: string; + type: string; + clientExtensionResults: { + appId?: boolean; + credProps?: { rk?: boolean }; + hmacCreateSecret?: boolean; + }; + authenticatorAttachment?: string; + response: PkcAssertionResponse; +} diff --git a/packages/auth/src/client/utils/store/autoSignInStore.ts b/packages/auth/src/client/utils/store/autoSignInStore.ts new file mode 100644 index 00000000000..2cd93f62bc8 --- /dev/null +++ b/packages/auth/src/client/utils/store/autoSignInStore.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Reducer, Store } from './types'; + +type AutoSignInAction = + | { type: 'START' } + | { type: 'SET_USERNAME'; value: string } + | { type: 'SET_SESSION'; value?: string } + | { type: 'RESET' }; + +interface AutoSignInState { + active: boolean; + username?: string; + session?: string; +} + +function defaultState(): AutoSignInState { + return { + active: false, + }; +} + +const autoSignInReducer: Reducer = ( + state: AutoSignInState, + action: AutoSignInAction, +): AutoSignInState => { + switch (action.type) { + case 'SET_USERNAME': + return { + ...state, + username: action.value, + }; + case 'SET_SESSION': + return { + ...state, + session: action.value, + }; + case 'START': + return { + ...state, + active: true, + }; + case 'RESET': + return defaultState(); + default: + return state; + } +}; + +const createAutoSignInStore: Store = ( + reducer: Reducer, +) => { + let currentState = reducer(defaultState(), { type: 'RESET' }); + + return { + getState: () => currentState, + dispatch: action => { + currentState = reducer(currentState, action); + }, + }; +}; + +export const autoSignInStore = createAutoSignInStore(autoSignInReducer); diff --git a/packages/auth/src/client/utils/store/index.ts b/packages/auth/src/client/utils/store/index.ts new file mode 100644 index 00000000000..b1070020ec5 --- /dev/null +++ b/packages/auth/src/client/utils/store/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './autoSignInStore'; +export * from './signInStore'; diff --git a/packages/auth/src/providers/cognito/utils/signInStore.ts b/packages/auth/src/client/utils/store/signInStore.ts similarity index 87% rename from packages/auth/src/providers/cognito/utils/signInStore.ts rename to packages/auth/src/client/utils/store/signInStore.ts index fd07cb15e6d..94311ce2b74 100644 --- a/packages/auth/src/providers/cognito/utils/signInStore.ts +++ b/packages/auth/src/client/utils/store/signInStore.ts @@ -1,9 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CognitoAuthSignInDetails } from '../types'; +import { CognitoAuthSignInDetails } from '../../../providers/cognito/types'; import { ChallengeName } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { Reducer, Store } from './types'; + // TODO: replace all of this implementation with state machines interface SignInState { username?: string; @@ -19,13 +21,6 @@ type SignInAction = | { type: 'SET_CHALLENGE_NAME'; value?: ChallengeName } | { type: 'SET_SIGN_IN_SESSION'; value?: string }; -type Store = (reducer: Reducer) => { - getState(): ReturnType>; - dispatch(action: Action): void; -}; - -type Reducer = (state: State, action: Action) => State; - const signInReducer: Reducer = (state, action) => { switch (action.type) { case 'SET_SIGN_IN_SESSION': diff --git a/packages/auth/src/client/utils/store/types.ts b/packages/auth/src/client/utils/store/types.ts new file mode 100644 index 00000000000..bce088ebf2a --- /dev/null +++ b/packages/auth/src/client/utils/store/types.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type Store = (reducer: Reducer) => { + getState(): ReturnType>; + dispatch(action: Action): void; +}; + +export type Reducer = (state: State, action: Action) => State; diff --git a/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts new file mode 100644 index 00000000000..c47b13ea303 --- /dev/null +++ b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { createDeleteWebAuthnCredentialClient } from '../factories/serviceClients/cognitoIdentityProvider'; +import { DeleteWebAuthnCredentialInput } from '../types'; + +export async function deleteWebAuthnCredential( + amplify: AmplifyClassV6, + input: DeleteWebAuthnCredentialInput, +): Promise { + const authConfig = amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + const { tokens } = await amplify.Auth.fetchAuthSession(); + assertAuthTokens(tokens); + + const deleteWebAuthnCredentialResult = createDeleteWebAuthnCredentialClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + await deleteWebAuthnCredentialResult( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.DeleteWebAuthnCredential, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + CredentialId: input.credentialId, + }, + ); +} diff --git a/packages/auth/src/foundation/apis/index.ts b/packages/auth/src/foundation/apis/index.ts new file mode 100644 index 00000000000..59d61c0cc16 --- /dev/null +++ b/packages/auth/src/foundation/apis/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { listWebAuthnCredentials } from './listWebAuthnCredentials'; +export { deleteWebAuthnCredential } from './deleteWebAuthnCredential'; diff --git a/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts new file mode 100644 index 00000000000..5016833bdc6 --- /dev/null +++ b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { createListWebAuthnCredentialsClient } from '../factories/serviceClients/cognitoIdentityProvider'; +import { + AuthWebAuthnCredential, + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from '../types'; + +export async function listWebAuthnCredentials( + amplify: AmplifyClassV6, + input?: ListWebAuthnCredentialsInput, +): Promise { + const authConfig = amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + + const { tokens } = await amplify.Auth.fetchAuthSession(); + assertAuthTokens(tokens); + + const listWebAuthnCredentialsResult = createListWebAuthnCredentialsClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { Credentials: commandCredentials = [], NextToken: nextToken } = + await listWebAuthnCredentialsResult( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.ListWebAuthnCredentials, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + MaxResults: input?.pageSize, + NextToken: input?.nextToken, + }, + ); + + const credentials: AuthWebAuthnCredential[] = commandCredentials.map( + item => ({ + credentialId: item.CredentialId, + friendlyCredentialName: item.FriendlyCredentialName, + relyingPartyId: item.RelyingPartyId, + authenticatorAttachment: item.AuthenticatorAttachment, + authenticatorTransports: item.AuthenticatorTransports, + createdAt: item.CreatedAt ? new Date(item.CreatedAt * 1000) : undefined, + }), + ); + + return { + credentials, + nextToken, + }; +} diff --git a/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts b/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts new file mode 100644 index 00000000000..981437f5a0a --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { base64Encoder } from '@aws-amplify/core/internals/utils'; + +// https://datatracker.ietf.org/doc/html/rfc4648#page-7 + +/** + * Converts an ArrayBuffer to a base64url encoded string + * @param buffer - the ArrayBuffer instance of a Uint8Array + * @returns string - a base64url encoded string + */ +export const convertArrayBufferToBase64Url = (buffer: ArrayBuffer): string => { + return base64Encoder.convert(new Uint8Array(buffer), { + urlSafe: true, + skipPadding: true, + }); +}; diff --git a/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts b/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts new file mode 100644 index 00000000000..987d57eff66 --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { base64Decoder } from '@aws-amplify/core/internals/utils'; + +/** + * Converts a base64url encoded string to an ArrayBuffer + * @param base64url - a base64url encoded string + * @returns ArrayBuffer + */ +export const convertBase64UrlToArrayBuffer = ( + base64url: string, +): ArrayBuffer => { + return Uint8Array.from( + base64Decoder.convert(base64url, { urlSafe: true }), + x => x.charCodeAt(0), + ).buffer; +}; diff --git a/packages/auth/src/foundation/convert/base64url/index.ts b/packages/auth/src/foundation/convert/base64url/index.ts new file mode 100644 index 00000000000..c4804b38a17 --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { convertArrayBufferToBase64Url } from './convertArrayBufferToBase64Url'; +export { convertBase64UrlToArrayBuffer } from './convertBase64UrlToArrayBuffer'; diff --git a/packages/auth/src/foundation/convert/index.ts b/packages/auth/src/foundation/convert/index.ts new file mode 100644 index 00000000000..7fea0c7c87c --- /dev/null +++ b/packages/auth/src/foundation/convert/index.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from './base64url'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts new file mode 100644 index 00000000000..f86ad95da2f --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + CompleteWebAuthnRegistrationCommandInput, + CompleteWebAuthnRegistrationCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createCompleteWebAuthnRegistrationClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'CompleteWebAuthnRegistration', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts new file mode 100644 index 00000000000..6e399cc3f39 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + DeleteWebAuthnCredentialCommandInput, + DeleteWebAuthnCredentialCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createDeleteWebAuthnCredentialClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'DeleteWebAuthnCredential', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts new file mode 100644 index 00000000000..60a864eb2e8 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + ListWebAuthnCredentialsCommandInput, + ListWebAuthnCredentialsCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createListWebAuthnCredentialsClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'ListWebAuthnCredentials', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts index e77676bab1d..ca9a2fda127 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts @@ -1,24 +1,61 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; +import { + HttpResponse, + parseJsonBody, + parseJsonError, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { validationErrorMap } from '../../../../common/AuthErrorStrings'; +import { AuthError } from '../../../../errors/AuthError'; +import { AuthValidationErrorCode } from '../../../../errors/types/validation'; +import { assertServiceError } from '../../../../errors/utils/assertServiceError'; +import { SignUpException } from '../../../../providers/cognito/types/errors'; +import { createUserPoolSerializer } from './shared/serde'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; import { ServiceClientFactoryInput, SignUpCommandInput, SignUpCommandOutput, } from './types'; -import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; -import { cognitoUserPoolTransferHandler } from './shared/handler'; -import { - createUserPoolDeserializer, - createUserPoolSerializer, -} from './shared/serde'; + +export const createSignUpClientDeserializer = + (): ((response: HttpResponse) => Promise) => + async (response: HttpResponse): Promise => { + if (response.statusCode >= 300) { + const error = await parseJsonError(response); + assertServiceError(error); + + if ( + // Missing Password Error + // 1 validation error detected: Value at 'password'failed to satisfy constraint: Member must not be null + error.name === SignUpException.InvalidParameterException && + /'password'/.test(error.message) && + /Member must not be null/.test(error.message) + ) { + const name = AuthValidationErrorCode.EmptySignUpPassword; + const { message, recoverySuggestion } = validationErrorMap[name]; + throw new AuthError({ + name, + message, + recoverySuggestion, + }); + } + + throw new AuthError({ name: error.name, message: error.message }); + } + + return parseJsonBody(response); + }; export const createSignUpClient = (config: ServiceClientFactoryInput) => composeServiceApi( cognitoUserPoolTransferHandler, createUserPoolSerializer('SignUp'), - createUserPoolDeserializer(), + createSignUpClientDeserializer(), { ...DEFAULT_SERVICE_CLIENT_API_CONFIG, ...config, diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts new file mode 100644 index 00000000000..453efccd8f0 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + ServiceClientFactoryInput, + StartWebAuthnRegistrationCommandInput, + StartWebAuthnRegistrationCommandOutput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createStartWebAuthnRegistrationClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'StartWebAuthnRegistration', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts index 2b93cd09150..c8db070223e 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -24,3 +24,7 @@ export { createVerifyUserAttributeClient } from './createVerifyUserAttributeClie export { createUpdateDeviceStatusClient } from './createUpdateDeviceStatusClient'; export { createListDevicesClient } from './createListDevicesClient'; export { createDeleteUserAttributesClient } from './createDeleteUserAttributesClient'; +export { createStartWebAuthnRegistrationClient } from './createStartWebAuthnRegistrationClient'; +export { createCompleteWebAuthnRegistrationClient } from './createCompleteWebAuthnRegistrationClient'; +export { createListWebAuthnCredentialsClient } from './createListWebAuthnCredentialsClient'; +export { createDeleteWebAuthnCredentialClient } from './createDeleteWebAuthnCredentialClient'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts index 81f22df9312..070789e898f 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts @@ -30,7 +30,11 @@ type ClientOperation = | 'DeleteUserAttributes' | 'UpdateDeviceStatus' | 'ListDevices' - | 'RevokeToken'; + | 'RevokeToken' + | 'StartWebAuthnRegistration' + | 'CompleteWebAuthnRegistration' + | 'ListWebAuthnCredentials' + | 'DeleteWebAuthnCredential'; export const createUserPoolSerializer = (operation: ClientOperation) => diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts new file mode 100644 index 00000000000..3a45bd9abbf --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export enum StartWebAuthnRegistrationException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + LimitExceededException = 'LimitExceededException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnConfigurationMissingException = 'WebAuthnConfigurationMissingException', +} + +export enum CompleteWebAuthnRegistrationException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + LimitExceededException = 'LimitExceededException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnChallengeNotFoundException = 'WebAuthnChallengeNotFoundException', + WebAuthnRelyingPartyMismatchException = 'WebAuthnRelyingPartyMismatchException', + WebAuthnClientMismatchException = 'WebAuthnClientMismatchException', + WebAuthnOriginNotAllowedException = 'WebAuthnOriginNotAllowedException', + WebAuthnCredentialNotSupportedException = 'WebAuthnCredentialNotSupportedException', +} + +export enum ListWebAuthnCredentialsException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', +} + +export enum DeleteWebAuthnCredentialException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', + ResourceNotFoundException = 'ResourceNotFoundException', +} diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts index 3374c6b6194..f39d3141184 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts @@ -1,4 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + export * from './sdk'; export * from './serviceClient'; +export * from './errors'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts index f7a1d4a483a..f4c5edd9f4d 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -7,16 +7,21 @@ import { MetadataBearer as __MetadataBearer } from '@aws-sdk/types'; export type ChallengeName = | 'SMS_MFA' + | 'SMS_OTP' | 'SOFTWARE_TOKEN_MFA' | 'EMAIL_OTP' | 'SELECT_MFA_TYPE' + | 'SELECT_CHALLENGE' | 'MFA_SETUP' + | 'PASSWORD' + | 'PASSWORD_SRP' | 'PASSWORD_VERIFIER' | 'CUSTOM_CHALLENGE' | 'DEVICE_SRP_AUTH' | 'DEVICE_PASSWORD_VERIFIER' | 'ADMIN_NO_SRP_AUTH' - | 'NEW_PASSWORD_REQUIRED'; + | 'NEW_PASSWORD_REQUIRED' + | 'WEB_AUTHN'; export type ChallengeParameters = { CODE_DELIVERY_DESTINATION?: string; @@ -27,6 +32,7 @@ export type ChallengeParameters = { PASSWORD_CLAIM_SIGNATURE?: string; MFAS_CAN_CHOOSE?: string; MFAS_CAN_SETUP?: string; + CREDENTIAL_REQUEST_OPTIONS?: string; } & Record; export type CognitoMFAType = 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'EMAIL_OTP'; @@ -56,6 +62,10 @@ declare enum ChallengeNameType { SELECT_MFA_TYPE = 'SELECT_MFA_TYPE', SMS_MFA = 'SMS_MFA', SOFTWARE_TOKEN_MFA = 'SOFTWARE_TOKEN_MFA', + PASSWORD = 'PASSWORD', + PASSWORD_SRP = 'PASSWORD_SRP', + WEB_AUTHN = 'WEB_AUTHN', + SMS_OTP = 'SMS_OTP', EMAIL_OTP = 'EMAIL_OTP', } declare enum DeliveryMediumType { @@ -696,7 +706,15 @@ export interface ConfirmSignUpRequest { /** *

      Represents the response from the server for the registration confirmation.

      */ -export type ConfirmSignUpResponse = Record; +export interface ConfirmSignUpResponse { + /** + *

      Your ConfirmSignUp request might produce a challenge that your user must + * respond to, for example a one-time code. The Session parameter tracks the + * session in the flow of challenge responses and requests. Include this parameter in + * RespondToAuthChallenge API requests.

      + */ + Session?: string; +} export type DeleteUserCommandInput = DeleteUserRequest; export interface DeleteUserCommandOutput extends DeleteUserResponse, @@ -1083,6 +1101,13 @@ export interface InitiateAuthRequest { *

      Contextual data such as the user's device fingerprint, IP address, or location used for evaluating the risk of an unexpected event by Amazon Cognito advanced security.

      */ UserContextData?: UserContextDataType; + + /** + *

      The optional session ID from a ConfirmSignUp API + * request. You can sign in a user directly from the sign-up process with the + * USER_AUTH authentication flow.

      + */ + Session?: string; } /** *

      Initiates the authentication response.

      @@ -1133,8 +1158,10 @@ export interface InitiateAuthResponse { */ ChallengeName?: ChallengeNameType | string; /** - *

      The session that should pass both ways in challenge-response calls to the service. If the caller must pass another challenge, they return a session with other challenge parameters. This session - * should be passed as it is to the next RespondToAuthChallenge API call.

      + *

      The session that should pass both ways in challenge-response calls to the service. If + * the caller must pass another challenge, they return a session with other challenge + * parameters. Include this session identifier in a RespondToAuthChallenge API + * request.

      */ Session?: string; /** @@ -1145,9 +1172,15 @@ export interface InitiateAuthResponse { ChallengeParameters?: Record; /** *

      The result of the authentication response. This result is only returned if the caller doesn't need to pass another challenge. If the caller does need to pass another challenge before it gets - * tokens, ChallengeName, ChallengeParameters, and Session are returned.

      + * tokens, ChallengeName, ChallengeParameters, AvailableChallenges, and Session are returned.

      */ AuthenticationResult?: AuthenticationResultType; + /** + *

      This response parameter prompts a user to select from multiple available challenges + * that they can complete authentication with. For example, they might be able to continue + * with passwordless authentication or with a one-time password from an SMS message.

      + */ + AvailableChallenges?: ChallengeNameType[]; } export type ListDevicesCommandInput = ListDevicesRequest; export interface ListDevicesCommandOutput @@ -1527,6 +1560,13 @@ export interface SignUpResponse { *

      The UUID of the authenticated user. This isn't the same as username.

      */ UserSub: string | undefined; + + /** + *

      A session Id that you can pass to ConfirmSignUp when you want to + * immediately sign in your user with the USER_AUTH flow after they complete + * sign-up.

      + */ + Session?: string; } /** *

      The type used for enabling software token MFA at the user level. If an MFA type is activated for a user, the user will be prompted for MFA during all sign-in attempts, unless device tracking @@ -1732,3 +1772,99 @@ export interface DeleteUserAttributesRequest { */ export type DeleteUserAttributesResponse = Record; export {}; + +export interface StartWebAuthnRegistrationRequest { + /** + * A valid access token that Amazon Cognito issued to the user whose passkey metadata you want to + * generate. + */ + AccessToken: string | undefined; +} + +export interface StartWebAuthnRegistrationResponse { + /** + * The information that a user can provide in their request to register with their + * passkey provider. + */ + CredentialCreationOptions: Record | undefined; +} + +export type StartWebAuthnRegistrationCommandInput = + StartWebAuthnRegistrationRequest; + +export interface StartWebAuthnRegistrationCommandOutput + extends StartWebAuthnRegistrationResponse, + __MetadataBearer {} + +export interface CompleteWebAuthnRegistrationRequest { + /** + * A valid access token that Amazon Cognito issued to the user whose passkey registration you want + * to verify. This information informs your user pool of the details of the user's + * successful registration with their passkey provider. + */ + AccessToken: string | undefined; + + /** + * A RegistrationResponseJSON public-key credential response from the + * user's passkey provider. + */ + Credential: Record | undefined; +} + +export type CompleteWebAuthnRegistrationResponse = Record; + +export type CompleteWebAuthnRegistrationCommandInput = + CompleteWebAuthnRegistrationRequest; + +export interface CompleteWebAuthnRegistrationCommandOutput + extends CompleteWebAuthnRegistrationResponse, + __MetadataBearer {} + +/** + *

      The request to list WebAuthN credentials.

      + */ +export interface ListWebAuthnCredentialsInput { + AccessToken: string | undefined; + NextToken?: string; + MaxResults?: number; +} + +export interface WebAuthnCredentialDescription { + CredentialId: string | undefined; + FriendlyCredentialName: string | undefined; + RelyingPartyId: string | undefined; + AuthenticatorAttachment?: string; + AuthenticatorTransports: string[] | undefined; + CreatedAt: number | undefined; +} + +/** + *

      The response containing the list of WebAuthN credentials.

      + */ +export interface ListWebAuthnCredentialsOutput { + Credentials: WebAuthnCredentialDescription[] | undefined; + NextToken?: string; +} + +export type ListWebAuthnCredentialsCommandInput = ListWebAuthnCredentialsInput; + +export interface ListWebAuthnCredentialsCommandOutput + extends ListWebAuthnCredentialsOutput, + __MetadataBearer {} + +/** + * The request to delete a WebAuthN credential. + */ +export interface DeleteWebAuthnCredentialInput { + AccessToken: string | undefined; + CredentialId: string | undefined; +} + +export type DeleteWebAuthnCredentialOutput = Record; + +export type DeleteWebAuthnCredentialCommandInput = + DeleteWebAuthnCredentialInput; + +export interface DeleteWebAuthnCredentialCommandOutput + extends DeleteWebAuthnCredentialOutput, + __MetadataBearer {} diff --git a/packages/auth/src/foundation/types/index.ts b/packages/auth/src/foundation/types/index.ts new file mode 100644 index 00000000000..cafbcffab9b --- /dev/null +++ b/packages/auth/src/foundation/types/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + ListWebAuthnCredentialsInput, + DeleteWebAuthnCredentialInput, +} from './inputs'; +export { ListWebAuthnCredentialsOutput } from './outputs'; +export { AuthWebAuthnCredential } from './models'; diff --git a/packages/auth/src/foundation/types/inputs.ts b/packages/auth/src/foundation/types/inputs.ts new file mode 100644 index 00000000000..6699cccd714 --- /dev/null +++ b/packages/auth/src/foundation/types/inputs.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Input type for Cognito listWebAuthnCredentials API. + */ +export interface ListWebAuthnCredentialsInput { + pageSize?: number; + nextToken?: string; +} + +export interface DeleteWebAuthnCredentialInput { + credentialId: string; +} diff --git a/packages/auth/src/foundation/types/models.ts b/packages/auth/src/foundation/types/models.ts new file mode 100644 index 00000000000..3183f305c4b --- /dev/null +++ b/packages/auth/src/foundation/types/models.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shape of a WebAuthn credential + */ +export interface AuthWebAuthnCredential { + credentialId: string | undefined; + friendlyCredentialName: string | undefined; + relyingPartyId: string | undefined; + authenticatorAttachment?: string; + authenticatorTransports: string[] | undefined; + createdAt: Date | undefined; +} diff --git a/packages/auth/src/foundation/types/outputs.ts b/packages/auth/src/foundation/types/outputs.ts new file mode 100644 index 00000000000..13604174687 --- /dev/null +++ b/packages/auth/src/foundation/types/outputs.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthWebAuthnCredential } from './models'; + +/** + * Output type for Cognito listWebAuthnCredentials API. + */ +export interface ListWebAuthnCredentialsOutput { + credentials: AuthWebAuthnCredential[]; + nextToken?: string; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 799492edb39..0ca9948aa9b 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -87,3 +87,17 @@ export { AuthTokens, JWT, } from '@aws-amplify/core'; + +export { associateWebAuthnCredential } from './client/apis'; + +export { + listWebAuthnCredentials, + deleteWebAuthnCredential, +} from './client/apis'; + +export { + AuthWebAuthnCredential, + DeleteWebAuthnCredentialInput, + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from './foundation/types'; diff --git a/packages/auth/src/providers/cognito/apis/autoSignIn.ts b/packages/auth/src/providers/cognito/apis/autoSignIn.ts index d10b4a8c820..6186ac159c9 100644 --- a/packages/auth/src/providers/cognito/apis/autoSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/autoSignIn.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { autoSignInStore } from '../../../client/utils/store'; import { AuthError } from '../../../errors/AuthError'; import { AUTO_SIGN_IN_EXCEPTION } from '../../../errors/constants'; import { AutoSignInCallback } from '../../../types/models'; @@ -114,6 +115,9 @@ export function setAutoSignIn(callback: AutoSignInCallback) { * * @internal */ -export function resetAutoSignIn() { - autoSignIn = initialAutoSignIn; +export function resetAutoSignIn(resetCallback = true) { + if (resetCallback) { + autoSignIn = initialAutoSignIn; + } + autoSignInStore.dispatch({ type: 'RESET' }); } diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index 2b577f1a1a9..ea2582a6d47 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts @@ -14,7 +14,7 @@ import { cleanActiveSignInState, setActiveSignInState, signInStore, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { AuthError } from '../../../errors/AuthError'; import { getNewDeviceMetadata, diff --git a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts index 92adf180210..c9633531908 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts @@ -14,15 +14,13 @@ import { AuthValidationErrorCode } from '../../../errors/types/validation'; import { ConfirmSignUpException } from '../types/errors'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; import { AutoSignInEventData } from '../types/models'; -import { - isAutoSignInStarted, - isAutoSignInUserUsingConfirmSignUp, - setAutoSignInStarted, -} from '../utils/signUpHelpers'; import { getAuthUserAgentValue } from '../../../utils'; import { getUserContextData } from '../utils/userContextData'; import { createConfirmSignUpClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; import { createCognitoUserPoolEndpointResolver } from '../factories'; +import { autoSignInStore } from '../../../client/utils/store'; + +import { resetAutoSignIn } from './autoSignIn'; /** * Confirms a new user account. @@ -65,7 +63,7 @@ export async function confirmSignUp( }), }); - await confirmSignUpClient( + const { Session: session } = await confirmSignUpClient( { region: getRegionFromUserPoolId(authConfig.userPoolId), userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignUp), @@ -88,16 +86,20 @@ export async function confirmSignUp( signUpStep: 'DONE', }, }; + const autoSignInStoreState = autoSignInStore.getState(); if ( - !isAutoSignInStarted() || - !isAutoSignInUserUsingConfirmSignUp(username) + !autoSignInStoreState.active || + autoSignInStoreState.username !== username ) { resolve(signUpOut); + resetAutoSignIn(); return; } + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); + const stopListener = HubInternal.listen( 'auth-internal', ({ payload }) => { @@ -109,7 +111,6 @@ export async function confirmSignUp( signUpStep: 'COMPLETE_AUTO_SIGN_IN', }, }); - setAutoSignInStarted(false); stopListener(); } }, diff --git a/packages/auth/src/providers/cognito/apis/signIn.ts b/packages/auth/src/providers/cognito/apis/signIn.ts index 10a9be79214..7fc23cfcc67 100644 --- a/packages/auth/src/providers/cognito/apis/signIn.ts +++ b/packages/auth/src/providers/cognito/apis/signIn.ts @@ -13,6 +13,8 @@ import { signInWithCustomAuth } from './signInWithCustomAuth'; import { signInWithCustomSRPAuth } from './signInWithCustomSRPAuth'; import { signInWithSRP } from './signInWithSRP'; import { signInWithUserPassword } from './signInWithUserPassword'; +import { signInWithUserAuth } from './signInWithUserAuth'; +import { resetAutoSignIn } from './autoSignIn'; /** * Signs a user in @@ -26,6 +28,12 @@ import { signInWithUserPassword } from './signInWithUserPassword'; * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. */ export async function signIn(input: SignInInput): Promise { + // Here we want to reset the store but not reassign the callback. + // The callback is reset when the underlying promise resolves or rejects. + // With the advent of session based sign in, this guarantees that the signIn API initiates a new auth flow, + // regardless of whether it is called for a user currently engaged in an active auto sign in session. + resetAutoSignIn(false); + const authFlowType = input.options?.authFlowType; await assertUserNotAuthenticated(); switch (authFlowType) { @@ -37,6 +45,8 @@ export async function signIn(input: SignInInput): Promise { return signInWithCustomAuth(input); case 'CUSTOM_WITH_SRP': return signInWithCustomSRPAuth(input); + case 'USER_AUTH': + return signInWithUserAuth(input); default: return signInWithSRP(input); } diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts index a666fba0acb..348c60870ae 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts @@ -24,7 +24,7 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts index a22f98b3804..4966cfaa9fa 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts @@ -26,7 +26,7 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, diff --git a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts index 9bb8d4deca7..4cff40e7cd7 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts @@ -30,11 +30,13 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { resetAutoSignIn } from './autoSignIn'; + /** * Signs a user in * @@ -104,6 +106,8 @@ export async function signInWithSRP( await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -116,6 +120,7 @@ export async function signInWithSRP( }); } catch (error) { cleanActiveSignInState(); + resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts new file mode 100644 index 00000000000..9ac1223a105 --- /dev/null +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { assertTokenProviderConfig } from '@aws-amplify/core/internals/utils'; + +import { AuthValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; +import { assertServiceError } from '../../../errors/utils/assertServiceError'; +import { + ChallengeName, + ChallengeParameters, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { + InitiateAuthException, + RespondToAuthChallengeException, +} from '../types/errors'; +import { + getActiveSignInUsername, + getNewDeviceMetadata, + getSignInResult, + getSignInResultFromError, +} from '../utils/signInHelpers'; +import { + CognitoAuthSignInDetails, + SignInWithUserAuthInput, + SignInWithUserAuthOutput, +} from '../types'; +import { + autoSignInStore, + cleanActiveSignInState, + setActiveSignInState, +} from '../../../client/utils/store'; +import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { tokenOrchestrator } from '../tokenProvider'; +import { + HandleUserAuthFlowInput, + handleUserAuthFlow, +} from '../../../client/flows/userAuth/handleUserAuthFlow'; + +import { resetAutoSignIn } from './autoSignIn'; + +/** + * Signs a user in through a registered email or phone number without a password by by receiving and entering an OTP. + * + * @param input - The SignInWithUserAuthInput object + * @returns SignInWithUserAuthOutput + * @throws service: {@link InitiateAuthException }, {@link RespondToAuthChallengeException } - Cognito service errors + * thrown during the sign-in process. + * @throws validation: {@link AuthValidationErrorCode } - Validation errors thrown when either username or password -- needs to change + * are not defined. + * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. + */ +export async function signInWithUserAuth( + input: SignInWithUserAuthInput, +): Promise { + const { username, password, options } = input; + const authConfig = Amplify.getConfig().Auth?.Cognito; + const signInDetails: CognitoAuthSignInDetails = { + loginId: username, + authFlowType: 'USER_AUTH', + }; + assertTokenProviderConfig(authConfig); + const clientMetaData = options?.clientMetadata; + const preferredChallenge = options?.preferredChallenge; + + assertValidationError( + !!username, + AuthValidationErrorCode.EmptySignInUsername, + ); + + try { + const handleUserAuthFlowInput: HandleUserAuthFlowInput = { + username, + config: authConfig, + tokenOrchestrator, + clientMetadata: clientMetaData, + preferredChallenge, + password, + }; + + const autoSignInStoreState = autoSignInStore.getState(); + if ( + autoSignInStoreState.active && + autoSignInStoreState.username === username + ) { + handleUserAuthFlowInput.session = autoSignInStoreState.session; + } + + const response = await handleUserAuthFlow(handleUserAuthFlowInput); + + const activeUsername = getActiveSignInUsername(username); + + setActiveSignInState({ + signInSession: response.Session, + username: activeUsername, + challengeName: response.ChallengeName as ChallengeName, + signInDetails, + }); + + if (response.AuthenticationResult) { + cleanActiveSignInState(); + await cacheCognitoTokens({ + username: activeUsername, + ...response.AuthenticationResult, + NewDeviceMetadata: await getNewDeviceMetadata({ + userPoolId: authConfig.userPoolId, + userPoolEndpoint: authConfig.userPoolEndpoint, + newDeviceMetadata: response.AuthenticationResult.NewDeviceMetadata, + accessToken: response.AuthenticationResult.AccessToken, + }), + signInDetails, + }); + await dispatchSignedInHubEvent(); + + resetAutoSignIn(); + + return { + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }; + } + + return getSignInResult({ + challengeName: response.ChallengeName as ChallengeName, + challengeParameters: response.ChallengeParameters as ChallengeParameters, + availableChallenges: + 'AvailableChallenges' in response + ? (response.AvailableChallenges as ChallengeName[]) + : undefined, + }); + } catch (error) { + cleanActiveSignInState(); + resetAutoSignIn(); + assertServiceError(error); + const result = getSignInResultFromError(error.name); + if (result) return result; + throw error; + } +} diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts index 071f54f8313..0cd3acd88d3 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts @@ -28,11 +28,13 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { resetAutoSignIn } from './autoSignIn'; + /** * Signs a user in using USER_PASSWORD_AUTH AuthFlowType * @@ -84,6 +86,7 @@ export async function signInWithUserPassword( signInDetails, }); if (AuthenticationResult) { + cleanActiveSignInState(); await cacheCognitoTokens({ ...AuthenticationResult, username: activeUsername, @@ -95,10 +98,11 @@ export async function signInWithUserPassword( }), signInDetails, }); - cleanActiveSignInState(); await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -111,6 +115,7 @@ export async function signInWithUserPassword( }); } catch (error) { cleanActiveSignInState(); + resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signUp.ts b/packages/auth/src/providers/cognito/apis/signUp.ts index 3ec246648f5..2861541243c 100644 --- a/packages/auth/src/providers/cognito/apis/signUp.ts +++ b/packages/auth/src/providers/cognito/apis/signUp.ts @@ -19,15 +19,13 @@ import { autoSignInUserConfirmed, autoSignInWhenUserIsConfirmedWithLink, handleCodeAutoSignIn, - isAutoSignInStarted, - isSignUpComplete, - setAutoSignInStarted, - setUsernameUsedForAutoSignIn, } from '../utils/signUpHelpers'; import { getUserContextData } from '../utils/userContextData'; import { getAuthUserAgentValue } from '../../../utils'; import { createSignUpClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; import { createCognitoUserPoolEndpointResolver } from '../factories'; +import { SignUpCommandInput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { autoSignInStore } from '../../../client/utils/store'; import { setAutoSignIn } from './autoSignIn'; @@ -52,10 +50,6 @@ export async function signUp(input: SignUpInput): Promise { !!username, AuthValidationErrorCode.EmptySignUpUsername, ); - assertValidationError( - !!password, - AuthValidationErrorCode.EmptySignUpPassword, - ); const signInServiceOptions = typeof autoSignIn !== 'boolean' ? autoSignIn : undefined; @@ -68,10 +62,6 @@ export async function signUp(input: SignUpInput): Promise { if (signInServiceOptions?.authFlowType !== 'CUSTOM_WITHOUT_SRP') { signInInput.password = password; } - if (signInServiceOptions || autoSignIn === true) { - setUsernameUsedForAutoSignIn(username); - setAutoSignInStarted(true); - } const { userPoolId, userPoolClientId, userPoolEndpoint } = authConfig; const signUpClient = createSignUpClient({ @@ -79,87 +69,106 @@ export async function signUp(input: SignUpInput): Promise { endpointOverride: userPoolEndpoint, }), }); - const clientOutput = await signUpClient( + + const signUpClientInput: SignUpCommandInput = { + Username: username, + Password: undefined, + UserAttributes: + options?.userAttributes && toAttributeType(options?.userAttributes), + ClientMetadata: clientMetadata, + ValidationData: validationData && toAttributeType(validationData), + ClientId: userPoolClientId, + UserContextData: getUserContextData({ + username, + userPoolId, + userPoolClientId, + }), + }; + + if (password) { + signUpClientInput.Password = password; + } + + const { + UserSub: userId, + CodeDeliveryDetails: cdd, + UserConfirmed: userConfirmed, + Session: session, + } = await signUpClient( { region: getRegionFromUserPoolId(userPoolId), userAgentValue: getAuthUserAgentValue(AuthAction.SignUp), }, - { - Username: username, - Password: password, - UserAttributes: - options?.userAttributes && toAttributeType(options?.userAttributes), - ClientMetadata: clientMetadata, - ValidationData: validationData && toAttributeType(validationData), - ClientId: userPoolClientId, - UserContextData: getUserContextData({ - username, - userPoolId, - userPoolClientId, - }), - }, + signUpClientInput, ); - const { UserSub, CodeDeliveryDetails } = clientOutput; - if (isSignUpComplete(clientOutput) && isAutoSignInStarted()) { - setAutoSignIn(autoSignInUserConfirmed(signInInput)); + if (signInServiceOptions || autoSignIn === true) { + autoSignInStore.dispatch({ type: 'START' }); + autoSignInStore.dispatch({ type: 'SET_USERNAME', value: username }); + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); + } + + const codeDeliveryDetails = { + destination: cdd?.Destination, + deliveryMedium: cdd?.DeliveryMedium as AuthDeliveryMedium, + attributeName: cdd?.AttributeName as AuthVerifiableAttributeKey, + }; + + const isSignUpComplete = !!userConfirmed; + const isAutoSignInStarted = autoSignInStore.getState().active; + + // Sign Up Complete + // No Confirm Sign In Step Required + if (isSignUpComplete) { + if (isAutoSignInStarted) { + setAutoSignIn(autoSignInUserConfirmed(signInInput)); + + return { + isSignUpComplete: true, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + }, + userId, + }; + } - return { - isSignUpComplete: true, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - }, - userId: UserSub, - }; - } else if (isSignUpComplete(clientOutput) && !isAutoSignInStarted()) { return { isSignUpComplete: true, nextStep: { signUpStep: 'DONE', }, - userId: UserSub, + userId, }; - } else if ( - !isSignUpComplete(clientOutput) && - isAutoSignInStarted() && - signUpVerificationMethod === 'code' - ) { - handleCodeAutoSignIn(signInInput); - } else if ( - !isSignUpComplete(clientOutput) && - isAutoSignInStarted() && - signUpVerificationMethod === 'link' - ) { - setAutoSignIn(autoSignInWhenUserIsConfirmedWithLink(signInInput)); + } - return { - isSignUpComplete: false, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - codeDeliveryDetails: { - deliveryMedium: - CodeDeliveryDetails?.DeliveryMedium as AuthDeliveryMedium, - destination: CodeDeliveryDetails?.Destination as string, - attributeName: - CodeDeliveryDetails?.AttributeName as AuthVerifiableAttributeKey, + // Sign Up Not Complete + // Confirm Sign Up Step Required + if (isAutoSignInStarted) { + // Confirmation Via Link Occurs In Separate Context + // AutoSignIn Fn Will Initiate Polling Once Executed + if (signUpVerificationMethod === 'link') { + setAutoSignIn(autoSignInWhenUserIsConfirmedWithLink(signInInput)); + + return { + isSignUpComplete: false, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + codeDeliveryDetails, }, - }, - userId: UserSub, - }; + userId, + }; + } + // Confirmation Via Code Occurs In Same Context + // AutoSignIn Next Step Will Be Returned From Confirm Sign Up + handleCodeAutoSignIn(signInInput); } return { isSignUpComplete: false, nextStep: { signUpStep: 'CONFIRM_SIGN_UP', - codeDeliveryDetails: { - deliveryMedium: - CodeDeliveryDetails?.DeliveryMedium as AuthDeliveryMedium, - destination: CodeDeliveryDetails?.Destination as string, - attributeName: - CodeDeliveryDetails?.AttributeName as AuthVerifiableAttributeKey, - }, + codeDeliveryDetails, }, - userId: UserSub, + userId, }; } diff --git a/packages/auth/src/providers/cognito/types/index.ts b/packages/auth/src/providers/cognito/types/index.ts index 0b72451e925..eda7dfb1ee4 100644 --- a/packages/auth/src/providers/cognito/types/index.ts +++ b/packages/auth/src/providers/cognito/types/index.ts @@ -39,6 +39,7 @@ export { SignInWithCustomAuthInput, SignInWithCustomSRPAuthInput, SignInWithSRPInput, + SignInWithUserAuthInput, SignInWithUserPasswordInput, SignInWithRedirectInput, SignOutInput, @@ -65,6 +66,7 @@ export { SignInOutput, SignInWithCustomAuthOutput, SignInWithSRPOutput, + SignInWithUserAuthOutput, SignInWithUserPasswordOutput, SignInWithCustomSRPAuthOutput, SignUpOutput, diff --git a/packages/auth/src/providers/cognito/types/inputs.ts b/packages/auth/src/providers/cognito/types/inputs.ts index 13952bf53e9..57aef3cb353 100644 --- a/packages/auth/src/providers/cognito/types/inputs.ts +++ b/packages/auth/src/providers/cognito/types/inputs.ts @@ -92,6 +92,11 @@ export type SignInWithCustomSRPAuthInput = AuthSignInInput; */ export type SignInWithSRPInput = AuthSignInInput; +/** + * Input type for Cognito signInWithUserAuth API. + */ +export type SignInWithUserAuthInput = AuthSignInInput; + /** * Input type for Cognito signInWithUserPasswordInput API. */ diff --git a/packages/auth/src/providers/cognito/types/models.ts b/packages/auth/src/providers/cognito/types/models.ts index 3341d439918..a65d738127d 100644 --- a/packages/auth/src/providers/cognito/types/models.ts +++ b/packages/auth/src/providers/cognito/types/models.ts @@ -18,8 +18,11 @@ import { SignUpOutput } from './outputs'; /** * Cognito supported AuthFlowTypes that may be passed as part of the Sign In request. + * USER_AUTH is a superset that can handle both USER_SRP_AUTH and USER_PASSWORD_AUTH, + * providing flexibility for future authentication methods. */ export type AuthFlowType = + | 'USER_AUTH' | 'USER_SRP_AUTH' | 'CUSTOM_WITH_SRP' | 'CUSTOM_WITHOUT_SRP' @@ -38,6 +41,16 @@ export const cognitoHostedUIIdentityProviderMap: Record = */ export type ClientMetadata = Record; +/** + * Allowed values for preferredChallenge + */ +export type AuthFactorType = + | 'WEB_AUTHN' + | 'EMAIL_OTP' + | 'SMS_OTP' + | 'PASSWORD' + | 'PASSWORD_SRP'; + /** * The user attribute types available for Cognito. */ diff --git a/packages/auth/src/providers/cognito/types/options.ts b/packages/auth/src/providers/cognito/types/options.ts index 52b4536297f..ae04219cccb 100644 --- a/packages/auth/src/providers/cognito/types/options.ts +++ b/packages/auth/src/providers/cognito/types/options.ts @@ -8,7 +8,12 @@ import { AuthUserAttributes, } from '../../../types'; -import { AuthFlowType, ClientMetadata, ValidationData } from './models'; +import { + AuthFactorType, + AuthFlowType, + ClientMetadata, + ValidationData, +} from './models'; /** * Options specific to Cognito Confirm Reset Password. @@ -37,6 +42,7 @@ export type ResetPasswordOptions = AuthServiceOptions & { export type SignInOptions = AuthServiceOptions & { authFlowType?: AuthFlowType; clientMetadata?: ClientMetadata; + preferredChallenge?: AuthFactorType; }; /** diff --git a/packages/auth/src/providers/cognito/types/outputs.ts b/packages/auth/src/providers/cognito/types/outputs.ts index 595b9009998..381d4de167e 100644 --- a/packages/auth/src/providers/cognito/types/outputs.ts +++ b/packages/auth/src/providers/cognito/types/outputs.ts @@ -73,6 +73,11 @@ export type SignInWithCustomAuthOutput = AuthSignInOutput; */ export type SignInWithSRPOutput = AuthSignInOutput; +/** + * Output type for Cognito signInWithUserAuth API. + */ +export type SignInWithUserAuthOutput = AuthSignInOutput; + /** * Output type for Cognito signInWithUserPassword API. */ diff --git a/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts b/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts index 3df0c9a7e1f..ecb5b90ce0e 100644 --- a/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts +++ b/packages/auth/src/providers/cognito/utils/handleMFAChallenge.ts @@ -25,7 +25,7 @@ export async function handleMFAChallenge({ }: HandleAuthChallengeRequest & { challengeName: Extract< ChallengeName, - 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' + 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'SMS_OTP' >; }) { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; @@ -42,6 +42,10 @@ export async function handleMFAChallenge({ challengeResponses.SMS_MFA_CODE = challengeResponse; } + if (challengeName === 'SMS_OTP') { + challengeResponses.SMS_OTP_CODE = challengeResponse; + } + if (challengeName === 'SOFTWARE_TOKEN_MFA') { challengeResponses.SOFTWARE_TOKEN_MFA_CODE = challengeResponse; } diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index 984299351d9..cfc2ca122b5 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -50,8 +50,13 @@ import { RespondToAuthChallengeCommandOutput, } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { handleWebAuthnSignInResult } from '../../../client/flows/userAuth/handleWebAuthnSignInResult'; +import { handlePasswordSRP } from '../../../client/flows/shared/handlePasswordSRP'; +import { initiateSelectedChallenge } from '../../../client/flows/userAuth/handleSelectChallenge'; +import { handleSelectChallengeWithPassword } from '../../../client/flows/userAuth/handleSelectChallengeWithPassword'; +import { handleSelectChallengeWithPasswordSRP } from '../../../client/flows/userAuth/handleSelectChallengeWithPasswordSRP'; +import { signInStore } from '../../../client/utils/store'; -import { signInStore } from './signInStore'; import { HandleAuthChallengeRequest, assertDeviceMetadata } from './types'; import { getAuthenticationHelper, @@ -427,60 +432,14 @@ export async function handleUserSRPAuthFlow( config: CognitoUserPoolConfig, tokenOrchestrator: AuthTokenOrchestrator, ): Promise { - const { userPoolId, userPoolClientId, userPoolEndpoint } = config; - const userPoolName = userPoolId?.split('_')[1] || ''; - const authenticationHelper = await getAuthenticationHelper(userPoolName); - - const authParameters: Record = { - USERNAME: username, - SRP_A: authenticationHelper.A.toString(16), - }; - - const UserContextData = getUserContextData({ + return handlePasswordSRP({ username, - userPoolId, - userPoolClientId, - }); - - const jsonReq: InitiateAuthCommandInput = { - AuthFlow: 'USER_SRP_AUTH', - AuthParameters: authParameters, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - UserContextData, - }; - - const initiateAuth = createInitiateAuthClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - - const resp = await initiateAuth( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), - }, - jsonReq, - ); - const { ChallengeParameters: challengeParameters, Session: session } = resp; - const activeUsername = challengeParameters?.USERNAME ?? username; - setActiveSignInUsername(activeUsername); - - return retryOnResourceNotFoundException( - handlePasswordVerifierChallenge, - [ - password, - challengeParameters as ChallengeParameters, - clientMetadata, - session, - authenticationHelper, - config, - tokenOrchestrator, - ], - activeUsername, + password, + clientMetadata, + config, tokenOrchestrator, - ); + authFlow: 'USER_SRP_AUTH', + }); } export async function handleCustomAuthFlowWithoutSRP( @@ -806,8 +765,9 @@ export async function handlePasswordVerifierChallenge( export async function getSignInResult(params: { challengeName: ChallengeName; challengeParameters: ChallengeParameters; + availableChallenges?: ChallengeName[]; }): Promise { - const { challengeName, challengeParameters } = params; + const { challengeName, challengeParameters, availableChallenges } = params; const authConfig = Amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); @@ -903,6 +863,7 @@ export async function getSignInResult(params: { ), }, }; + case 'SMS_OTP': case 'SMS_MFA': return { isSignedIn: false, @@ -934,6 +895,25 @@ export async function getSignInResult(params: { }, }, }; + + case 'WEB_AUTHN': + return handleWebAuthnSignInResult(challengeParameters); + case 'PASSWORD': + case 'PASSWORD_SRP': + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_PASSWORD', + }, + }; + case 'SELECT_CHALLENGE': + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION', + availableChallenges, + }, + }; case 'ADMIN_NO_SRP_AUTH': break; case 'DEVICE_PASSWORD_VERIFIER': @@ -1021,6 +1001,26 @@ export async function handleChallengeName( const deviceName = options?.friendlyDeviceName; switch (challengeName) { + case 'WEB_AUTHN': + case 'SELECT_CHALLENGE': + if ( + challengeResponse === 'PASSWORD_SRP' || + challengeResponse === 'PASSWORD' + ) { + return { + ChallengeName: challengeResponse, + Session: session, + $metadata: {}, + }; + } + + return initiateSelectedChallenge({ + username, + session, + selectedChallenge: challengeResponse, + config, + clientMetadata, + }); case 'SELECT_MFA_TYPE': return handleSelectMFATypeChallenge({ challengeResponse, @@ -1065,6 +1065,7 @@ export async function handleChallengeName( ); case 'SMS_MFA': case 'SOFTWARE_TOKEN_MFA': + case 'SMS_OTP': case 'EMAIL_OTP': return handleMFAChallenge({ challengeName, @@ -1074,6 +1075,23 @@ export async function handleChallengeName( username, config, }); + case 'PASSWORD': + return handleSelectChallengeWithPassword( + username, + challengeResponse, + clientMetadata, + config, + session, + ); + case 'PASSWORD_SRP': + return handleSelectChallengeWithPasswordSRP( + username, + challengeResponse, // This is the actual password + clientMetadata, + config, + session, + tokenOrchestrator, + ); } // TODO: remove this error message for production apps throw new AuthError({ diff --git a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 8ab2943ce2a..9bebcf4be82 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -10,7 +10,7 @@ import { AutoSignInCallback } from '../../../types/models'; import { AuthError } from '../../../errors/AuthError'; import { resetAutoSignIn, setAutoSignIn } from '../apis/autoSignIn'; import { AUTO_SIGN_IN_EXCEPTION } from '../../../errors/constants'; -import { SignUpCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { signInWithUserAuth } from '../apis/signInWithUserAuth'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -36,7 +36,6 @@ export function handleCodeAutoSignIn(signInInput: SignInInput) { // This will stop the listener if confirmSignUp is not resolved. const timeOutId = setTimeout(() => { stopHubListener(); - setAutoSignInStarted(false); clearTimeout(timeOutId); resetAutoSignIn(); }, MAX_AUTOSIGNIN_POLLING_MS); @@ -74,7 +73,6 @@ function handleAutoSignInWithLink( const maxTime = MAX_AUTOSIGNIN_POLLING_MS; if (elapsedTime > maxTime) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject( new AuthError({ name: AUTO_SIGN_IN_EXCEPTION, @@ -90,12 +88,10 @@ function handleAutoSignInWithLink( if (signInOutput.nextStep.signInStep !== 'CONFIRM_SIGN_UP') { resolve(signInOutput); clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); resetAutoSignIn(); } } catch (error) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject(error); resetAutoSignIn(); } @@ -108,31 +104,6 @@ const debouncedAutoSignWithCodeOrUserConfirmed = debounce( 300, ); -let autoSignInStarted = false; - -let usernameUsedForAutoSignIn: string | undefined; - -export function setUsernameUsedForAutoSignIn(username?: string) { - usernameUsedForAutoSignIn = username; -} -export function isAutoSignInUserUsingConfirmSignUp(username: string) { - return usernameUsedForAutoSignIn === username; -} - -export function isAutoSignInStarted(): boolean { - return autoSignInStarted; -} -export function setAutoSignInStarted(value: boolean) { - if (value === false) { - setUsernameUsedForAutoSignIn(undefined); - } - autoSignInStarted = value; -} - -export function isSignUpComplete(output: SignUpCommandOutput): boolean { - return !!output.UserConfirmed; -} - export function autoSignInWhenUserIsConfirmedWithLink( signInInput: SignInInput, ): AutoSignInCallback { @@ -148,7 +119,11 @@ async function handleAutoSignInWithCodeOrUserConfirmed( reject: (reason?: any) => void, ) { try { - const output = await signIn(signInInput); + const output = + signInInput?.options?.authFlowType === 'USER_AUTH' + ? await signInWithUserAuth(signInInput) + : await signIn(signInInput); + resolve(output); resetAutoSignIn(); } catch (error) { diff --git a/packages/auth/src/types/inputs.ts b/packages/auth/src/types/inputs.ts index 6e152cdc1e5..c2947b4650a 100644 --- a/packages/auth/src/types/inputs.ts +++ b/packages/auth/src/types/inputs.ts @@ -75,7 +75,7 @@ export interface AuthSignInWithRedirectInput { * The parameters for constructing a Sign Up input. * * @param username - a standard username, potentially an email/phone number - * @param password - the user's password + * @param password - the user's password, may be required depending on your Cognito User Pool configuration * @param options - optional parameters for the Sign Up process, including user attributes */ export interface AuthSignUpInput< @@ -83,7 +83,7 @@ export interface AuthSignUpInput< AuthSignUpOptions = AuthSignUpOptions, > { username: string; - password: string; + password?: string; options?: ServiceOptions; } diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index e08b7bce5f9..1655de572e8 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -3,6 +3,7 @@ import { AuthStandardAttributeKey } from '@aws-amplify/core/internals/utils'; +import { ChallengeName } from '../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { SignInOutput } from '../providers/cognito'; /** @@ -217,6 +218,16 @@ export interface DoneSignInStep { signInStep: 'DONE'; } +// New interfaces for USER_AUTH flow +export interface ContinueSignInWithFirstFactorSelection { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION'; + availableChallenges?: ChallengeName[]; +} + +export interface ConfirmSignInWithPassword { + signInStep: 'CONFIRM_SIGN_IN_WITH_PASSWORD'; +} + export type AuthNextSignInStep< UserAttributeKey extends AuthUserAttributeKey = AuthUserAttributeKey, > = @@ -229,6 +240,8 @@ export type AuthNextSignInStep< | ContinueSignInWithTOTPSetup | ContinueSignInWithEmailSetup | ContinueSignInWithMFASetupSelection + | ContinueSignInWithFirstFactorSelection + | ConfirmSignInWithPassword | ConfirmSignUpStep | ResetPasswordStep | DoneSignInStep; diff --git a/packages/aws-amplify/CHANGELOG.md b/packages/aws-amplify/CHANGELOG.md index 93361f07dab..6dd11203d9b 100644 --- a/packages/aws-amplify/CHANGELOG.md +++ b/packages/aws-amplify/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [6.10.0](https://github.com/aws-amplify/amplify-js/compare/aws-amplify@6.9.0...aws-amplify@6.10.0) (2024-11-25) + +### Features + +- **auth:** passwordless ([#14032](https://github.com/aws-amplify/amplify-js/issues/14032)) ([68c7f6f](https://github.com/aws-amplify/amplify-js/commit/68c7f6fbaa903ac8e45035bc25a71321a6240aec)), closes [#1](https://github.com/aws-amplify/amplify-js/issues/1) [#3](https://github.com/aws-amplify/amplify-js/issues/3) [#6](https://github.com/aws-amplify/amplify-js/issues/6) [#8](https://github.com/aws-amplify/amplify-js/issues/8) [#2](https://github.com/aws-amplify/amplify-js/issues/2) [#11](https://github.com/aws-amplify/amplify-js/issues/11) [#7](https://github.com/aws-amplify/amplify-js/issues/7) [#14](https://github.com/aws-amplify/amplify-js/issues/14) [#15](https://github.com/aws-amplify/amplify-js/issues/15) [#16](https://github.com/aws-amplify/amplify-js/issues/16) [#18](https://github.com/aws-amplify/amplify-js/issues/18) [#17](https://github.com/aws-amplify/amplify-js/issues/17) [#19](https://github.com/aws-amplify/amplify-js/issues/19) [#22](https://github.com/aws-amplify/amplify-js/issues/22) + +# [6.9.0](https://github.com/aws-amplify/amplify-js/compare/aws-amplify@6.8.2...aws-amplify@6.9.0) (2024-11-20) + +### Features + +- **storage:** Integrity changes for storage browser ([#13909](https://github.com/aws-amplify/amplify-js/issues/13909)) ([ec7bf6f](https://github.com/aws-amplify/amplify-js/commit/ec7bf6ff2fb4af84425eca4f2d68c2bef7f49d03)), closes [#13478](https://github.com/aws-amplify/amplify-js/issues/13478) [#13474](https://github.com/aws-amplify/amplify-js/issues/13474) + ## [6.8.2](https://github.com/aws-amplify/amplify-js/compare/aws-amplify@6.8.1...aws-amplify@6.8.2) (2024-11-13) **Note:** Version bump only for package aws-amplify diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 0a354d6cf11..0225e72d868 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -180,6 +180,9 @@ describe('aws-amplify Exports', () => { 'autoSignIn', 'fetchAuthSession', 'decodeJWT', + 'associateWebAuthnCredential', + 'listWebAuthnCredentials', + 'deleteWebAuthnCredential', ].sort(), ); }); @@ -239,6 +242,7 @@ describe('aws-amplify Exports', () => { 'getUrl', 'isCancelError', 'StorageError', + 'DEFAULT_PART_SIZE', ].sort(), ); }); @@ -253,6 +257,7 @@ describe('aws-amplify Exports', () => { 'getProperties', 'copy', 'getUrl', + 'DEFAULT_PART_SIZE', ].sort(), ); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 9c05b6b9f25..57300e32067 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -1,6 +1,6 @@ { "name": "aws-amplify", - "version": "6.8.2", + "version": "6.10.0", "description": "AWS Amplify is a JavaScript library for Frontend and mobile developers building cloud-enabled applications.", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -276,13 +276,13 @@ "utils" ], "dependencies": { - "@aws-amplify/analytics": "7.0.58", - "@aws-amplify/api": "6.1.3", - "@aws-amplify/auth": "6.6.2", - "@aws-amplify/core": "6.5.3", - "@aws-amplify/datastore": "5.0.60", - "@aws-amplify/notifications": "2.0.58", - "@aws-amplify/storage": "6.6.16", + "@aws-amplify/analytics": "7.0.60", + "@aws-amplify/api": "6.1.5", + "@aws-amplify/auth": "6.8.0", + "@aws-amplify/core": "6.7.0", + "@aws-amplify/datastore": "5.0.62", + "@aws-amplify/notifications": "2.0.60", + "@aws-amplify/storage": "6.7.1", "tslib": "^2.5.0" }, "devDependencies": { @@ -293,31 +293,31 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.5 kB" + "limit": "17.60 kB" }, { "name": "[Analytics] record (Kinesis)", "path": "./dist/esm/analytics/kinesis/index.mjs", "import": "{ record }", - "limit": "48.74 kB" + "limit": "48.8 kB" }, { "name": "[Analytics] record (Kinesis Firehose)", "path": "./dist/esm/analytics/kinesis-firehose/index.mjs", "import": "{ record }", - "limit": "45.76 kB" + "limit": "45.85 kB" }, { "name": "[Analytics] record (Personalize)", "path": "./dist/esm/analytics/personalize/index.mjs", "import": "{ record }", - "limit": "49.58 kB" + "limit": "49.67 kB" }, { "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.97 kB" + "limit": "16.10 kB" }, { "name": "[Analytics] enable", @@ -335,7 +335,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "44.1 kB" + "limit": "44.23 kB" }, { "name": "[API] REST API handlers", @@ -353,61 +353,61 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.57 kB" + "limit": "12.68 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.51 kB" + "limit": "12.63 kB" }, { "name": "[Auth] signIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn }", - "limit": "30.00 kB" + "limit": "28.78 kB" }, { "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.53 kB" + "limit": "12.64 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignUp }", - "limit": "31.00 kB" + "limit": "29.40 kB" }, { "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.39 kB" + "limit": "28.46 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "12.00 kB" + "limit": "12.11 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "12.1 kB" + "limit": "12.14 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.86 kB" + "limit": "12.99 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.87 kB" + "limit": "12.99 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,7 +419,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "12.1 kB" + "limit": "12.21 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,73 +431,91 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.86 kB" + "limit": "12.98 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect }", - "limit": "21.19 kB" + "limit": "21.21 kB" }, { "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.93 kB" + "limit": "12.03 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.23 kB" + "limit": "30.56 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "21.66 kB" + "limit": "21.64 kB" + }, + { + "name": "[Auth] Associate WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ associateWebAuthnCredential }", + "limit": "13.55 kB" + }, + { + "name": "[Auth] List WebAuthN Credentials (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ listWebAuthnCredentials }", + "limit": "12.14 kB" + }, + { + "name": "[Auth] Delete WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ deleteWebAuthnCredential }", + "limit": "12.01 kB" }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "15.03 kB" + "limit": "16.39 kB" }, { "name": "[Storage] downloadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ downloadData }", - "limit": "15.62 kB" + "limit": "16.73 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getProperties }", - "limit": "14.89 kB" + "limit": "15.99 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "16.11 kB" + "limit": "17.22 kB" }, { "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "15.55 kB" + "limit": "16.74 kB" }, { "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "14.75 kB" + "limit": "15.83 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "20.17 kB" + "limit": "22.81 kB" } ] } diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 4cf9badd0d1..fb13764c47e 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [6.7.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/core@6.6.0...@aws-amplify/core@6.7.0) (2024-11-25) + +### Features + +- **auth:** passwordless ([#14032](https://github.com/aws-amplify/amplify-js/issues/14032)) ([68c7f6f](https://github.com/aws-amplify/amplify-js/commit/68c7f6fbaa903ac8e45035bc25a71321a6240aec)), closes [#1](https://github.com/aws-amplify/amplify-js/issues/1) [#3](https://github.com/aws-amplify/amplify-js/issues/3) [#6](https://github.com/aws-amplify/amplify-js/issues/6) [#8](https://github.com/aws-amplify/amplify-js/issues/8) [#2](https://github.com/aws-amplify/amplify-js/issues/2) [#11](https://github.com/aws-amplify/amplify-js/issues/11) [#7](https://github.com/aws-amplify/amplify-js/issues/7) [#14](https://github.com/aws-amplify/amplify-js/issues/14) [#15](https://github.com/aws-amplify/amplify-js/issues/15) [#16](https://github.com/aws-amplify/amplify-js/issues/16) [#18](https://github.com/aws-amplify/amplify-js/issues/18) [#17](https://github.com/aws-amplify/amplify-js/issues/17) [#19](https://github.com/aws-amplify/amplify-js/issues/19) [#22](https://github.com/aws-amplify/amplify-js/issues/22) + +# [6.6.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/core@6.5.3...@aws-amplify/core@6.6.0) (2024-11-20) + +### Features + +- **storage:** Integrity changes for storage browser ([#13909](https://github.com/aws-amplify/amplify-js/issues/13909)) ([ec7bf6f](https://github.com/aws-amplify/amplify-js/commit/ec7bf6ff2fb4af84425eca4f2d68c2bef7f49d03)), closes [#13478](https://github.com/aws-amplify/amplify-js/issues/13478) [#13474](https://github.com/aws-amplify/amplify-js/issues/13474) + ## [6.5.3](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/core@6.5.2...@aws-amplify/core@6.5.3) (2024-11-13) **Note:** Version bump only for package @aws-amplify/core diff --git a/packages/core/__tests__/clients/middleware/retry/defaultRetryDecider.test.ts b/packages/core/__tests__/clients/middleware/retry/defaultRetryDecider.test.ts index 160c4fdbe74..0cee3a38ec1 100644 --- a/packages/core/__tests__/clients/middleware/retry/defaultRetryDecider.test.ts +++ b/packages/core/__tests__/clients/middleware/retry/defaultRetryDecider.test.ts @@ -2,12 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 import { HttpResponse } from '../../../../src/clients'; -import { getRetryDecider } from '../../../../src/clients/middleware/retry/defaultRetryDecider'; +import { getRetryDecider } from '../../../../src/clients/middleware/retry'; +import { isClockSkewError } from '../../../../src/clients/middleware/retry/isClockSkewError'; import { AmplifyError } from '../../../../src/errors'; import { AmplifyErrorCode } from '../../../../src/types'; +jest.mock('../../../../src/clients/middleware/retry/isClockSkewError'); + +const mockIsClockSkewError = jest.mocked(isClockSkewError); + describe('getRetryDecider', () => { const mockErrorParser = jest.fn(); + const mockHttpResponse: HttpResponse = { + statusCode: 200, + headers: {}, + body: 'body' as any, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); describe('created retryDecider', () => { const mockNetworkErrorThrownFromFetch = new AmplifyError({ @@ -21,20 +35,124 @@ describe('getRetryDecider', () => { test.each([ [ 'a network error from the fetch handler', - true, + { + retryable: true, + }, mockNetworkErrorThrownFromFetch, ], [ 'a network error from the XHR handler defined in Storage', - true, + { + retryable: true, + }, mockNetworkErrorThrownFromXHRInStorage, ], - ])('when receives %p returns %p', (_, expected, error) => { + ])('when receives %p returns %p', async (_, expected, error) => { const mockResponse = {} as unknown as HttpResponse; mockErrorParser.mockReturnValueOnce(error); const retryDecider = getRetryDecider(mockErrorParser); + const result = await retryDecider(mockResponse, error); - expect(retryDecider(mockResponse, error)).resolves.toBe(expected); + expect(result).toEqual(expected); }); }); + + describe('handling throttling errors', () => { + it.each([ + 'BandwidthLimitExceeded', + 'EC2ThrottledException', + 'LimitExceededException', + 'PriorRequestNotComplete', + 'ProvisionedThroughputExceededException', + 'RequestLimitExceeded', + 'RequestThrottled', + 'RequestThrottledException', + 'SlowDown', + 'ThrottledException', + 'Throttling', + 'ThrottlingException', + 'TooManyRequestsException', + ])('should return retryable at %s error', async errorCode => { + expect.assertions(2); + mockErrorParser.mockResolvedValueOnce({ + code: errorCode, + }); + const retryDecider = getRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + mockHttpResponse, + undefined, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBeFalsy(); + }); + + it('should set retryable for 402 error', async () => { + expect.assertions(2); + const retryDecider = getRetryDecider(mockErrorParser); + const { + retryable, + isCredentialsExpiredError: isInvalidCredentialsError, + } = await retryDecider( + { + ...mockHttpResponse, + statusCode: 429, + }, + undefined, + ); + expect(retryable).toBe(true); + expect(isInvalidCredentialsError).toBeFalsy(); + }); + }); + + describe('handling clockskew error', () => { + it.each([{ code: 'ClockSkew' }, { name: 'ClockSkew' }])( + 'should handle clockskew error %o', + async parsedError => { + expect.assertions(3); + mockErrorParser.mockResolvedValue(parsedError); + mockIsClockSkewError.mockReturnValue(true); + const retryDecider = getRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + mockHttpResponse, + undefined, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBeFalsy(); + expect(mockIsClockSkewError).toHaveBeenCalledWith( + Object.values(parsedError)[0], + ); + }, + ); + }); + + it.each([500, 502, 503, 504])( + 'should handle server-side status code %s', + async statusCode => { + const retryDecider = getRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + { + ...mockHttpResponse, + statusCode, + }, + undefined, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBeFalsy(); + }, + ); + + it.each(['TimeoutError', 'RequestTimeout', 'RequestTimeoutException'])( + 'should handle server-side timeout error code %s', + async errorCode => { + expect.assertions(2); + mockErrorParser.mockResolvedValue({ code: errorCode }); + const retryDecider = getRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + mockHttpResponse, + undefined, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBeFalsy(); + }, + ); }); diff --git a/packages/core/__tests__/clients/middleware/retry/middleware.test.ts b/packages/core/__tests__/clients/middleware/retry/middleware.test.ts index 1391f010d23..05f1b0f8de9 100644 --- a/packages/core/__tests__/clients/middleware/retry/middleware.test.ts +++ b/packages/core/__tests__/clients/middleware/retry/middleware.test.ts @@ -11,13 +11,13 @@ import { jest.spyOn(global, 'setTimeout'); jest.spyOn(global, 'clearTimeout'); -describe(`${retryMiddlewareFactory.name} middleware`, () => { +describe(`retry middleware`, () => { beforeEach(() => { jest.clearAllMocks(); }); const defaultRetryOptions = { - retryDecider: async () => true, + retryDecider: async () => ({ retryable: true }), computeDelay: () => 1, }; const defaultRequest = { url: new URL('https://a.b') }; @@ -72,7 +72,7 @@ describe(`${retryMiddlewareFactory.name} middleware`, () => { const retryableHandler = getRetryableHandler(nextHandler); const retryDecider = jest .fn() - .mockImplementation(response => response.body !== 'foo'); // retry if response is not foo + .mockImplementation(response => ({ retryable: response.body !== 'foo' })); // retry if response is not foo const resp = await retryableHandler(defaultRequest, { ...defaultRetryOptions, retryDecider, @@ -88,11 +88,9 @@ describe(`${retryMiddlewareFactory.name} middleware`, () => { .fn() .mockRejectedValue(new Error('UnretryableError')); const retryableHandler = getRetryableHandler(nextHandler); - const retryDecider = jest - .fn() - .mockImplementation( - (resp, error) => error.message !== 'UnretryableError', - ); + const retryDecider = jest.fn().mockImplementation((resp, error) => ({ + retryable: error.message !== 'UnretryableError', + })); try { await retryableHandler(defaultRequest, { ...defaultRetryOptions, @@ -103,11 +101,46 @@ describe(`${retryMiddlewareFactory.name} middleware`, () => { expect(e.message).toBe('UnretryableError'); expect(nextHandler).toHaveBeenCalledTimes(1); expect(retryDecider).toHaveBeenCalledTimes(1); - expect(retryDecider).toHaveBeenCalledWith(undefined, expect.any(Error)); + expect(retryDecider).toHaveBeenCalledWith( + undefined, + expect.any(Error), + expect.anything(), + ); } expect.assertions(4); }); + test('should set isCredentialsExpired in middleware context if retry decider returns the flag', async () => { + expect.assertions(4); + const coreHandler = jest + .fn() + .mockRejectedValueOnce(new Error('InvalidSignature')) + .mockResolvedValueOnce(defaultResponse); + + const nextMiddleware = jest.fn( + (next: MiddlewareHandler) => (request: any) => next(request), + ); + const retryableHandler = composeTransferHandler<[RetryOptions, any]>( + coreHandler, + [retryMiddlewareFactory, () => nextMiddleware], + ); + const retryDecider = jest.fn().mockImplementation((resp, error) => ({ + retryable: error?.message === 'InvalidSignature', + isCredentialsExpiredError: error?.message === 'InvalidSignature', + })); + const response = await retryableHandler(defaultRequest, { + ...defaultRetryOptions, + retryDecider, + }); + expect(response).toEqual(expect.objectContaining(defaultResponse)); + expect(coreHandler).toHaveBeenCalledTimes(2); + expect(retryDecider).toHaveBeenCalledTimes(2); + expect(nextMiddleware).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ isCredentialsExpired: true }), + ); + }); + test('should call computeDelay for intervals', async () => { const nextHandler = jest.fn().mockResolvedValue(defaultResponse); const retryableHandler = getRetryableHandler(nextHandler); @@ -152,7 +185,7 @@ describe(`${retryMiddlewareFactory.name} middleware`, () => { const nextHandler = jest.fn().mockResolvedValue(defaultResponse); const retryableHandler = getRetryableHandler(nextHandler); const controller = new AbortController(); - const retryDecider = async () => true; + const retryDecider = async () => ({ retryable: true }); const computeDelay = jest.fn().mockImplementation(attempt => { if (attempt === 1) { setTimeout(() => { @@ -204,9 +237,10 @@ describe(`${retryMiddlewareFactory.name} middleware`, () => { const retryDecider = jest .fn() .mockImplementation((response, error: Error) => { - if (error && error.message.endsWith('RetryableError')) return true; + if (error && error.message.endsWith('RetryableError')) + return { retryable: true }; - return false; + return { retryable: false }; }); const computeDelay = jest.fn().mockReturnValue(0); const response = await doubleRetryableHandler(defaultRequest, { diff --git a/packages/core/__tests__/clients/middleware/signing/middleware.test.ts b/packages/core/__tests__/clients/middleware/signing/middleware.test.ts index a3183ebcdb5..874d82e2282 100644 --- a/packages/core/__tests__/clients/middleware/signing/middleware.test.ts +++ b/packages/core/__tests__/clients/middleware/signing/middleware.test.ts @@ -11,6 +11,7 @@ import { getUpdatedSystemClockOffset } from '../../../../src/clients/middleware/ import { HttpRequest, HttpResponse, + Middleware, MiddlewareHandler, } from '../../../../src/clients/types'; @@ -113,6 +114,30 @@ describe('Signing middleware', () => { expect(credentialsProvider).toHaveBeenCalledTimes(1); }); + test('should forceRefresh credentials provider if middleware context isCredentialsInvalid flag is set', async () => { + expect.assertions(2); + const credentialsProvider = jest.fn().mockResolvedValue(credentials); + const nextHandler = jest.fn().mockResolvedValue(defaultResponse); + const setInvalidCredsMiddleware: Middleware = + () => (next, context) => request => { + context.isCredentialsExpired = true; + + return next(request); + }; + const signableHandler = composeTransferHandler< + [any, SigningOptions], + HttpRequest, + HttpResponse + >(nextHandler, [setInvalidCredsMiddleware, signingMiddlewareFactory]); + const config = { + ...defaultSigningOptions, + credentials: credentialsProvider, + }; + await signableHandler(defaultRequest, config); + expect(credentialsProvider).toHaveBeenCalledTimes(1); + expect(credentialsProvider).toHaveBeenCalledWith({ forceRefresh: true }); + }); + test.each([ ['response with Date header', 'Date'], ['response with date header', 'date'], @@ -128,6 +153,7 @@ describe('Signing middleware', () => { const middlewareFunction = signingMiddlewareFactory(defaultSigningOptions)( nextHandler, + {}, ); await middlewareFunction(defaultRequest); diff --git a/packages/core/__tests__/parseAmplifyOutputs.test.ts b/packages/core/__tests__/parseAmplifyOutputs.test.ts index bb93d12116c..38a8fa141c4 100644 --- a/packages/core/__tests__/parseAmplifyOutputs.test.ts +++ b/packages/core/__tests__/parseAmplifyOutputs.test.ts @@ -294,6 +294,59 @@ describe('parseAmplifyOutputs tests', () => { expect(() => parseAmplifyOutputs(amplifyOutputs)).toThrow(); }); + it('should parse storage bucket with paths', () => { + const amplifyOutputs: AmplifyOutputs = { + version: '1.2', + 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', + paths: { + 'other/*': { + guest: ['get', 'list'], + authenticated: ['get', 'list', 'write'], + }, + 'admin/*': { + groupsauditor: ['get', 'list'], + groupsadmin: ['get', 'list', 'write', 'delete'], + }, + }, + }, + ], + }, + }; + + const result = parseAmplifyOutputs(amplifyOutputs); + + expect(result).toEqual({ + Storage: { + S3: { + bucket: 'storage-bucket-test', + region: 'us-west-2', + buckets: { + 'default-bucket': { + bucketName: 'storage-bucket-test', + region: 'us-west-2', + paths: { + 'other/*': { + guest: ['get', 'list'], + authenticated: ['get', 'list', 'write'], + }, + 'admin/*': { + groupsauditor: ['get', 'list'], + groupsadmin: ['get', 'list', 'write', 'delete'], + }, + }, + }, + }, + }, + }, + }); + }); }); describe('analytics tests', () => { diff --git a/packages/core/__tests__/utils/convert/base64Decoder.test.ts b/packages/core/__tests__/utils/convert/base64Decoder.test.ts index 675db4e09a4..088d44a6f00 100644 --- a/packages/core/__tests__/utils/convert/base64Decoder.test.ts +++ b/packages/core/__tests__/utils/convert/base64Decoder.test.ts @@ -26,4 +26,14 @@ describe('base64Decoder (non-native)', () => { expect(mockGetAtob).toHaveBeenCalled(); expect(mockAtob).toHaveBeenCalledWith('test'); }); + + it('makes the result url safe if urlSafe is true', () => { + const mockInput = 'test-test_test'; + const mockOutput = 'test+test/test'; + + base64Decoder.convert(mockInput, { urlSafe: true }); + + expect(mockGetAtob).toHaveBeenCalled(); + expect(mockAtob).toHaveBeenCalledWith(mockOutput); + }); }); diff --git a/packages/core/metadata b/packages/core/metadata index 852adf07360..143dd93133c 100644 --- a/packages/core/metadata +++ b/packages/core/metadata @@ -1 +1 @@ -f6727c6df +87666a9ba diff --git a/packages/core/package.json b/packages/core/package.json index 7cfe8932f22..67228dd650d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/core", - "version": "6.5.3", + "version": "6.7.0", "description": "Core category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index d5c60a84241..fd6057c9704 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -90,6 +90,10 @@ export enum AuthAction { FetchDevices = '34', SendUserAttributeVerificationCode = '35', SignInWithRedirect = '36', + StartWebAuthnRegistration = '37', + CompleteWebAuthnRegistration = '38', + ListWebAuthnCredentials = '39', + DeleteWebAuthnCredential = '40', } export enum DataStoreAction { Subscribe = '1', @@ -133,6 +137,8 @@ export enum StorageAction { Remove = '5', GetProperties = '6', GetUrl = '7', + GetDataAccess = '8', + ListCallerAccessGrants = '9', } interface ActionMap { diff --git a/packages/core/src/clients/index.ts b/packages/core/src/clients/index.ts index a06067604bc..31abf267c77 100644 --- a/packages/core/src/clients/index.ts +++ b/packages/core/src/clients/index.ts @@ -15,9 +15,14 @@ export { } from './middleware/signing/signer/signatureV4'; export { EMPTY_HASH as EMPTY_SHA256_HASH } from './middleware/signing/signer/signatureV4/constants'; export { extendedEncodeURIComponent } from './middleware/signing/utils/extendedEncodeURIComponent'; -export { signingMiddlewareFactory, SigningOptions } from './middleware/signing'; +export { + signingMiddlewareFactory, + SigningOptions, + CredentialsProviderOptions, +} from './middleware/signing'; export { getRetryDecider, + RetryDeciderOutput, jitteredBackoff, retryMiddlewareFactory, RetryOptions, diff --git a/packages/core/src/clients/internal/composeServiceApi.ts b/packages/core/src/clients/internal/composeServiceApi.ts index 259a0ee7cd6..4b788ec852d 100644 --- a/packages/core/src/clients/internal/composeServiceApi.ts +++ b/packages/core/src/clients/internal/composeServiceApi.ts @@ -5,6 +5,42 @@ import { ServiceClientOptions } from '../types/aws'; import { Endpoint, TransferHandler } from '../types/core'; import { HttpRequest, HttpResponse } from '../types/http'; +/** + * Compose a service API handler that accepts input as defined shape and responds conforming to defined output shape. + * A service API handler is composed with: + * * A transfer handler + * * A serializer function + * * A deserializer function + * * A default config object + * + * The returned service API handler, when called, will trigger the following workflow: + * 1. When calling the service API handler function, the default config object is merged into the input config + * object to assign the default values of some omitted configs, resulting to a resolved config object. + * 2. The `endpointResolver` function from the default config object will be invoked with the resolved config object and + * API input object resulting to an endpoint instance. + * 3. The serializer function is invoked with API input object and the endpoint instance resulting to an HTTP request + * instance. + * 4. The HTTP request instance and the resolved config object is passed to the transfer handler function. + * 5. The transfer handler function resolves to an HTTP response instance(can be either successful or failed status code). + * 6. The deserializer function is invoked with the HTTP response instance resulting to the API output object, and + * return to the caller. + * + * + * @param transferHandler Async function for dispatching HTTP requests and returning HTTP response. + * @param serializer Async function for converting object in defined input shape into HTTP request targeting a given + * endpoint. + * @param deserializer Async function for converting HTTP response into output object in defined output shape, or error + * shape. + * @param defaultConfig object containing default options to be consumed by transfer handler, serializer and + * deserializer. + * @returns a async service API handler function that accepts a config object and input object in defined shape, returns + * an output object in defined shape. It may also throw error instance in defined shape in deserializer. The config + * object type is composed with options type of transferHandler, endpointResolver function as well as endpointResolver + * function's input options type, region string. The config object property will be marked as optional if it's also + * defined in defaultConfig. + * + * @internal + */ export const composeServiceApi = < TransferHandlerOptions, Input, @@ -26,9 +62,9 @@ export const composeServiceApi = < return async ( config: OptionalizeKey< TransferHandlerOptions & - ServiceClientOptions & - Partial & - InferEndpointResolverOptionType, + ServiceClientOptions & // Required configs(e.g. endpointResolver, region) to serialize input shapes into requests + InferEndpointResolverOptionType & // Required inputs for endpointResolver + Partial, // Properties defined in default configs, we need to allow overwriting them when invoking the service API handler DefaultConfig >, input: Input, @@ -37,8 +73,8 @@ export const composeServiceApi = < ...defaultConfig, ...config, } as unknown as TransferHandlerOptions & ServiceClientOptions; - // We may want to allow different endpoints from given config(other than region) and input. - // Currently S3 supports additional `useAccelerateEndpoint` option to use accelerate endpoint. + // We need to allow different endpoints based on both given config(other than region) and input. + // However for most of non-S3 services, region is the only input for endpoint resolver. const endpoint = await resolvedConfig.endpointResolver( resolvedConfig, input, @@ -55,6 +91,30 @@ export const composeServiceApi = < }; }; +/** + * Type helper to make a given key optional in a given type. For all the keys in the `InputDefaultsType`, if its value + * is assignable to the value of the same key in `InputType`, we will mark the key in `InputType` is optional. If + * the `InputType` and `InputDefaultsType` has the same key but un-assignable types, the resulting type is `never` to + * trigger a type error down the line. + * + * @example + * type InputType = { + * a: string; + * b: number; + * c: string; + * }; + * type InputDefaultsType = { + * a: string; + * b: number; + * }; + * type OutputType = OptionalizeKey; + * OutputType equals to: + * { + * a?: string; + * b?: number; + * c: string; + * } + */ type OptionalizeKey = { [KeyWithDefaultValue in keyof InputDefaultsType]?: KeyWithDefaultValue extends keyof InputType ? InputType[KeyWithDefaultValue] @@ -67,7 +127,7 @@ type OptionalizeKey = { }; type InferEndpointResolverOptionType = T extends { - endpointResolver(options: infer EndpointOptions): any; + endpointResolver(options: infer EndpointOptions, input: any): any; } ? EndpointOptions : never; diff --git a/packages/core/src/clients/middleware/retry/defaultRetryDecider.ts b/packages/core/src/clients/middleware/retry/defaultRetryDecider.ts index a990fbbdc3c..feb350fbbf0 100644 --- a/packages/core/src/clients/middleware/retry/defaultRetryDecider.ts +++ b/packages/core/src/clients/middleware/retry/defaultRetryDecider.ts @@ -5,6 +5,7 @@ import { AmplifyErrorCode } from '../../../types'; import { ErrorParser, HttpResponse } from '../../types'; import { isClockSkewError } from './isClockSkewError'; +import { RetryDeciderOutput } from './types'; /** * Get retry decider function @@ -12,7 +13,10 @@ import { isClockSkewError } from './isClockSkewError'; */ export const getRetryDecider = (errorParser: ErrorParser) => - async (response?: HttpResponse, error?: unknown): Promise => { + async ( + response?: HttpResponse, + error?: unknown, + ): Promise => { const parsedError = (error as Error & { code: string }) ?? (await errorParser(response)) ?? @@ -20,12 +24,15 @@ export const getRetryDecider = const errorCode = parsedError?.code || parsedError?.name; const statusCode = response?.statusCode; - return ( + const isRetryable = isConnectionError(error) || isThrottlingError(statusCode, errorCode) || isClockSkewError(errorCode) || - isServerSideError(statusCode, errorCode) - ); + isServerSideError(statusCode, errorCode); + + return { + retryable: isRetryable, + }; }; // reference: https://github.com/aws/aws-sdk-js-v3/blob/ab0e7be36e7e7f8a0c04834357aaad643c7912c3/packages/service-error-classification/src/constants.ts#L22-L37 diff --git a/packages/core/src/clients/middleware/retry/index.ts b/packages/core/src/clients/middleware/retry/index.ts index 4c82c603508..fdf34552fa7 100644 --- a/packages/core/src/clients/middleware/retry/index.ts +++ b/packages/core/src/clients/middleware/retry/index.ts @@ -4,3 +4,4 @@ export { RetryOptions, retryMiddlewareFactory } from './middleware'; export { jitteredBackoff } from './jitteredBackoff'; export { getRetryDecider } from './defaultRetryDecider'; +export { RetryDeciderOutput } from './types'; diff --git a/packages/core/src/clients/middleware/retry/middleware.ts b/packages/core/src/clients/middleware/retry/middleware.ts index bce886abb73..9bf7e093030 100644 --- a/packages/core/src/clients/middleware/retry/middleware.ts +++ b/packages/core/src/clients/middleware/retry/middleware.ts @@ -8,6 +8,8 @@ import { Response, } from '../../types/core'; +import { RetryDeciderOutput } from './types'; + const DEFAULT_RETRY_ATTEMPTS = 3; /** @@ -19,9 +21,14 @@ export interface RetryOptions { * * @param response Optional response of the request. * @param error Optional error thrown from previous attempts. + * @param middlewareContext Optional context object to store data between retries. * @returns True if the request should be retried. */ - retryDecider(response?: TResponse, error?: unknown): Promise; + retryDecider( + response?: TResponse, + error?: unknown, + middlewareContext?: MiddlewareContext, + ): Promise; /** * Function to compute the delay in milliseconds before the next retry based * on the number of attempts. @@ -87,7 +94,14 @@ export const retryMiddlewareFactory = ({ ? (context.attemptsCount ?? 0) : attemptsCount + 1; context.attemptsCount = attemptsCount; - if (await retryDecider(response, error)) { + const { isCredentialsExpiredError, retryable } = await retryDecider( + response, + error, + context, + ); + if (retryable) { + // Setting isCredentialsInvalid flag to notify signing middleware to forceRefresh credentials provider. + context.isCredentialsExpired = !!isCredentialsExpiredError; if (!abortSignal?.aborted && attemptsCount < maxAttempts) { // prevent sleep for last attempt or cancelled request; const delay = computeDelay(attemptsCount); diff --git a/packages/core/src/clients/middleware/retry/types.ts b/packages/core/src/clients/middleware/retry/types.ts new file mode 100644 index 00000000000..a229216edee --- /dev/null +++ b/packages/core/src/clients/middleware/retry/types.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface RetryDeciderOutput { + retryable: boolean; + isCredentialsExpiredError?: boolean; +} diff --git a/packages/core/src/clients/middleware/signing/index.ts b/packages/core/src/clients/middleware/signing/index.ts index a1458bca3e4..1ce90db4b7e 100644 --- a/packages/core/src/clients/middleware/signing/index.ts +++ b/packages/core/src/clients/middleware/signing/index.ts @@ -1,4 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { signingMiddlewareFactory, SigningOptions } from './middleware'; +export { + signingMiddlewareFactory, + SigningOptions, + CredentialsProviderOptions, +} from './middleware'; diff --git a/packages/core/src/clients/middleware/signing/middleware.ts b/packages/core/src/clients/middleware/signing/middleware.ts index a7bed1e6b7f..1b36519729e 100644 --- a/packages/core/src/clients/middleware/signing/middleware.ts +++ b/packages/core/src/clients/middleware/signing/middleware.ts @@ -7,16 +7,27 @@ import { HttpResponse, MiddlewareHandler, } from '../../types'; +import { MiddlewareContext } from '../../types/core'; import { signRequest } from './signer/signatureV4'; import { getSkewCorrectedDate } from './utils/getSkewCorrectedDate'; import { getUpdatedSystemClockOffset } from './utils/getUpdatedSystemClockOffset'; +/** + * Options type for the async callback function returning aws credentials. This + * function is used by SigV4 signer to resolve the aws credentials + */ +export interface CredentialsProviderOptions { + forceRefresh?: boolean; +} + /** * Configuration of the signing middleware */ export interface SigningOptions { - credentials: Credentials | (() => Promise); + credentials: + | Credentials + | ((options?: CredentialsProviderOptions) => Promise); region: string; service: string; @@ -41,12 +52,19 @@ export const signingMiddlewareFactory = ({ }: SigningOptions) => { let currentSystemClockOffset: number; - return (next: MiddlewareHandler) => + return ( + next: MiddlewareHandler, + context: MiddlewareContext, + ) => async function signingMiddleware(request: HttpRequest) { currentSystemClockOffset = currentSystemClockOffset ?? 0; const signRequestOptions = { credentials: - typeof credentials === 'function' ? await credentials() : credentials, + typeof credentials === 'function' + ? await credentials({ + forceRefresh: !!context?.isCredentialsExpired, + }) + : credentials, signingDate: getSkewCorrectedDate(currentSystemClockOffset), signingRegion: region, signingService: service, diff --git a/packages/core/src/clients/types/core.ts b/packages/core/src/clients/types/core.ts index 1fa122250b6..a6348655899 100644 --- a/packages/core/src/clients/types/core.ts +++ b/packages/core/src/clients/types/core.ts @@ -30,6 +30,11 @@ export type MiddlewareHandler = ( * The context object to store states across the middleware chain. */ export interface MiddlewareContext { + /** + * Whether an error indicating expired credentials has been returned from server-side. + * This is set by the retry middleware. + */ + isCredentialsExpired?: boolean; /** * The number of times the request has been attempted. This is set by retry middleware */ diff --git a/packages/core/src/clients/types/index.ts b/packages/core/src/clients/types/index.ts index e2b8953a4d2..0ee905fb162 100644 --- a/packages/core/src/clients/types/index.ts +++ b/packages/core/src/clients/types/index.ts @@ -4,6 +4,7 @@ export { Middleware, MiddlewareHandler, + MiddlewareContext, Request, Response, TransferHandler, diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index c7a5d819487..0fcbd0eebee 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -88,12 +88,14 @@ function parseAuth( oauth, username_attributes, standard_required_attributes, + groups, } = amplifyOutputsAuthProperties; const authConfig = { Cognito: { userPoolId: user_pool_id, userPoolClientId: user_pool_client_id, + groups, }, } as AuthConfig; @@ -373,18 +375,21 @@ function createBucketInfoMap( ): 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, - }; - }); + buckets.forEach( + ({ name, bucket_name: bucketName, aws_region: region, paths }) => { + if (name in mappedBuckets) { + throw new Error( + `Duplicate friendly name found: ${name}. Name must be unique.`, + ); + } + + mappedBuckets[name] = { + bucketName, + region, + paths, + }; + }, + ); return mappedBuckets; } diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index c3a23fc98ab..a862d4e4efe 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -13,7 +13,8 @@ export type AmplifyOutputsAuthMFAConfiguration = | 'NONE'; export type AmplifyOutputsAuthMFAMethod = 'SMS' | 'TOTP'; - +type UserGroupName = string; +type UserGroupPrecedence = Record; export interface AmplifyOutputsAuthProperties { aws_region: string; authentication_flow_type?: 'USER_SRP_AUTH' | 'CUSTOM_AUTH'; @@ -41,6 +42,7 @@ export interface AmplifyOutputsAuthProperties { unauthenticated_identities_enabled?: boolean; mfa_configuration?: string; mfa_methods?: string[]; + groups?: Record[]; } export interface AmplifyOutputsStorageBucketProperties { @@ -50,6 +52,8 @@ export interface AmplifyOutputsStorageBucketProperties { bucket_name: string; /** Region for the bucket */ aws_region: string; + /** Paths to object with access permissions */ + paths?: Record>; } export interface AmplifyOutputsStorageProperties { /** Default region for Storage */ diff --git a/packages/core/src/singleton/Auth/types.ts b/packages/core/src/singleton/Auth/types.ts index fd7bc788472..03265710990 100644 --- a/packages/core/src/singleton/Auth/types.ts +++ b/packages/core/src/singleton/Auth/types.ts @@ -108,6 +108,9 @@ export type LegacyUserAttributeKey = Uppercase; export type AuthVerifiableAttributeKey = 'email' | 'phone_number'; +type UserGroupName = string; +type UserGroupPrecedence = Record; + export type AuthConfigUserAttributes = Partial< Record >; @@ -130,6 +133,7 @@ export interface AuthIdentityPoolConfig { userAttributes?: never; mfa?: never; passwordFormat?: never; + groups?: never; }; } @@ -171,6 +175,7 @@ export interface CognitoUserPoolConfig { requireNumbers?: boolean; requireSpecialCharacters?: boolean; }; + groups?: Record[]; } export interface OAuthConfig { @@ -194,7 +199,6 @@ export type OAuthScope = | 'email' | 'openid' | 'phone' - | 'email' | 'profile' | 'aws.cognito.signin.user.admin' | CustomScope; @@ -255,6 +259,7 @@ interface AWSAuthSignInDetails { * @deprecated */ type AuthFlowType = + | 'USER_AUTH' | 'USER_SRP_AUTH' | 'CUSTOM_WITH_SRP' | 'CUSTOM_WITHOUT_SRP' diff --git a/packages/core/src/singleton/Storage/types.ts b/packages/core/src/singleton/Storage/types.ts index 5bca120c9b3..160c93da2e5 100644 --- a/packages/core/src/singleton/Storage/types.ts +++ b/packages/core/src/singleton/Storage/types.ts @@ -12,6 +12,8 @@ export interface BucketInfo { bucketName: string; /** Region of the bucket */ region: string; + /** Paths to object with access permissions */ + paths?: Record>; } export interface S3ProviderConfig { S3: { diff --git a/packages/core/src/utils/convert/base64/base64Decoder.ts b/packages/core/src/utils/convert/base64/base64Decoder.ts index 216e5fc5e5e..a18a0fd4c82 100644 --- a/packages/core/src/utils/convert/base64/base64Decoder.ts +++ b/packages/core/src/utils/convert/base64/base64Decoder.ts @@ -5,7 +5,15 @@ import { getAtob } from '../../globalHelpers'; import { Base64Decoder } from '../types'; export const base64Decoder: Base64Decoder = { - convert(input) { - return getAtob()(input); + convert(input, options) { + let inputStr = input; + + // urlSafe character replacement options conform to the base64 url spec + // https://datatracker.ietf.org/doc/html/rfc4648#page-7 + if (options?.urlSafe) { + inputStr = inputStr.replace(/-/g, '+').replace(/_/g, '/'); + } + + return getAtob()(inputStr); }, }; diff --git a/packages/core/src/utils/convert/types.ts b/packages/core/src/utils/convert/types.ts index 7a1c4d4d86d..1582aa1cb77 100644 --- a/packages/core/src/utils/convert/types.ts +++ b/packages/core/src/utils/convert/types.ts @@ -1,11 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export interface Base64EncoderConvertOptions { +interface Base64ConvertOptions { urlSafe: boolean; +} +export interface Base64EncoderConvertOptions extends Base64ConvertOptions { skipPadding?: boolean; } +export type Base64DecoderConvertOptions = Base64ConvertOptions; + export interface Base64Encoder { convert( input: Uint8Array | string, @@ -14,5 +18,5 @@ export interface Base64Encoder { } export interface Base64Decoder { - convert(input: string): string; + convert(input: string, options?: Base64DecoderConvertOptions): string; } diff --git a/packages/datastore-storage-adapter/CHANGELOG.md b/packages/datastore-storage-adapter/CHANGELOG.md index f40c5d42bb2..59372de3a11 100644 --- a/packages/datastore-storage-adapter/CHANGELOG.md +++ b/packages/datastore-storage-adapter/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.1.62](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore-storage-adapter@2.1.61...@aws-amplify/datastore-storage-adapter@2.1.62) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/datastore-storage-adapter + +## [2.1.61](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore-storage-adapter@2.1.60...@aws-amplify/datastore-storage-adapter@2.1.61) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/datastore-storage-adapter + ## [2.1.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore-storage-adapter@2.1.59...@aws-amplify/datastore-storage-adapter@2.1.60) (2024-11-13) **Note:** Version bump only for package @aws-amplify/datastore-storage-adapter diff --git a/packages/datastore-storage-adapter/package.json b/packages/datastore-storage-adapter/package.json index ca17c163092..e411ad3e786 100644 --- a/packages/datastore-storage-adapter/package.json +++ b/packages/datastore-storage-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/datastore-storage-adapter", - "version": "2.1.60", + "version": "2.1.62", "description": "SQLite storage adapter for Amplify DataStore ", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -36,8 +36,8 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", - "@aws-amplify/datastore": "5.0.60", + "@aws-amplify/core": "6.7.0", + "@aws-amplify/datastore": "5.0.62", "@types/react-native-sqlite-storage": "5.0.1", "expo-file-system": "13.1.4", "expo-sqlite": "10.1.0", diff --git a/packages/datastore/CHANGELOG.md b/packages/datastore/CHANGELOG.md index c265ab5711e..be55b27c971 100644 --- a/packages/datastore/CHANGELOG.md +++ b/packages/datastore/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [5.0.62](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore@5.0.61...@aws-amplify/datastore@5.0.62) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/datastore + +## [5.0.61](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore@5.0.60...@aws-amplify/datastore@5.0.61) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/datastore + ## [5.0.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/datastore@5.0.59...@aws-amplify/datastore@5.0.60) (2024-11-13) **Note:** Version bump only for package @aws-amplify/datastore diff --git a/packages/datastore/package.json b/packages/datastore/package.json index f7463e9fbee..cd8e398828a 100644 --- a/packages/datastore/package.json +++ b/packages/datastore/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/datastore", - "version": "5.0.60", + "version": "5.0.62", "description": "AppSyncLocal support for aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -44,7 +44,7 @@ "src" ], "dependencies": { - "@aws-amplify/api": "6.1.3", + "@aws-amplify/api": "6.1.5", "buffer": "4.9.2", "idb": "5.0.6", "immer": "9.0.6", @@ -55,7 +55,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", "@types/uuid-validate": "^0.0.1", "dexie": "3.2.2", diff --git a/packages/geo/CHANGELOG.md b/packages/geo/CHANGELOG.md index 4aa7b63e4dd..5491dea8fd1 100644 --- a/packages/geo/CHANGELOG.md +++ b/packages/geo/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [3.0.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/geo@3.0.59...@aws-amplify/geo@3.0.60) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/geo + +## [3.0.59](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/geo@3.0.58...@aws-amplify/geo@3.0.59) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/geo + ## [3.0.58](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/geo@3.0.57...@aws-amplify/geo@3.0.58) (2024-11-13) **Note:** Version bump only for package @aws-amplify/geo diff --git a/packages/geo/package.json b/packages/geo/package.json index 84afbc1ed0e..2382d1d64d6 100644 --- a/packages/geo/package.json +++ b/packages/geo/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/geo", - "version": "3.0.58", + "version": "3.0.60", "description": "Geo category for aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -76,7 +76,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/interactions/CHANGELOG.md b/packages/interactions/CHANGELOG.md index ba6198c37dd..0948d7c87ca 100644 --- a/packages/interactions/CHANGELOG.md +++ b/packages/interactions/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.1.1](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/interactions@6.1.0...@aws-amplify/interactions@6.1.1) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/interactions + +# [6.1.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/interactions@6.0.57...@aws-amplify/interactions@6.1.0) (2024-11-20) + +### Features + +- **storage:** Integrity changes for storage browser ([#13909](https://github.com/aws-amplify/amplify-js/issues/13909)) ([ec7bf6f](https://github.com/aws-amplify/amplify-js/commit/ec7bf6ff2fb4af84425eca4f2d68c2bef7f49d03)), closes [#13478](https://github.com/aws-amplify/amplify-js/issues/13478) [#13474](https://github.com/aws-amplify/amplify-js/issues/13474) + ## [6.0.57](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/interactions@6.0.56...@aws-amplify/interactions@6.0.57) (2024-11-13) **Note:** Version bump only for package @aws-amplify/interactions diff --git a/packages/interactions/package.json b/packages/interactions/package.json index c6afad8222d..dfe9f55f5f9 100644 --- a/packages/interactions/package.json +++ b/packages/interactions/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/interactions", - "version": "6.0.57", + "version": "6.1.1", "description": "Interactions category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -81,7 +81,7 @@ "uuid": "^9.0.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "typescript": "^5.0.2" }, "size-limit": [ @@ -89,19 +89,19 @@ "name": "Interactions (default to Lex v2)", "path": "./dist/esm/index.mjs", "import": "{ Interactions }", - "limit": "52.61 kB" + "limit": "54.05 kB" }, { "name": "Interactions (Lex v2)", "path": "./dist/esm/lex-v2/index.mjs", "import": "{ Interactions }", - "limit": "52.61 kB" + "limit": "54.05 kB" }, { "name": "Interactions (Lex v1)", "path": "./dist/esm/lex-v1/index.mjs", "import": "{ Interactions }", - "limit": "47.41 kB" + "limit": "47.46 kB" } ] } diff --git a/packages/notifications/CHANGELOG.md b/packages/notifications/CHANGELOG.md index 8a53f7eb202..de6b53973fc 100644 --- a/packages/notifications/CHANGELOG.md +++ b/packages/notifications/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.0.60](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/notifications@2.0.59...@aws-amplify/notifications@2.0.60) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/notifications + +## [2.0.59](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/notifications@2.0.58...@aws-amplify/notifications@2.0.59) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/notifications + ## [2.0.58](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/notifications@2.0.57...@aws-amplify/notifications@2.0.58) (2024-11-13) **Note:** Version bump only for package @aws-amplify/notifications diff --git a/packages/notifications/package.json b/packages/notifications/package.json index 05430066a13..614310d8248 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/notifications", - "version": "2.0.58", + "version": "2.0.60", "description": "Notifications category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -98,7 +98,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", "typescript": "5.0.2" } diff --git a/packages/predictions/CHANGELOG.md b/packages/predictions/CHANGELOG.md index 0f3ab4a03d6..9fb1d7451e5 100644 --- a/packages/predictions/CHANGELOG.md +++ b/packages/predictions/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.1.35](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/predictions@6.1.34...@aws-amplify/predictions@6.1.35) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/predictions + +## [6.1.34](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/predictions@6.1.33...@aws-amplify/predictions@6.1.34) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/predictions + ## [6.1.33](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/predictions@6.1.32...@aws-amplify/predictions@6.1.33) (2024-11-13) **Note:** Version bump only for package @aws-amplify/predictions diff --git a/packages/predictions/package.json b/packages/predictions/package.json index c972b675e20..3f37e8af3c5 100644 --- a/packages/predictions/package.json +++ b/packages/predictions/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/predictions", - "version": "6.1.33", + "version": "6.1.35", "description": "Machine learning category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -43,7 +43,7 @@ "src" ], "dependencies": { - "@aws-amplify/storage": "6.6.16", + "@aws-amplify/storage": "6.7.1", "@aws-sdk/client-comprehend": "3.621.0", "@aws-sdk/client-polly": "3.621.0", "@aws-sdk/client-rekognition": "3.621.0", @@ -59,7 +59,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/pubsub/CHANGELOG.md b/packages/pubsub/CHANGELOG.md index f197f2f5fd1..af3cad43c4b 100644 --- a/packages/pubsub/CHANGELOG.md +++ b/packages/pubsub/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.1.35](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/pubsub@6.1.34...@aws-amplify/pubsub@6.1.35) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/pubsub + +## [6.1.34](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/pubsub@6.1.33...@aws-amplify/pubsub@6.1.34) (2024-11-20) + +**Note:** Version bump only for package @aws-amplify/pubsub + ## [6.1.33](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/pubsub@6.1.32...@aws-amplify/pubsub@6.1.33) (2024-11-13) **Note:** Version bump only for package @aws-amplify/pubsub diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 3836690f391..aeaebacf3af 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/pubsub", - "version": "6.1.33", + "version": "6.1.35", "description": "Pubsub category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -73,7 +73,7 @@ "mqtt" ], "dependencies": { - "@aws-amplify/auth": "6.6.2", + "@aws-amplify/auth": "6.8.0", "buffer": "4.9.2", "graphql": "15.8.0", "rxjs": "^7.8.1", @@ -84,7 +84,7 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index f5039e23a09..315cdbfddd3 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [6.7.1](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/storage@6.7.0...@aws-amplify/storage@6.7.1) (2024-11-25) + +**Note:** Version bump only for package @aws-amplify/storage + +# [6.7.0](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/storage@6.6.16...@aws-amplify/storage@6.7.0) (2024-11-20) + +### Features + +- **storage:** Integrity changes for storage browser ([#13909](https://github.com/aws-amplify/amplify-js/issues/13909)) ([ec7bf6f](https://github.com/aws-amplify/amplify-js/commit/ec7bf6ff2fb4af84425eca4f2d68c2bef7f49d03)), closes [#13478](https://github.com/aws-amplify/amplify-js/issues/13478) [#13474](https://github.com/aws-amplify/amplify-js/issues/13474) + ## [6.6.16](https://github.com/aws-amplify/amplify-js/compare/@aws-amplify/storage@6.6.15...@aws-amplify/storage@6.6.16) (2024-11-13) **Note:** Version bump only for package @aws-amplify/storage diff --git a/packages/storage/__tests__/internals/apis/copy.test.ts b/packages/storage/__tests__/internals/apis/copy.test.ts new file mode 100644 index 00000000000..2692f4f6a68 --- /dev/null +++ b/packages/storage/__tests__/internals/apis/copy.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; + +import { copy as advancedCopy } from '../../../src/internals'; +import { copy as copyInternal } from '../../../src/providers/s3/apis/internal/copy'; + +jest.mock('../../../src/providers/s3/apis/internal/copy'); +const mockedCopyInternal = jest.mocked(copyInternal); + +describe('copy (internals)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedCopyInternal.mockResolvedValue({ + path: 'output/path/to/mock/object', + }); + }); + + it('should pass advanced option locationCredentialsProvider to internal list', async () => { + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const copyInputWithAdvancedOptions = { + source: { + path: 'path/to/object', + bucket: 'bucket', + eTag: 'eTag', + notModifiedSince: new Date(), + expectedBucketOwner: '012345678901', + }, + destination: { + path: 'path/to/object', + bucket: 'bucket', + expectedBucketOwner: '212345678901', + }, + options: { + locationCredentialsProvider, + customEndpoint, + }, + }; + const result = await advancedCopy(copyInputWithAdvancedOptions); + expect(mockedCopyInternal).toHaveBeenCalledTimes(1); + expect(mockedCopyInternal).toHaveBeenCalledWith( + expect.any(AmplifyClassV6), + copyInputWithAdvancedOptions, + ); + expect(result).toEqual({ + path: 'output/path/to/mock/object', + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/downloadData.test.ts b/packages/storage/__tests__/internals/apis/downloadData.test.ts new file mode 100644 index 00000000000..f18ea441e69 --- /dev/null +++ b/packages/storage/__tests__/internals/apis/downloadData.test.ts @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { downloadData as advancedDownloadData } from '../../../src/internals'; +import { downloadData as downloadDataInternal } from '../../../src/providers/s3/apis/internal/downloadData'; + +jest.mock('../../../src/providers/s3/apis/internal/downloadData'); +const mockedDownloadDataInternal = jest.mocked(downloadDataInternal); + +describe('downloadData (internal)', () => { + beforeEach(() => { + mockedDownloadDataInternal.mockReturnValue({ + result: Promise.resolve({ + path: 'output/path/to/mock/object', + body: { + blob: () => Promise.resolve(new Blob()), + json: () => Promise.resolve(''), + text: () => Promise.resolve(''), + }, + }), + cancel: jest.fn(), + state: 'SUCCESS', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass advanced option locationCredentialsProvider to internal downloadData', async () => { + const useAccelerateEndpoint = true; + const expectedBucketOwner = '012345678901'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const onProgress = jest.fn(); + const bytesRange = { start: 1024, end: 2048 }; + + const output = await advancedDownloadData({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + locationCredentialsProvider, + onProgress, + bytesRange, + expectedBucketOwner, + }, + }); + + expect(mockedDownloadDataInternal).toHaveBeenCalledTimes(1); + expect(mockedDownloadDataInternal).toHaveBeenCalledWith({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + locationCredentialsProvider, + onProgress, + bytesRange, + expectedBucketOwner, + }, + }); + + expect(await output.result).toEqual({ + path: 'output/path/to/mock/object', + body: { + blob: expect.any(Function), + json: expect.any(Function), + text: expect.any(Function), + }, + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/getDataAccess.test.ts b/packages/storage/__tests__/internals/apis/getDataAccess.test.ts new file mode 100644 index 00000000000..34c41fe2bc7 --- /dev/null +++ b/packages/storage/__tests__/internals/apis/getDataAccess.test.ts @@ -0,0 +1,133 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CredentialsProviderOptions } from '@aws-amplify/core/internals/aws-client-utils'; + +import { getDataAccess } from '../../../src/internals/apis/getDataAccess'; +import { getDataAccess as getDataAccessClient } from '../../../src/providers/s3/utils/client/s3control'; +import { GetDataAccessInput } from '../../../src/internals/types/inputs'; + +jest.mock('../../../src/providers/s3/utils/client/s3control'); + +const MOCK_ACCOUNT_ID = 'accountId'; +const MOCK_REGION = 'us-east-2'; +const MOCK_ACCESS_ID = 'accessId'; +const MOCK_SECRET_ACCESS_KEY = 'secretAccessKey'; +const MOCK_SESSION_TOKEN = 'sessionToken'; +const MOCK_EXPIRATION = '2013-09-17T18:07:53.000Z'; +const MOCK_EXPIRATION_DATE = new Date(MOCK_EXPIRATION); +const MOCK_SCOPE = 's3://mybucket/files/*'; +const MOCK_CREDENTIALS = { + credentials: { + accessKeyId: MOCK_ACCESS_ID, + secretAccessKey: MOCK_SECRET_ACCESS_KEY, + sessionToken: MOCK_SESSION_TOKEN, + expiration: MOCK_EXPIRATION_DATE, + }, +}; +const MOCK_ACCESS_CREDENTIALS = { + AccessKeyId: MOCK_ACCESS_ID, + SecretAccessKey: MOCK_SECRET_ACCESS_KEY, + SessionToken: MOCK_SESSION_TOKEN, + Expiration: MOCK_EXPIRATION_DATE, +}; +const MOCK_CUSTOM_ENDPOINT = 's3-accesspoint.dualstack.us-east-2.amazonaws.com'; +const MOCK_CREDENTIAL_PROVIDER = jest.fn().mockResolvedValue(MOCK_CREDENTIALS); +const sharedGetDataAccessParams: GetDataAccessInput = { + accountId: MOCK_ACCOUNT_ID, + customEndpoint: MOCK_CUSTOM_ENDPOINT, + credentialsProvider: MOCK_CREDENTIAL_PROVIDER, + durationSeconds: 900, + permission: 'READWRITE', + region: MOCK_REGION, + scope: MOCK_SCOPE, +}; + +describe('getDataAccess', () => { + const getDataAccessClientMock = jest.mocked(getDataAccessClient); + + beforeEach(() => { + jest.clearAllMocks(); + + getDataAccessClientMock.mockResolvedValue({ + Credentials: MOCK_ACCESS_CREDENTIALS, + MatchedGrantTarget: MOCK_SCOPE, + $metadata: {}, + }); + }); + + it('should invoke the getDataAccess client correctly', async () => { + expect.assertions(6); + const result = await getDataAccess(sharedGetDataAccessParams); + + expect(getDataAccessClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.any(Function), + customEndpoint: MOCK_CUSTOM_ENDPOINT, + region: MOCK_REGION, + userAgentValue: expect.stringContaining('storage/8'), + }), + expect.objectContaining({ + AccountId: MOCK_ACCOUNT_ID, + Target: MOCK_SCOPE, + Permission: 'READWRITE', + TargetType: undefined, + DurationSeconds: 900, + }), + ); + const inputCredentialsProvider = getDataAccessClientMock.mock.calls[0][0] + .credentials as (input: CredentialsProviderOptions) => any; + expect(inputCredentialsProvider).toBeInstanceOf(Function); + await expect( + inputCredentialsProvider({ forceRefresh: true }), + ).resolves.toEqual(MOCK_CREDENTIALS.credentials); + expect(MOCK_CREDENTIAL_PROVIDER).toHaveBeenCalledWith({ + forceRefresh: true, + }); + + expect(result.credentials).toEqual(MOCK_CREDENTIALS.credentials); + expect(result.scope).toEqual(MOCK_SCOPE); + }); + + it('should throw an error if the service does not return credentials', async () => { + expect.assertions(1); + + getDataAccessClientMock.mockResolvedValue({ + Credentials: undefined, + MatchedGrantTarget: MOCK_SCOPE, + $metadata: {}, + }); + + expect(getDataAccess(sharedGetDataAccessParams)).rejects.toThrow( + 'Service did not return valid temporary credentials.', + ); + }); + + it('should set the correct target type when accessing an object', async () => { + const MOCK_OBJECT_SCOPE = 's3://mybucket/files/file.md'; + + getDataAccessClientMock.mockResolvedValue({ + Credentials: MOCK_ACCESS_CREDENTIALS, + MatchedGrantTarget: MOCK_OBJECT_SCOPE, + $metadata: {}, + }); + + const result = await getDataAccess({ + ...sharedGetDataAccessParams, + scope: MOCK_OBJECT_SCOPE, + }); + + expect(getDataAccessClientMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + AccountId: MOCK_ACCOUNT_ID, + Target: MOCK_OBJECT_SCOPE, + Permission: 'READWRITE', + TargetType: 'Object', + DurationSeconds: 900, + }), + ); + + expect(result.scope).toEqual(MOCK_OBJECT_SCOPE); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/getProperties.test.ts b/packages/storage/__tests__/internals/apis/getProperties.test.ts new file mode 100644 index 00000000000..aa0c2c9815e --- /dev/null +++ b/packages/storage/__tests__/internals/apis/getProperties.test.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; + +import { getProperties as advancedGetProperties } from '../../../src/internals'; +import { getProperties as getPropertiesInternal } from '../../../src/providers/s3/apis/internal/getProperties'; + +jest.mock('../../../src/providers/s3/apis/internal/getProperties'); +const mockedGetPropertiesInternal = jest.mocked(getPropertiesInternal); + +describe('getProperties (internal)', () => { + beforeEach(() => { + mockedGetPropertiesInternal.mockResolvedValue({ + path: 'output/path/to/mock/object', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass advanced option locationCredentialsProvider to internal getProperties', async () => { + const useAccelerateEndpoint = true; + const expectedBucketOwner = '012345678901'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const result = await advancedGetProperties({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }); + expect(mockedGetPropertiesInternal).toHaveBeenCalledTimes(1); + expect(mockedGetPropertiesInternal).toHaveBeenCalledWith( + expect.any(AmplifyClassV6), + { + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }, + ); + expect(result).toEqual({ + path: 'output/path/to/mock/object', + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/getUrl.test.ts b/packages/storage/__tests__/internals/apis/getUrl.test.ts new file mode 100644 index 00000000000..fcffafd3f2e --- /dev/null +++ b/packages/storage/__tests__/internals/apis/getUrl.test.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; + +import { getUrl as advancedGetUrl } from '../../../src/internals'; +import { getUrl as getUrlInternal } from '../../../src/providers/s3/apis/internal/getUrl'; + +jest.mock('../../../src/providers/s3/apis/internal/getUrl'); +const mockedGetUrlInternal = jest.mocked(getUrlInternal); + +const MOCK_URL = new URL('https://s3.aws/mock-presigned-url'); +const MOCK_DATE = new Date(); +MOCK_DATE.setMonth(MOCK_DATE.getMonth() + 1); + +describe('getUrl (internal)', () => { + beforeEach(() => { + mockedGetUrlInternal.mockResolvedValue({ + url: MOCK_URL, + expiresAt: MOCK_DATE, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through advanced options to the internal getUrl', async () => { + const useAccelerateEndpoint = true; + const validateObjectExistence = false; + const expectedBucketOwner = '012345678901'; + const expiresIn = 300; // seconds + const contentDisposition = 'inline; filename="example.jpg"'; + const contentType = 'image/jpeg'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const result = await advancedGetUrl({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + validateObjectExistence, + expiresIn, + contentDisposition, + contentType, + expectedBucketOwner, + locationCredentialsProvider, + }, + }); + expect(mockedGetUrlInternal).toHaveBeenCalledTimes(1); + expect(mockedGetUrlInternal).toHaveBeenCalledWith( + expect.any(AmplifyClassV6), + { + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + validateObjectExistence, + expiresIn, + contentDisposition, + contentType, + expectedBucketOwner, + locationCredentialsProvider, + }, + }, + ); + expect(result).toEqual({ + url: MOCK_URL, + expiresAt: MOCK_DATE, + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/list.test.ts b/packages/storage/__tests__/internals/apis/list.test.ts new file mode 100644 index 00000000000..16ea0e5037b --- /dev/null +++ b/packages/storage/__tests__/internals/apis/list.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; + +import { list as advancedList } from '../../../src/internals'; +import { list as listInternal } from '../../../src/providers/s3/apis/internal/list'; + +jest.mock('../../../src/providers/s3/apis/internal/list'); +const mockedListInternal = jest.mocked(listInternal); + +describe('list (internals)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedListInternal.mockResolvedValue({ + items: [], + }); + }); + + it('should pass advanced option locationCredentialsProvider to internal list', async () => { + const useAccelerateEndpoint = true; + const expectedBucketOwner = '012345678901'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const result = await advancedList({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }); + expect(mockedListInternal).toHaveBeenCalledTimes(1); + expect(mockedListInternal).toHaveBeenCalledWith( + expect.any(AmplifyClassV6), + { + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }, + ); + expect(result).toEqual({ + items: [], + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/listCallerAccessGrants.test.ts b/packages/storage/__tests__/internals/apis/listCallerAccessGrants.test.ts new file mode 100644 index 00000000000..43d96f24488 --- /dev/null +++ b/packages/storage/__tests__/internals/apis/listCallerAccessGrants.test.ts @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import { CredentialsProviderOptions } from '@aws-amplify/core/internals/aws-client-utils'; + +import { listCallerAccessGrants } from '../../../src/internals/apis/listCallerAccessGrants'; +import { listCallerAccessGrants as listCallerAccessGrantsClient } from '../../../src/providers/s3/utils/client/s3control'; + +jest.mock('../../../src/providers/s3/utils/client/s3control'); + +const mockAccountId = '1234567890'; +const mockRegion = 'us-foo-2'; +const mockCredentials = { + accessKeyId: 'key', + secretAccessKey: 'secret', + sessionToken: 'session', + expiration: new Date(), +}; +const mockCredentialsProvider = jest + .fn() + .mockResolvedValue({ credentials: mockCredentials }); +const mockNextToken = '123'; +const mockPageSize = 123; +const mockCustomEndpoint = 's3-accesspoint.dualstack.us-east-2.amazonaws.com'; + +describe('listCallerAccessGrants', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should invoke the listCallerAccessGrants client with expected parameters', async () => { + expect.assertions(4); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [], + $metadata: {} as any, + }); + await listCallerAccessGrants({ + accountId: mockAccountId, + customEndpoint: mockCustomEndpoint, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + nextToken: mockNextToken, + pageSize: mockPageSize, + }); + expect(listCallerAccessGrantsClient).toHaveBeenCalledWith( + expect.objectContaining({ + region: mockRegion, + credentials: expect.any(Function), + customEndpoint: mockCustomEndpoint, + }), + expect.objectContaining({ + AccountId: mockAccountId, + NextToken: mockNextToken, + MaxResults: mockPageSize, + AllowedByApplication: true, + }), + ); + const inputCredentialsProvider = jest.mocked(listCallerAccessGrantsClient) + .mock.calls[0][0].credentials as ( + input: CredentialsProviderOptions, + ) => any; + expect(inputCredentialsProvider).toBeInstanceOf(Function); + await expect( + inputCredentialsProvider({ forceRefresh: true }), + ).resolves.toEqual(mockCredentials); + expect(mockCredentialsProvider).toHaveBeenCalledWith({ + forceRefresh: true, + }); + }); + + it('should set a default page size', async () => { + expect.assertions(1); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [], + $metadata: {} as any, + }); + await listCallerAccessGrants({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + }); + expect(listCallerAccessGrantsClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + MaxResults: 1000, + }), + ); + }); + + it('should set response location type correctly', async () => { + expect.assertions(2); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [ + { + GrantScope: 's3://bucket/*', + Permission: 'READ', + }, + { + GrantScope: 's3://bucket/path/*', + Permission: 'READWRITE', + }, + { + GrantScope: 's3://bucket/path/to/object', + Permission: 'READ', + ApplicationArn: 'arn:123', + }, + ], + $metadata: {} as any, + }); + const { locations, nextToken } = await listCallerAccessGrants({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + }); + + expect(locations).toEqual([ + { + scope: 's3://bucket/*', + type: 'BUCKET', + permission: 'READ', + }, + { + scope: 's3://bucket/path/*', + type: 'PREFIX', + permission: 'READWRITE', + }, + { + scope: 's3://bucket/path/to/object', + type: 'OBJECT', + permission: 'READ', + }, + ]); + expect(nextToken).toBeUndefined(); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/listPaths/getHighestPrecedenceUserGroup.test.ts b/packages/storage/__tests__/internals/apis/listPaths/getHighestPrecedenceUserGroup.test.ts new file mode 100644 index 00000000000..76897ebc0ca --- /dev/null +++ b/packages/storage/__tests__/internals/apis/listPaths/getHighestPrecedenceUserGroup.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + UserGroupConfig, + getHighestPrecedenceUserGroup, +} from '../../../../src/internals/apis/listPaths/getHighestPrecedenceUserGroup'; + +const userGroupsFromConfig: UserGroupConfig = [ + { + editor: { + precedence: 0, + }, + }, + { + admin: { + precedence: 1, + }, + }, + { + auditor: { + precedence: 2, + }, + }, +]; +const currentUserGroups = ['guest', 'user', 'admin']; + +describe('getHighestPrecedenceUserGroup', () => { + it('should return the user group with the highest precedence', () => { + const result = getHighestPrecedenceUserGroup( + userGroupsFromConfig, + currentUserGroups, + ); + expect(result).toBe('admin'); + }); + + it('should return undefined if userGroupsFromConfig is undefined', () => { + const result = getHighestPrecedenceUserGroup(undefined, currentUserGroups); + expect(result).toBeUndefined(); + }); + + it('should return undefined if currentUserGroups is undefined', () => { + const result = getHighestPrecedenceUserGroup( + userGroupsFromConfig, + undefined, + ); + expect(result).toBeUndefined(); + }); + + it('should handle currentUserGroups containing groups not present in userGroupsFromConfig', () => { + const result = getHighestPrecedenceUserGroup(userGroupsFromConfig, [ + 'unknown', + 'user', + ]); + expect(result).toBe(undefined); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/listPaths/listPaths.test.ts b/packages/storage/__tests__/internals/apis/listPaths/listPaths.test.ts new file mode 100644 index 00000000000..dfe1a711c5a --- /dev/null +++ b/packages/storage/__tests__/internals/apis/listPaths/listPaths.test.ts @@ -0,0 +1,202 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify, AuthTokens, fetchAuthSession } from '@aws-amplify/core'; + +import { resolveLocationsForCurrentSession } from '../../../../src/internals/apis/listPaths/resolveLocationsForCurrentSession'; +import { getHighestPrecedenceUserGroup } from '../../../../src/internals/apis/listPaths/getHighestPrecedenceUserGroup'; +import { listPaths } from '../../../../src/internals'; + +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn(), + Amplify: { + getConfig: jest.fn(), + Auth: { + getConfig: jest.fn(), + fetchAuthSession: jest.fn(), + }, + }, + fetchAuthSession: jest.fn(), +})); +jest.mock( + '../../../../src/internals/apis/listPaths/resolveLocationsForCurrentSession', +); +jest.mock( + '../../../../src/internals/apis/listPaths/getHighestPrecedenceUserGroup', +); + +const credentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const identityId = 'identityId'; + +const mockGetConfig = jest.mocked(Amplify.getConfig); +const mockFetchAuthSession = jest.mocked(fetchAuthSession); +const mockResolveLocationsFromCurrentSession = + resolveLocationsForCurrentSession as jest.Mock; +const mockGetHighestPrecedenceUserGroup = jest.mocked( + getHighestPrecedenceUserGroup, +); + +const mockAuthConfig = { + Auth: { + Cognito: { + userPoolClientId: 'userPoolClientId', + userPoolId: 'userPoolId', + identityPoolId: 'identityPoolId', + groups: [{ admin: { precedence: 0 } }], + }, + }, +}; +const mockBuckets = { + bucket1: { + bucketName: 'bucket1', + region: 'region1', + paths: { + '/path1': { + authenticated: ['read', 'write'], + groupsadmin: ['read'], + guest: ['read'], + }, + }, + }, +}; + +describe('listPaths', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + mockGetConfig.mockReturnValue({ + ...mockAuthConfig, + Storage: { + S3: { + bucket: 'bucket1', + region: 'region1', + buckets: { + 'bucket-1': { + bucketName: 'bucket-1', + region: 'region1', + paths: {}, + }, + }, + }, + }, + }); + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId, + tokens: { + accessToken: { payload: {} }, + }, + }); + + it('should return empty locations when buckets are not defined', async () => { + mockGetConfig.mockReturnValue({ + ...mockAuthConfig, + Storage: { S3: { buckets: undefined } }, + }); + + const result = await listPaths(); + + expect(result).toEqual({ locations: [] }); + }); + + it('should generate locations correctly when buckets are defined', async () => { + mockGetConfig.mockReturnValue({ + ...mockAuthConfig, + Storage: { S3: { buckets: mockBuckets } }, + }); + mockResolveLocationsFromCurrentSession.mockReturnValue([ + { + type: 'PREFIX', + permission: ['read', 'write'], + bucket: 'bucket1', + prefix: '/path1', + }, + ]); + + const result = await listPaths(); + + expect(result).toEqual({ + locations: [ + { + type: 'PREFIX', + permission: ['read', 'write'], + bucket: 'bucket1', + prefix: '/path1', + }, + ], + }); + }); + + it('should call resolveLocations with authenticated false for unauthenticated user', async () => { + mockGetConfig.mockReturnValue({ + Auth: { + Cognito: { + userPoolClientId: 'userPoolClientId', + userPoolId: 'userPoolId', + identityPoolId: 'identityPoolId', + groups: [{ admin: { precedence: 0 } }], + }, + }, + + Storage: { S3: { buckets: mockBuckets } }, + }); + mockFetchAuthSession.mockResolvedValue({ + tokens: undefined, + identityId: undefined, + }); + mockResolveLocationsFromCurrentSession.mockReturnValue({ + locations: { + type: 'PREFIX', + permission: ['read'], + bucket: 'bucket1', + prefix: '/path1', + }, + }); + await listPaths(); + + expect(mockResolveLocationsFromCurrentSession).toHaveBeenCalled(); + expect(mockResolveLocationsFromCurrentSession).toHaveBeenCalledWith({ + buckets: mockBuckets, + isAuthenticated: false, + identityId: undefined, + userGroup: undefined, + }); + }); + + it('should call resolveLocations with right userGroup when provided', async () => { + mockGetConfig.mockReturnValue({ + Auth: { + Cognito: { + userPoolClientId: 'userPoolClientId', + userPoolId: 'userPoolId', + identityPoolId: 'identityPoolId', + groups: [{ admin: { precedence: 0 } }], + }, + }, + + Storage: { S3: { buckets: mockBuckets } }, + }); + mockFetchAuthSession.mockResolvedValue({ + tokens: { + accessToken: { payload: {} }, + } as AuthTokens, + identityId: 'identityId', + }); + mockGetHighestPrecedenceUserGroup.mockReturnValue('admin'); + + await listPaths(); + + expect(mockResolveLocationsFromCurrentSession).toHaveBeenCalled(); + expect(mockResolveLocationsFromCurrentSession).toHaveBeenCalledWith({ + buckets: mockBuckets, + isAuthenticated: true, + identityId: 'identityId', + userGroup: 'admin', + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/listPaths/resolveLocationsForCurrentSession.test.ts b/packages/storage/__tests__/internals/apis/listPaths/resolveLocationsForCurrentSession.test.ts new file mode 100644 index 00000000000..3040ca68d5a --- /dev/null +++ b/packages/storage/__tests__/internals/apis/listPaths/resolveLocationsForCurrentSession.test.ts @@ -0,0 +1,141 @@ +import { resolveLocationsForCurrentSession } from '../../../../src/internals/apis/listPaths/resolveLocationsForCurrentSession'; +import { BucketInfo } from '../../../../src/providers/s3/types/options'; + +describe('resolveLocationsForCurrentSession', () => { + const mockBuckets: Record = { + bucket1: { + bucketName: 'bucket1', + region: 'region1', + paths: { + 'path1/*': { + guest: ['get', 'list'], + authenticated: ['get', 'list', 'write'], + }, + 'path2/*': { + groupsauditor: ['get', 'list'], + groupsadmin: ['get', 'list', 'write', 'delete'], + }, + // eslint-disable-next-line no-template-curly-in-string + 'profile-pictures/${cognito-identity.amazonaws.com:sub}/*': { + entityidentity: ['get', 'list', 'write', 'delete'], + }, + }, + }, + bucket2: { + bucketName: 'bucket2', + region: 'region1', + paths: { + 'path3/*': { + guest: ['read'], + }, + }, + }, + }; + + it('should generate locations correctly when tokens are true', () => { + const result = resolveLocationsForCurrentSession({ + buckets: mockBuckets, + isAuthenticated: true, + identityId: '12345', + }); + + expect(result).toEqual([ + { + type: 'PREFIX', + permission: ['get', 'list', 'write'], + bucket: 'bucket1', + prefix: 'path1/*', + }, + { + type: 'PREFIX', + permission: ['get', 'list', 'write', 'delete'], + bucket: 'bucket1', + prefix: 'profile-pictures/12345/*', + }, + ]); + }); + + it('should generate locations correctly when tokens are true & userGroup', () => { + const result = resolveLocationsForCurrentSession({ + buckets: mockBuckets, + isAuthenticated: true, + identityId: '12345', + userGroup: 'admin', + }); + + expect(result).toEqual([ + { + type: 'PREFIX', + permission: ['get', 'list', 'write', 'delete'], + bucket: 'bucket1', + prefix: 'path2/*', + }, + ]); + }); + + it('should return empty locations when tokens are true & bad userGroup', () => { + const result = resolveLocationsForCurrentSession({ + buckets: mockBuckets, + isAuthenticated: true, + identityId: '12345', + userGroup: 'editor', + }); + + expect(result).toEqual([]); + }); + + it('should continue to next bucket when paths are not defined', () => { + const result = resolveLocationsForCurrentSession({ + buckets: { + bucket1: { + bucketName: 'bucket1', + region: 'region1', + paths: undefined, + }, + bucket2: { + bucketName: 'bucket1', + region: 'region1', + paths: { + 'path1/*': { + guest: ['get', 'list'], + authenticated: ['get', 'list', 'write'], + }, + }, + }, + }, + isAuthenticated: true, + identityId: '12345', + }); + + expect(result).toEqual([ + { + type: 'PREFIX', + permission: ['get', 'list', 'write'], + bucket: 'bucket1', + prefix: 'path1/*', + }, + ]); + }); + + it('should generate locations correctly when tokens are false', () => { + const result = resolveLocationsForCurrentSession({ + buckets: mockBuckets, + isAuthenticated: false, + }); + + expect(result).toEqual([ + { + type: 'PREFIX', + permission: ['get', 'list'], + bucket: 'bucket1', + prefix: 'path1/*', + }, + { + type: 'PREFIX', + permission: ['read'], + bucket: 'bucket2', + prefix: 'path3/*', + }, + ]); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/remove.test.ts b/packages/storage/__tests__/internals/apis/remove.test.ts new file mode 100644 index 00000000000..2adab6dd0ef --- /dev/null +++ b/packages/storage/__tests__/internals/apis/remove.test.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; + +import { remove as advancedRemove } from '../../../src/internals'; +import { remove as removeInternal } from '../../../src/providers/s3/apis/internal/remove'; + +jest.mock('../../../src/providers/s3/apis/internal/remove'); +const mockedRemoveInternal = jest.mocked(removeInternal); + +describe('remove (internal)', () => { + beforeEach(() => { + mockedRemoveInternal.mockResolvedValue({ + path: 'output/path/to/mock/object', + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass advanced option locationCredentialsProvider to internal remove', async () => { + const useAccelerateEndpoint = true; + const expectedBucketOwner = '012345678901'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + + const result = await advancedRemove({ + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }); + + expect(mockedRemoveInternal).toHaveBeenCalledTimes(1); + expect(mockedRemoveInternal).toHaveBeenCalledWith( + expect.any(AmplifyClassV6), + { + path: 'input/path/to/mock/object', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + expectedBucketOwner, + locationCredentialsProvider, + }, + }, + ); + expect(result).toEqual({ + path: 'output/path/to/mock/object', + }); + }); +}); diff --git a/packages/storage/__tests__/internals/apis/uploadData.test.ts b/packages/storage/__tests__/internals/apis/uploadData.test.ts new file mode 100644 index 00000000000..c26096d8464 --- /dev/null +++ b/packages/storage/__tests__/internals/apis/uploadData.test.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { uploadData as advancedUploadData } from '../../../src/internals'; +import { uploadData as uploadDataInternal } from '../../../src/providers/s3/apis/internal/uploadData'; + +jest.mock('../../../src/providers/s3/apis/internal/uploadData'); +const mockedUploadDataInternal = jest.mocked(uploadDataInternal); +const mockedUploadTask = 'UPLOAD_TASK'; + +describe('uploadData (internal)', () => { + beforeEach(() => { + mockedUploadDataInternal.mockReturnValue(mockedUploadTask as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass advanced option locationCredentialsProvider to internal remove', async () => { + const useAccelerateEndpoint = true; + const expectedBucketOwner = '012345678901'; + const bucket = { bucketName: 'bucket', region: 'us-east-1' }; + const customEndpoint = 's3.dualstack.us-east-2.amazonaws.com'; + + const locationCredentialsProvider = async () => ({ + credentials: { + accessKeyId: 'akid', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(), + }, + }); + const contentDisposition = { type: 'attachment', filename: 'foo' } as const; + const onProgress = jest.fn(); + const metadata = { foo: 'bar' }; + + const result = advancedUploadData({ + path: 'input/path/to/mock/object', + data: 'data', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + locationCredentialsProvider, + contentDisposition, + contentEncoding: 'gzip', + contentType: 'text/html', + onProgress, + metadata, + expectedBucketOwner, + checksumAlgorithm: 'crc-32', + }, + }); + + expect(mockedUploadDataInternal).toHaveBeenCalledTimes(1); + expect(mockedUploadDataInternal).toHaveBeenCalledWith({ + path: 'input/path/to/mock/object', + data: 'data', + options: { + customEndpoint, + useAccelerateEndpoint, + bucket, + locationCredentialsProvider, + contentDisposition, + contentEncoding: 'gzip', + contentType: 'text/html', + onProgress, + metadata, + expectedBucketOwner, + checksumAlgorithm: 'crc-32', + }, + }); + expect(result).toEqual(mockedUploadTask); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/copy.test.ts b/packages/storage/__tests__/providers/s3/apis/copy.test.ts index 7ddd0430dd8..606786ebfc2 100644 --- a/packages/storage/__tests__/providers/s3/apis/copy.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/copy.test.ts @@ -1,412 +1,44 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; -import { StorageError } from '../../../../src/errors/StorageError'; -import { StorageValidationErrorCode } from '../../../../src/errors/types/validation'; -import { copyObject } from '../../../../src/providers/s3/utils/client'; +import { CopyInput, CopyWithPathInput } from '../../../../src'; import { copy } from '../../../../src/providers/s3/apis'; -import { - CopyInput, - CopyOutput, - CopyWithPathInput, - CopyWithPathOutput, -} from '../../../../src/providers/s3/types'; -import './testUtils'; -import { BucketInfo } from '../../../../src/providers/s3/types/options'; +import { copy as internalCopyImpl } from '../../../../src/providers/s3/apis/internal/copy'; -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); -const mockCopyObject = copyObject as jest.Mock; -const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = Amplify.getConfig as jest.Mock; +jest.mock('../../../../src/providers/s3/apis/internal/copy'); -const sourceKey = 'sourceKey'; -const destinationKey = 'destinationKey'; -const bucket = 'bucket'; -const region = 'region'; -const targetIdentityId = 'targetIdentityId'; -const defaultIdentityId = 'defaultIdentityId'; -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const copyObjectClientConfig = { - credentials, - region, - userAgentValue: expect.any(String), -}; -const copyObjectClientBaseParams = { - Bucket: bucket, - MetadataDirective: 'COPY', -}; +const mockInternalCopyImpl = jest.mocked(internalCopyImpl); -describe('copy API', () => { - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'bucket-1': { bucketName: bucket, region } }, - }, - }, - }); +describe('client-side copy', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe('Happy Cases', () => { - describe('With key', () => { - const copyWrapper = async (input: CopyInput): Promise => - copy(input); - beforeEach(() => { - mockCopyObject.mockImplementation(() => { - return { - Metadata: { key: 'value' }, - }; - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - const testCases: { - source: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; - destination: { - accessLevel?: StorageAccessLevel; - }; - expectedSourceKey: string; - expectedDestinationKey: string; - }[] = [ - { - source: { accessLevel: 'guest' }, - destination: { accessLevel: 'guest' }, - expectedSourceKey: `${bucket}/public/${sourceKey}`, - expectedDestinationKey: `public/${destinationKey}`, - }, - { - source: { accessLevel: 'guest' }, - destination: { accessLevel: 'private' }, - expectedSourceKey: `${bucket}/public/${sourceKey}`, - expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'guest' }, - destination: { accessLevel: 'protected' }, - expectedSourceKey: `${bucket}/public/${sourceKey}`, - expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'private' }, - destination: { accessLevel: 'guest' }, - expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `public/${destinationKey}`, - }, - { - source: { accessLevel: 'private' }, - destination: { accessLevel: 'private' }, - expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'private' }, - destination: { accessLevel: 'protected' }, - expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'protected' }, - destination: { accessLevel: 'guest' }, - expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `public/${destinationKey}`, - }, - { - source: { accessLevel: 'protected' }, - destination: { accessLevel: 'private' }, - expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'protected' }, - destination: { accessLevel: 'protected' }, - expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, - expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'protected', targetIdentityId }, - destination: { accessLevel: 'guest' }, - expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, - expectedDestinationKey: `public/${destinationKey}`, - }, - { - source: { accessLevel: 'protected', targetIdentityId }, - destination: { accessLevel: 'private' }, - expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, - expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, - }, - { - source: { accessLevel: 'protected', targetIdentityId }, - destination: { accessLevel: 'protected' }, - expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, - expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, - }, - ]; - testCases.forEach( - ({ - source, - destination, - expectedSourceKey, - expectedDestinationKey, - }) => { - const targetIdentityIdMsg = source?.targetIdentityId - ? `with targetIdentityId` - : ''; - it(`should copy ${source.accessLevel} ${targetIdentityIdMsg} -> ${destination.accessLevel}`, async () => { - const { key } = await copyWrapper({ - source: { - ...source, - key: sourceKey, - }, - destination: { - ...destination, - key: destinationKey, - }, - }); - expect(key).toEqual(destinationKey); - expect(copyObject).toHaveBeenCalledTimes(1); - await expect(copyObject).toBeLastCalledWithConfigAndInput( - copyObjectClientConfig, - { - ...copyObjectClientBaseParams, - CopySource: expectedSourceKey, - Key: expectedDestinationKey, - }, - ); - }); - }, - ); - - 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', () => { - const copyWrapper = async ( - input: CopyWithPathInput, - ): Promise => copy(input); - - beforeEach(() => { - mockCopyObject.mockImplementation(() => { - return { - Metadata: { key: 'value' }, - }; - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - test.each([ - { - sourcePath: 'sourcePathAsString', - expectedSourcePath: 'sourcePathAsString', - destinationPath: 'destinationPathAsString', - expectedDestinationPath: 'destinationPathAsString', - }, - { - sourcePath: () => 'sourcePathAsFunction', - expectedSourcePath: 'sourcePathAsFunction', - destinationPath: () => 'destinationPathAsFunction', - expectedDestinationPath: 'destinationPathAsFunction', - }, - ])( - 'should copy $sourcePath -> $destinationPath', - async ({ - sourcePath, - expectedSourcePath, - destinationPath, - expectedDestinationPath, - }) => { - const { path } = await copyWrapper({ - source: { path: sourcePath }, - destination: { path: destinationPath }, - }); - expect(path).toEqual(expectedDestinationPath); - expect(copyObject).toHaveBeenCalledTimes(1); - await expect(copyObject).toBeLastCalledWithConfigAndInput( - copyObjectClientConfig, - { - ...copyObjectClientBaseParams, - CopySource: `${bucket}/${expectedSourcePath}`, - Key: expectedDestinationPath, - }, - ); - }, - ); - 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', - }, - ); - }); - }); + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalCopyImpl.mockReturnValue(mockInternalResult); + const input: CopyInput = { + source: { + key: 'source-key', + }, + destination: { + key: 'destination-key', + }, + }; + expect(copy(input)).toEqual(mockInternalResult); + expect(mockInternalCopyImpl).toBeCalledWith(Amplify, input); }); - describe('Error Cases:', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should return a not found error', async () => { - mockCopyObject.mockRejectedValueOnce( - Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }), - ); - expect.assertions(3); - const missingSourceKey = 'SourceKeyNotFound'; - try { - await copy({ - source: { key: missingSourceKey }, - destination: { key: destinationKey }, - }); - } catch (error: any) { - expect(copyObject).toHaveBeenCalledTimes(1); - await expect(copyObject).toBeLastCalledWithConfigAndInput( - copyObjectClientConfig, - { - ...copyObjectClientBaseParams, - CopySource: `${bucket}/public/${missingSourceKey}`, - Key: `public/${destinationKey}`, - }, - ); - expect(error.$metadata.httpStatusCode).toBe(404); - } - }); - - it('should return a path not found error when source uses path and destination uses key', async () => { - expect.assertions(2); - try { - // @ts-expect-error mismatch copy input not allowed - await copy({ - source: { path: 'sourcePath' }, - destination: { key: 'destinationKey' }, - }); - } catch (error: any) { - expect(error).toBeInstanceOf(StorageError); - // source uses path so destination expects path as well - expect(error.name).toBe(StorageValidationErrorCode.NoDestinationPath); - } - }); - - it('should return a key not found error when source uses key and destination uses path', async () => { - expect.assertions(2); - try { - // @ts-expect-error mismatch copy input not allowed - await copy({ - source: { key: 'sourcePath' }, - destination: { path: 'destinationKey' }, - }); - } catch (error: any) { - expect(error).toBeInstanceOf(StorageError); - 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, - ); - } - }); + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalCopyImpl.mockReturnValue(mockInternalResult); + const input: CopyWithPathInput = { + source: { path: 'abc' }, + destination: { path: 'abc' }, + }; + expect(copy(input)).toEqual(mockInternalResult); + expect(mockInternalCopyImpl).toBeCalledWith(Amplify, input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts index 35b790366bc..baf27558169 100644 --- a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts @@ -1,500 +1,40 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { downloadData } from '../../../../src/providers/s3/apis'; +import { downloadData as internalDownloadDataImpl } from '../../../../src/providers/s3/apis/internal/downloadData'; -import { getObject } from '../../../../src/providers/s3/utils/client'; -import { downloadData } from '../../../../src/providers/s3'; -import { - createDownloadTask, - validateStorageOperationInput, -} from '../../../../src/providers/s3/utils'; -import { - DownloadDataInput, - DownloadDataWithPathInput, -} from '../../../../src/providers/s3/types'; -import { - STORAGE_INPUT_KEY, - STORAGE_INPUT_PATH, -} from '../../../../src/providers/s3/utils/constants'; -import { StorageDownloadDataOutput } from '../../../../src/types'; -import { - ItemWithKey, - ItemWithPath, -} from '../../../../src/providers/s3/types/outputs'; -import './testUtils'; -import { BucketInfo } from '../../../../src/providers/s3/types/options'; +jest.mock('../../../../src/providers/s3/apis/internal/downloadData'); -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('../../../../src/providers/s3/utils'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const inputKey = 'key'; -const inputPath = 'path'; -const bucket = 'bucket'; -const region = 'region'; -const targetIdentityId = 'targetIdentityId'; -const defaultIdentityId = 'defaultIdentityId'; -const mockDownloadResultBase = { - body: 'body', - lastModified: 'lastModified', - size: 'contentLength', - eTag: 'eTag', - metadata: 'metadata', - versionId: 'versionId', - contentType: 'contentType', -}; - -const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockCreateDownloadTask = createDownloadTask as jest.Mock; -const mockValidateStorageInput = validateStorageOperationInput as jest.Mock; -const mockGetConfig = jest.mocked(Amplify.getConfig); - -describe('downloadData with key', () => { - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); - }); +const mockInternalDownloadDataImpl = jest.mocked(internalDownloadDataImpl); +describe('client-side downloadData', () => { beforeEach(() => { jest.clearAllMocks(); - - mockCreateDownloadTask.mockReturnValue('downloadTask'); - mockValidateStorageInput.mockReturnValue({ - inputType: STORAGE_INPUT_KEY, - objectKey: inputKey, - }); }); - it('should return a download task with key', async () => { - const mockDownloadInput: DownloadDataInput = { - key: inputKey, - options: { accessLevel: 'protected', targetIdentityId }, - }; - expect(downloadData(mockDownloadInput)).toBe('downloadTask'); - }); - - const testCases: { - expectedKey: string; - options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; - }[] = [ - { - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'guest' }, - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'private' }, - expectedKey: `private/${defaultIdentityId}/${inputKey}`, - }, - { - options: { accessLevel: 'protected' }, - expectedKey: `protected/${defaultIdentityId}/${inputKey}`, - }, - { - options: { accessLevel: 'protected', targetIdentityId }, - expectedKey: `protected/${targetIdentityId}/${inputKey}`, - }, - ]; - - test.each(testCases)( - 'should supply the correct parameters to getObject API handler with $expectedKey accessLevel', - async ({ options, expectedKey }) => { - (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); - const onProgress = jest.fn(); - downloadData({ - key: inputKey, - options: { - ...options, - useAccelerateEndpoint: true, - onProgress, - }, - }); - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - const { key, body }: StorageDownloadDataOutput = await job(); - expect({ key, body }).toEqual({ - key: inputKey, - body: 'body', - }); - expect(getObject).toHaveBeenCalledTimes(1); - await expect(getObject).toBeLastCalledWithConfigAndInput( - { - credentials, - region, - useAccelerateEndpoint: true, - onDownloadProgress: onProgress, - abortSignal: expect.any(AbortSignal), - userAgentValue: expect.any(String), - }, - { - Bucket: bucket, - Key: expectedKey, - }, - ); - }, - ); - - it('should assign the getObject API handler response to the result with key', async () => { - (getObject as jest.Mock).mockResolvedValueOnce({ - Body: 'body', - LastModified: 'lastModified', - ContentLength: 'contentLength', - ETag: 'eTag', - Metadata: 'metadata', - VersionId: 'versionId', - ContentType: 'contentType', - }); - downloadData({ key: inputKey }); - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - const { - key, - body, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }: StorageDownloadDataOutput = await job(); - expect(getObject).toHaveBeenCalledTimes(1); - expect({ - key, - body, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }).toEqual({ - key: inputKey, - ...mockDownloadResultBase, - }); - }); - - it('should forward the bytes range option to the getObject API', async () => { - const start = 1; - const end = 100; - (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); - - downloadData({ - key: inputKey, + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalDownloadDataImpl.mockReturnValue(mockInternalResult); + const input = { + key: 'key', + data: 'data', options: { - bytesRange: { start, end }, - }, - }); - - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - await job(); - - expect(getObject).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - Range: `bytes=${start}-${end}`, - }), - ); - }); - - 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', () => { - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, + accessLevel: 'protected' as const, }, - }); - mockCreateDownloadTask.mockReturnValue('downloadTask'); - mockValidateStorageInput.mockReturnValue({ - inputType: STORAGE_INPUT_PATH, - objectKey: inputPath, - }); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return a download task with path', async () => { - const mockDownloadInput: DownloadDataWithPathInput = { - path: inputPath, - options: { useAccelerateEndpoint: true }, }; - expect(downloadData(mockDownloadInput)).toBe('downloadTask'); - }); - - test.each([ - { - path: inputPath, - expectedKey: inputPath, - }, - { - path: () => inputPath, - expectedKey: inputPath, - }, - ])( - 'should call getObject API with $expectedKey when path provided is $path', - async ({ path, expectedKey }) => { - (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); - const onProgress = jest.fn(); - downloadData({ - path, - options: { - useAccelerateEndpoint: true, - onProgress, - }, - }); - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - const { - path: resultPath, - body, - }: StorageDownloadDataOutput = await job(); - expect({ - path: resultPath, - body, - }).toEqual({ - path: expectedKey, - body: 'body', - }); - expect(getObject).toHaveBeenCalledTimes(1); - await expect(getObject).toBeLastCalledWithConfigAndInput( - { - credentials, - region, - useAccelerateEndpoint: true, - onDownloadProgress: onProgress, - abortSignal: expect.any(AbortSignal), - userAgentValue: expect.any(String), - }, - { - Bucket: bucket, - Key: expectedKey, - }, - ); - }, - ); - - it('should assign the getObject API handler response to the result with path', async () => { - (getObject as jest.Mock).mockResolvedValueOnce({ - Body: 'body', - LastModified: 'lastModified', - ContentLength: 'contentLength', - ETag: 'eTag', - Metadata: 'metadata', - VersionId: 'versionId', - ContentType: 'contentType', - }); - downloadData({ path: inputPath }); - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - const { - path, - body, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }: StorageDownloadDataOutput = await job(); - expect(getObject).toHaveBeenCalledTimes(1); - expect({ - path, - body, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }).toEqual({ - path: inputPath, - ...mockDownloadResultBase, - }); - }); - - it('should forward the bytes range option to the getObject API', async () => { - const start = 1; - const end = 100; - (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); - - downloadData({ - path: inputPath, - options: { - bytesRange: { start, end }, - }, - }); - - const { job } = mockCreateDownloadTask.mock.calls[0][0]; - await job(); - - expect(getObject).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - Range: `bytes=${start}-${end}`, - }), - ); + expect(downloadData(input)).toEqual(mockInternalResult); + expect(mockInternalDownloadDataImpl).toBeCalledWith(input); }); - 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, - }, - ); - }); + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalDownloadDataImpl.mockReturnValue(mockInternalResult); + const input = { + path: 'path', + data: 'data', + }; + expect(downloadData(input)).toEqual(mockInternalResult); + expect(mockInternalDownloadDataImpl).toBeCalledWith(input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts index 0fcd989453e..70367b21e6a 100644 --- a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts @@ -1,414 +1,41 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; -import { headObject } from '../../../../src/providers/s3/utils/client'; -import { getProperties } from '../../../../src/providers/s3'; import { GetPropertiesInput, - GetPropertiesOutput, GetPropertiesWithPathInput, - GetPropertiesWithPathOutput, -} from '../../../../src/providers/s3/types'; -import './testUtils'; -import { BucketInfo } from '../../../../src/providers/s3/types/options'; +} from '../../../../src'; +import { getProperties } from '../../../../src/providers/s3/apis'; +import { getProperties as internalGetPropertiesImpl } from '../../../../src/providers/s3/apis/internal/getProperties'; -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); -const mockHeadObject = headObject as jest.MockedFunction; -const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = jest.mocked(Amplify.getConfig); +jest.mock('../../../../src/providers/s3/apis/internal/getProperties'); -const bucket = 'bucket'; -const region = 'region'; -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const inputKey = 'key'; -const inputPath = 'path'; -const targetIdentityId = 'targetIdentityId'; -const defaultIdentityId = 'defaultIdentityId'; +const mockInternalGetPropertiesImpl = jest.mocked(internalGetPropertiesImpl); -const expectedResult = { - size: 100, - contentType: 'text/plain', - eTag: 'etag', - metadata: { key: 'value' }, - lastModified: new Date('01-01-1980'), - versionId: 'version-id', -}; - -describe('getProperties with key', () => { - const getPropertiesWrapper = ( - input: GetPropertiesInput, - ): Promise => getProperties(input); - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); +describe('client-side getProperties', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe('Happy cases: With key', () => { - const config = { - credentials, - region, - userAgentValue: expect.any(String), + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalGetPropertiesImpl.mockReturnValue(mockInternalResult); + const input: GetPropertiesInput = { + key: 'source-key', }; - beforeEach(() => { - mockHeadObject.mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { key: 'value' }, - VersionId: 'version-id', - $metadata: {} as any, - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - const testCases: { - expectedKey: string; - options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; - }[] = [ - { - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'guest' }, - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'private' }, - expectedKey: `private/${defaultIdentityId}/${inputKey}`, - }, - { - options: { accessLevel: 'protected' }, - expectedKey: `protected/${defaultIdentityId}/${inputKey}`, - }, - { - options: { accessLevel: 'protected', targetIdentityId }, - expectedKey: `protected/${targetIdentityId}/${inputKey}`, - }, - ]; - test.each(testCases)( - 'should getProperties with key $expectedKey', - async ({ options, expectedKey }) => { - const headObjectOptions = { - Bucket: 'bucket', - Key: expectedKey, - }; - const { - key, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - } = await getPropertiesWrapper({ - key: inputKey, - options, - }); - expect({ - key, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }).toEqual({ - key: inputKey, - ...expectedResult, - }); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - }, - ); - - 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}`, - }, - ); - }); - }); + expect(getProperties(input)).toEqual(mockInternalResult); + expect(mockInternalGetPropertiesImpl).toBeCalledWith(Amplify, input); }); - describe('Error cases : With key', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('getProperties should return a not found error', async () => { - mockHeadObject.mockRejectedValueOnce( - Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }), - ); - expect.assertions(3); - try { - await getPropertiesWrapper({ key: inputKey }); - } catch (error: any) { - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - { - credentials, - region: 'region', - userAgentValue: expect.any(String), - }, - { - Bucket: 'bucket', - Key: `public/${inputKey}`, - }, - ); - expect(error.$metadata.httpStatusCode).toBe(404); - } - }); - }); -}); - -describe('Happy cases: With path', () => { - const getPropertiesWrapper = ( - input: GetPropertiesWithPathInput, - ): Promise => getProperties(input); - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); - }); - describe('getProperties with path', () => { - const config = { - credentials, - region: 'region', - useAccelerateEndpoint: true, - userAgentValue: expect.any(String), + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalGetPropertiesImpl.mockReturnValue(mockInternalResult); + const input: GetPropertiesWithPathInput = { + path: 'abc', }; - beforeEach(() => { - mockHeadObject.mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { key: 'value' }, - VersionId: 'version-id', - $metadata: {} as any, - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - test.each([ - { - testPath: inputPath, - expectedPath: inputPath, - }, - { - testPath: () => inputPath, - expectedPath: inputPath, - }, - ])( - 'should getProperties with path $path and expectedPath $expectedPath', - async ({ testPath, expectedPath }) => { - const headObjectOptions = { - Bucket: 'bucket', - Key: expectedPath, - }; - const { - path, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - } = await getPropertiesWrapper({ - path: testPath, - options: { - useAccelerateEndpoint: true, - }, - }); - expect({ - path, - contentType, - eTag, - lastModified, - metadata, - size, - versionId, - }).toEqual({ - path: expectedPath, - ...expectedResult, - }); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - }, - ); - 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', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('getProperties should return a not found error', async () => { - mockHeadObject.mockRejectedValueOnce( - Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }), - ); - expect.assertions(3); - try { - await getPropertiesWrapper({ path: inputPath }); - } catch (error: any) { - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - { - credentials, - region: 'region', - userAgentValue: expect.any(String), - }, - { - Bucket: 'bucket', - Key: inputPath, - }, - ); - expect(error.$metadata.httpStatusCode).toBe(404); - } - }); + expect(getProperties(input)).toEqual(mockInternalResult); + expect(mockInternalGetPropertiesImpl).toBeCalledWith(Amplify, input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts index 52e65ddd1b0..b7e43285d49 100644 --- a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts @@ -1,490 +1,38 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; +import { GetUrlInput, GetUrlWithPathInput } from '../../../../src'; import { getUrl } from '../../../../src/providers/s3/apis'; -import { - getPresignedGetObjectUrl, - headObject, -} from '../../../../src/providers/s3/utils/client'; -import { - GetUrlInput, - GetUrlOutput, - GetUrlWithPathInput, - GetUrlWithPathOutput, -} from '../../../../src/providers/s3/types'; -import './testUtils'; -import { BucketInfo } from '../../../../src/providers/s3/types/options'; +import { getUrl as internalGetUrlImpl } from '../../../../src/providers/s3/apis/internal/getUrl'; -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); +jest.mock('../../../../src/providers/s3/apis/internal/getUrl'); -const bucket = 'bucket'; -const region = 'region'; -const mockFetchAuthSession = jest.mocked(Amplify.Auth.fetchAuthSession); -const mockGetConfig = jest.mocked(Amplify.getConfig); -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const targetIdentityId = 'targetIdentityId'; -const defaultIdentityId = 'defaultIdentityId'; -const mockURL = new URL('https://google.com'); +const mockInternalGetUrlImpl = jest.mocked(internalGetUrlImpl); -describe('getUrl test with key', () => { - const getUrlWrapper = (input: GetUrlInput): Promise => - getUrl(input); - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); +describe('client-side getUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe('Happy cases: With key', () => { - const config = { - credentials, - region, - userAgentValue: expect.any(String), + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalGetUrlImpl.mockReturnValue(mockInternalResult); + const input: GetUrlInput = { + key: 'source-key', }; - const key = 'key'; - beforeEach(() => { - jest.mocked(headObject).mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { meta: 'value' }, - $metadata: {} as any, - }); - jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - const testCases: { - options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; - expectedKey: string; - }[] = [ - { - expectedKey: `public/${key}`, - }, - { - options: { accessLevel: 'guest' }, - expectedKey: `public/${key}`, - }, - { - options: { accessLevel: 'private' }, - expectedKey: `private/${defaultIdentityId}/${key}`, - }, - { - options: { accessLevel: 'protected' }, - expectedKey: `protected/${defaultIdentityId}/${key}`, - }, - { - options: { accessLevel: 'protected', targetIdentityId }, - expectedKey: `protected/${targetIdentityId}/${key}`, - }, - ]; - - test.each(testCases)( - 'should getUrl with key $expectedKey', - async ({ options, expectedKey }) => { - const headObjectOptions = { - Bucket: bucket, - Key: expectedKey, - }; - const { url, expiresAt } = await getUrlWrapper({ - key, - options: { - ...options, - validateObjectExistence: true, - }, - }); - const expectedResult = { - url: mockURL, - expiresAt: expect.any(Date), - }; - expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - 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(() => { - jest.clearAllMocks(); - }); - it('should return not found error when the object is not found', async () => { - (headObject as jest.Mock).mockImplementation(() => { - throw Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }); - }); - expect.assertions(2); - try { - await getUrlWrapper({ - key: 'invalid_key', - options: { validateObjectExistence: true }, - }); - } catch (error: any) { - expect(headObject).toHaveBeenCalledTimes(1); - expect(error.$metadata?.httpStatusCode).toBe(404); - } - }); - }); -}); - -describe('getUrl test with path', () => { - const getUrlWrapper = ( - input: GetUrlWithPathInput, - ): Promise => getUrl(input); - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); - }); - - describe('Happy cases: With path', () => { - const config = { - credentials, - region, - userAgentValue: expect.any(String), - }; - beforeEach(() => { - jest.mocked(headObject).mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { meta: 'value' }, - $metadata: {} as any, - }); - jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - test.each([ - { - path: 'path', - expectedKey: 'path', - }, - { - path: () => 'path', - expectedKey: 'path', - }, - ])( - 'should getUrl with path $path and expectedKey $expectedKey', - async ({ path, expectedKey }) => { - const headObjectOptions = { - Bucket: bucket, - Key: expectedKey, - }; - const { url, expiresAt } = await getUrlWrapper({ - path, - options: { - validateObjectExistence: true, - }, - }); - expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - expect({ url, expiresAt }).toEqual({ - url: mockURL, - expiresAt: expect.any(Date), - }); - }, - ); - - 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, - }, - ); - }); - }); + expect(getUrl(input)).toEqual(mockInternalResult); + expect(mockInternalGetUrlImpl).toBeCalledWith(Amplify, input); }); - describe('Happy cases: With path and Content Disposition, Content Type', () => { - const config = { - credentials, - region, - userAgentValue: expect.any(String), - }; - beforeEach(() => { - jest.mocked(headObject).mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { meta: 'value' }, - $metadata: {} as any, - }); - jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - test.each([ - { - path: 'path', - expectedKey: 'path', - contentDisposition: 'inline; filename="example.txt"', - contentType: 'text/plain', - }, - { - path: () => 'path', - expectedKey: 'path', - contentDisposition: { - type: 'attachment' as const, - filename: 'example.pdf', - }, - contentType: 'application/pdf', - }, - ])( - 'should getUrl with path $path and expectedKey $expectedKey and content disposition and content type', - async ({ path, expectedKey, contentDisposition, contentType }) => { - const headObjectOptions = { - Bucket: bucket, - Key: expectedKey, - }; - const { url, expiresAt } = await getUrlWrapper({ - path, - options: { - validateObjectExistence: true, - contentDisposition, - contentType, - }, - }); - expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - expect({ url, expiresAt }).toEqual({ - url: mockURL, - expiresAt: expect.any(Date), - }); - }, - ); - }); - describe('Error cases: With invalid Content Disposition', () => { - const config = { - credentials, - region, - userAgentValue: expect.any(String), + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalGetUrlImpl.mockReturnValue(mockInternalResult); + const input: GetUrlWithPathInput = { + path: 'abc', }; - beforeEach(() => { - jest.mocked(headObject).mockResolvedValue({ - ContentLength: 100, - ContentType: 'text/plain', - ETag: 'etag', - LastModified: new Date('01-01-1980'), - Metadata: { meta: 'value' }, - $metadata: {} as any, - }); - jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - test.each([ - { - path: 'path', - expectedKey: 'path', - contentDisposition: { - type: 'invalid' as 'attachment' | 'inline', - filename: '"example.txt', - }, - }, - { - path: 'path', - expectedKey: 'path', - contentDisposition: { - type: 'invalid' as 'attachment' | 'inline', - }, - }, - ])( - 'should ignore for invalid content disposition: $contentDisposition', - async ({ path, expectedKey, contentDisposition }) => { - const headObjectOptions = { - Bucket: bucket, - Key: expectedKey, - }; - const { url, expiresAt } = await getUrlWrapper({ - path, - options: { - validateObjectExistence: true, - contentDisposition, - }, - }); - expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); - expect(headObject).toHaveBeenCalledTimes(1); - await expect(headObject).toBeLastCalledWithConfigAndInput( - config, - headObjectOptions, - ); - expect({ url, expiresAt }).toEqual({ - url: mockURL, - expiresAt: expect.any(Date), - }); - }, - ); - }); - describe('Error cases : With path', () => { - afterAll(() => { - jest.clearAllMocks(); - }); - it('should return not found error when the object is not found', async () => { - (headObject as jest.Mock).mockImplementation(() => { - throw Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }); - }); - expect.assertions(2); - try { - await getUrlWrapper({ - path: 'invalid_key', - options: { validateObjectExistence: true }, - }); - } catch (error: any) { - expect(headObject).toHaveBeenCalledTimes(1); - expect(error.$metadata?.httpStatusCode).toBe(404); - } - }); + expect(getUrl(input)).toEqual(mockInternalResult); + expect(mockInternalGetUrlImpl).toBeCalledWith(Amplify, input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/copy.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/copy.test.ts new file mode 100644 index 00000000000..51b0e65fa79 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/copy.test.ts @@ -0,0 +1,533 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { StorageError } from '../../../../../src/errors/StorageError'; +import { StorageValidationErrorCode } from '../../../../../src/errors/types/validation'; +import { copyObject } from '../../../../../src/providers/s3/utils/client/s3data'; +import { copy } from '../../../../../src/providers/s3/apis/internal/copy'; +import { + CopyInput, + CopyOutput, + CopyWithPathInput, + CopyWithPathOutput, +} from '../../../../../src/providers/s3/types'; +import './testUtils'; +import { BucketInfo } from '../../../../../src/providers/s3/types/options'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); +const mockCopyObject = copyObject as jest.Mock; +const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; +const mockGetConfig = Amplify.getConfig as jest.Mock; + +const sourceKey = 'sourceKey'; +const destinationKey = 'destinationKey'; +const bucket = 'bucket'; +const region = 'region'; +const targetIdentityId = 'targetIdentityId'; +const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const validBucketOwner2 = '123456789012'; +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const copyObjectClientConfig = { + credentials, + region, + userAgentValue: expect.any(String), +}; +const copyObjectClientBaseParams = { + Bucket: bucket, + MetadataDirective: 'COPY', +}; + +describe('copy API', () => { + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'bucket-1': { bucketName: bucket, region } }, + }, + }, + }); + }); + + describe('Happy Cases', () => { + describe('With key', () => { + const copyWrapper = async (input: CopyInput) => copy(Amplify, input); + beforeEach(() => { + mockCopyObject.mockImplementation(() => { + return { + Metadata: { key: 'value' }, + }; + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + const testCases: { + source: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; + destination: { + accessLevel?: StorageAccessLevel; + }; + expectedSourceKey: string; + expectedDestinationKey: string; + }[] = [ + { + source: { accessLevel: 'guest' }, + destination: { accessLevel: 'guest' }, + expectedSourceKey: `${bucket}/public/${sourceKey}`, + expectedDestinationKey: `public/${destinationKey}`, + }, + { + source: { accessLevel: 'guest' }, + destination: { accessLevel: 'private' }, + expectedSourceKey: `${bucket}/public/${sourceKey}`, + expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'guest' }, + destination: { accessLevel: 'protected' }, + expectedSourceKey: `${bucket}/public/${sourceKey}`, + expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'private' }, + destination: { accessLevel: 'guest' }, + expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `public/${destinationKey}`, + }, + { + source: { accessLevel: 'private' }, + destination: { accessLevel: 'private' }, + expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'private' }, + destination: { accessLevel: 'protected' }, + expectedSourceKey: `${bucket}/private/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'protected' }, + destination: { accessLevel: 'guest' }, + expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `public/${destinationKey}`, + }, + { + source: { accessLevel: 'protected' }, + destination: { accessLevel: 'private' }, + expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'protected' }, + destination: { accessLevel: 'protected' }, + expectedSourceKey: `${bucket}/protected/${defaultIdentityId}/${sourceKey}`, + expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'protected', targetIdentityId }, + destination: { accessLevel: 'guest' }, + expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, + expectedDestinationKey: `public/${destinationKey}`, + }, + { + source: { accessLevel: 'protected', targetIdentityId }, + destination: { accessLevel: 'private' }, + expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, + expectedDestinationKey: `private/${defaultIdentityId}/${destinationKey}`, + }, + { + source: { accessLevel: 'protected', targetIdentityId }, + destination: { accessLevel: 'protected' }, + expectedSourceKey: `${bucket}/protected/${targetIdentityId}/${sourceKey}`, + expectedDestinationKey: `protected/${defaultIdentityId}/${destinationKey}`, + }, + ]; + testCases.forEach( + ({ + source, + destination, + expectedSourceKey, + expectedDestinationKey, + }) => { + const targetIdentityIdMsg = source?.targetIdentityId + ? `with targetIdentityId` + : ''; + it(`should copy ${source.accessLevel} ${targetIdentityIdMsg} -> ${destination.accessLevel}`, async () => { + const { key } = (await copyWrapper({ + source: { + ...source, + key: sourceKey, + }, + destination: { + ...destination, + key: destinationKey, + }, + })) as CopyOutput; + expect(key).toEqual(destinationKey); + expect(copyObject).toHaveBeenCalledTimes(1); + await expect(copyObject).toBeLastCalledWithConfigAndInput( + copyObjectClientConfig, + { + ...copyObjectClientBaseParams, + CopySource: expectedSourceKey, + Key: expectedDestinationKey, + }, + ); + }); + }, + ); + + 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', + }, + ); + }); + + it('should pass notModifiedSince to copyObject', async () => { + const mockDate = 'mock-date' as any; + await copyWrapper({ + source: { + key: 'sourceKey', + notModifiedSince: mockDate, + }, + destination: { key: 'destinationKey' }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + CopySourceIfUnmodifiedSince: mockDate, + }), + ); + }); + + it('should pass eTag to copyObject', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + key: 'sourceKey', + eTag: mockEtag, + }, + destination: { key: 'destinationKey' }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + CopySourceIfMatch: mockEtag, + }), + ); + }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + key: 'sourceKey', + eTag: mockEtag, + expectedBucketOwner: validBucketOwner, + }, + destination: { + key: 'destinationKey', + expectedBucketOwner: validBucketOwner2, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ExpectedSourceBucketOwner: validBucketOwner, + ExpectedBucketOwner: validBucketOwner2, + }), + ); + }); + }); + }); + + describe('With path', () => { + const copyWrapper = async (input: CopyWithPathInput) => + copy(Amplify, input); + + beforeEach(() => { + mockCopyObject.mockImplementation(() => { + return { + Metadata: { key: 'value' }, + }; + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + { + sourcePath: 'sourcePathAsString', + expectedSourcePath: 'sourcePathAsString', + destinationPath: 'destinationPathAsString', + expectedDestinationPath: 'destinationPathAsString', + }, + { + sourcePath: () => 'sourcePathAsFunction', + expectedSourcePath: 'sourcePathAsFunction', + destinationPath: () => 'destinationPathAsFunction', + expectedDestinationPath: 'destinationPathAsFunction', + }, + ])( + 'should copy $sourcePath -> $destinationPath', + async ({ + sourcePath, + expectedSourcePath, + destinationPath, + expectedDestinationPath, + }) => { + const { path } = (await copyWrapper({ + source: { path: sourcePath }, + destination: { path: destinationPath }, + })) as CopyWithPathOutput; + expect(path).toEqual(expectedDestinationPath); + expect(copyObject).toHaveBeenCalledTimes(1); + await expect(copyObject).toBeLastCalledWithConfigAndInput( + copyObjectClientConfig, + { + ...copyObjectClientBaseParams, + CopySource: `${bucket}/${expectedSourcePath}`, + Key: expectedDestinationPath, + }, + ); + }, + ); + 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', + }, + ); + }); + + it('should pass notModifiedSince to copyObject', async () => { + const mockDate = 'mock-date' as any; + await copyWrapper({ + source: { + path: 'sourcePath', + notModifiedSince: mockDate, + }, + destination: { path: 'destinationPath' }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + CopySourceIfUnmodifiedSince: mockDate, + }), + ); + }); + + it('should pass eTag to copyObject', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + path: 'sourcePath', + eTag: mockEtag, + }, + destination: { path: 'destinationPath' }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + CopySourceIfMatch: mockEtag, + }), + ); + }); + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + path: 'public/sourceKey', + eTag: mockEtag, + expectedBucketOwner: validBucketOwner, + }, + destination: { + path: 'public/destinationKey', + expectedBucketOwner: validBucketOwner2, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ExpectedSourceBucketOwner: validBucketOwner, + ExpectedBucketOwner: validBucketOwner2, + }), + ); + }); + }); + }); + }); + + describe('Error Cases:', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return a not found error', async () => { + mockCopyObject.mockRejectedValueOnce( + Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }), + ); + expect.assertions(3); + const missingSourceKey = 'SourceKeyNotFound'; + try { + await copy(Amplify, { + source: { key: missingSourceKey }, + destination: { key: destinationKey }, + }); + } catch (error: any) { + expect(copyObject).toHaveBeenCalledTimes(1); + await expect(copyObject).toBeLastCalledWithConfigAndInput( + copyObjectClientConfig, + { + ...copyObjectClientBaseParams, + CopySource: `${bucket}/public/${missingSourceKey}`, + Key: `public/${destinationKey}`, + }, + ); + expect(error.$metadata.httpStatusCode).toBe(404); + } + }); + + it('should return a path not found error when source uses path and destination uses key', async () => { + expect.assertions(2); + try { + // @ts-expect-error mismatch copy input not allowed + await copy(Amplify, { + source: { path: 'sourcePath' }, + destination: { key: 'destinationKey' }, + }); + } catch (error: any) { + expect(error).toBeInstanceOf(StorageError); + // source uses path so destination expects path as well + expect(error.name).toBe(StorageValidationErrorCode.NoDestinationPath); + } + }); + + it('should return a key not found error when source uses key and destination uses path', async () => { + expect.assertions(2); + try { + // @ts-expect-error mismatch copy input not allowed + await copy(Amplify, { + source: { key: 'sourcePath' }, + destination: { path: 'destinationKey' }, + }); + } catch (error: any) { + expect(error).toBeInstanceOf(StorageError); + expect(error.name).toBe(StorageValidationErrorCode.NoDestinationKey); + } + }); + + it('should throw an error when only source has bucket option', async () => { + expect.assertions(2); + try { + await copy(Amplify, { + 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(Amplify, { + 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/internal/downloadData.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts new file mode 100644 index 00000000000..cb0dafdaf27 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/downloadData.test.ts @@ -0,0 +1,547 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { getObject } from '../../../../../src/providers/s3/utils/client/s3data'; +import { downloadData } from '../../../../../src/providers/s3/apis/internal/downloadData'; +import { + createDownloadTask, + validateStorageOperationInput, +} from '../../../../../src/providers/s3/utils'; +import { + DownloadDataInput, + DownloadDataWithPathInput, +} from '../../../../../src/providers/s3/types'; +import { + STORAGE_INPUT_KEY, + STORAGE_INPUT_PATH, +} from '../../../../../src/providers/s3/utils/constants'; +import { StorageDownloadDataOutput } from '../../../../../src/types'; +import { + ItemWithKey, + ItemWithPath, +} from '../../../../../src/providers/s3/types/outputs'; +import './testUtils'; +import { BucketInfo } from '../../../../../src/providers/s3/types/options'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('../../../../../src/providers/s3/utils'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const inputKey = 'key'; +const inputPath = 'path'; +const bucket = 'bucket'; +const region = 'region'; +const targetIdentityId = 'targetIdentityId'; +const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const mockDownloadResultBase = { + body: 'body', + lastModified: 'lastModified', + size: 'contentLength', + eTag: 'eTag', + metadata: 'metadata', + versionId: 'versionId', + contentType: 'contentType', +}; + +const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; +const mockCreateDownloadTask = createDownloadTask as jest.Mock; +const mockValidateStorageInput = validateStorageOperationInput as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); + +describe('downloadData with key', () => { + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockCreateDownloadTask.mockReturnValue('downloadTask'); + mockValidateStorageInput.mockReturnValue({ + inputType: STORAGE_INPUT_KEY, + objectKey: inputKey, + }); + }); + + it('should return a download task with key', async () => { + const mockDownloadInput: DownloadDataInput = { + key: inputKey, + options: { accessLevel: 'protected', targetIdentityId }, + }; + expect(downloadData(mockDownloadInput)).toBe('downloadTask'); + }); + + const testCases: { + expectedKey: string; + options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; + }[] = [ + { + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'guest' }, + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'private' }, + expectedKey: `private/${defaultIdentityId}/${inputKey}`, + }, + { + options: { accessLevel: 'protected' }, + expectedKey: `protected/${defaultIdentityId}/${inputKey}`, + }, + { + options: { accessLevel: 'protected', targetIdentityId }, + expectedKey: `protected/${targetIdentityId}/${inputKey}`, + }, + ]; + + test.each(testCases)( + 'should supply the correct parameters to getObject API handler with $expectedKey accessLevel', + async ({ options, expectedKey }) => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const onProgress = jest.fn(); + downloadData({ + key: inputKey, + options: { + ...options, + useAccelerateEndpoint: true, + onProgress, + }, + }); + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + const { key, body }: StorageDownloadDataOutput = await job(); + expect({ key, body }).toEqual({ + key: inputKey, + body: 'body', + }); + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + useAccelerateEndpoint: true, + onDownloadProgress: onProgress, + abortSignal: expect.any(AbortSignal), + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: expectedKey, + }, + ); + }, + ); + + it('should assign the getObject API handler response to the result with key', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ + Body: 'body', + LastModified: 'lastModified', + ContentLength: 'contentLength', + ETag: 'eTag', + Metadata: 'metadata', + VersionId: 'versionId', + ContentType: 'contentType', + }); + downloadData({ key: inputKey }); + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + const { + key, + body, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }: StorageDownloadDataOutput = await job(); + expect(getObject).toHaveBeenCalledTimes(1); + expect({ + key, + body, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }).toEqual({ + key: inputKey, + ...mockDownloadResultBase, + }); + }); + + it('should forward the bytes range option to the getObject API', async () => { + const start = 1; + const end = 100; + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + + downloadData({ + key: inputKey, + options: { + bytesRange: { start, end }, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + Range: `bytes=${start}-${end}`, + }), + ); + }); + + 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('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + key: inputKey, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); +}); + +describe('downloadData with path', () => { + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + mockCreateDownloadTask.mockReturnValue('downloadTask'); + mockValidateStorageInput.mockReturnValue({ + inputType: STORAGE_INPUT_PATH, + objectKey: inputPath, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a download task with path', async () => { + const mockDownloadInput: DownloadDataWithPathInput = { + path: inputPath, + options: { useAccelerateEndpoint: true }, + }; + expect(downloadData(mockDownloadInput)).toBe('downloadTask'); + }); + + test.each([ + { + path: inputPath, + expectedKey: inputPath, + }, + { + path: () => inputPath, + expectedKey: inputPath, + }, + ])( + 'should call getObject API with $expectedKey when path provided is $path', + async ({ path, expectedKey }) => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + const onProgress = jest.fn(); + downloadData({ + path, + options: { + useAccelerateEndpoint: true, + onProgress, + }, + }); + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + const { + path: resultPath, + body, + }: StorageDownloadDataOutput = await job(); + expect({ + path: resultPath, + body, + }).toEqual({ + path: expectedKey, + body: 'body', + }); + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + useAccelerateEndpoint: true, + onDownloadProgress: onProgress, + abortSignal: expect.any(AbortSignal), + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: expectedKey, + }, + ); + }, + ); + + it('should assign the getObject API handler response to the result with path', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ + Body: 'body', + LastModified: 'lastModified', + ContentLength: 'contentLength', + ETag: 'eTag', + Metadata: 'metadata', + VersionId: 'versionId', + ContentType: 'contentType', + }); + downloadData({ path: inputPath }); + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + const { + path, + body, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }: StorageDownloadDataOutput = await job(); + expect(getObject).toHaveBeenCalledTimes(1); + expect({ + path, + body, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }).toEqual({ + path: inputPath, + ...mockDownloadResultBase, + }); + }); + + it('should forward the bytes range option to the getObject API', async () => { + const start = 1; + const end = 100; + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + + downloadData({ + path: inputPath, + options: { + bytesRange: { start, end }, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + Range: `bytes=${start}-${end}`, + }), + ); + }); + + 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, + }, + ); + }); + }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/getProperties.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/getProperties.test.ts new file mode 100644 index 00000000000..01d7a73ef2c --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/getProperties.test.ts @@ -0,0 +1,500 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { headObject } from '../../../../../src/providers/s3/utils/client/s3data'; +import { getProperties } from '../../../../../src/providers/s3/apis/internal/getProperties'; +import { + GetPropertiesInput, + GetPropertiesOutput, + GetPropertiesWithPathInput, + GetPropertiesWithPathOutput, +} from '../../../../../src/providers/s3/types'; +import './testUtils'; +import { BucketInfo } from '../../../../../src/providers/s3/types/options'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); +const mockHeadObject = headObject as jest.MockedFunction; +const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); + +const bucket = 'bucket'; +const region = 'region'; +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const inputKey = 'key'; +const inputPath = 'path'; +const targetIdentityId = 'targetIdentityId'; +const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const invalidBucketOwner = '123'; + +const expectedResult = { + size: 100, + contentType: 'text/plain', + eTag: 'etag', + metadata: { key: 'value' }, + lastModified: new Date('01-01-1980'), + versionId: 'version-id', +}; + +describe('getProperties with key', () => { + const getPropertiesWrapper = (input: GetPropertiesInput) => + getProperties(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + describe('Happy cases: With key', () => { + const config = { + credentials, + region, + userAgentValue: expect.any(String), + }; + beforeEach(() => { + mockHeadObject.mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { key: 'value' }, + VersionId: 'version-id', + $metadata: {} as any, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + const testCases: { + expectedKey: string; + options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; + }[] = [ + { + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'guest' }, + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'private' }, + expectedKey: `private/${defaultIdentityId}/${inputKey}`, + }, + { + options: { accessLevel: 'protected' }, + expectedKey: `protected/${defaultIdentityId}/${inputKey}`, + }, + { + options: { accessLevel: 'protected', targetIdentityId }, + expectedKey: `protected/${targetIdentityId}/${inputKey}`, + }, + ]; + test.each(testCases)( + 'should getProperties with key $expectedKey', + async ({ options, expectedKey }) => { + const headObjectOptions = { + Bucket: 'bucket', + Key: expectedKey, + }; + const { + key, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + } = (await getPropertiesWrapper({ + key: inputKey, + options, + })) as GetPropertiesOutput; + expect({ + key, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }).toEqual({ + key: inputKey, + ...expectedResult, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + }, + ); + + 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', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('getProperties should return a not found error', async () => { + mockHeadObject.mockRejectedValueOnce( + Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }), + ); + expect.assertions(3); + try { + await getPropertiesWrapper({ key: inputKey }); + } catch (error: any) { + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: 'region', + userAgentValue: expect.any(String), + }, + { + Bucket: 'bucket', + Key: `public/${inputKey}`, + }, + ); + expect(error.$metadata.httpStatusCode).toBe(404); + } + }); + }); +}); + +describe('Happy cases: With path', () => { + const getPropertiesWrapper = (input: GetPropertiesWithPathInput) => + getProperties(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + describe('getProperties with path', () => { + const config = { + credentials, + region: 'region', + useAccelerateEndpoint: true, + userAgentValue: expect.any(String), + }; + beforeEach(() => { + mockHeadObject.mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { key: 'value' }, + VersionId: 'version-id', + $metadata: {} as any, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test.each([ + { + testPath: inputPath, + expectedPath: inputPath, + }, + { + testPath: () => inputPath, + expectedPath: inputPath, + }, + ])( + 'should getProperties with path $path and expectedPath $expectedPath', + async ({ testPath, expectedPath }) => { + const headObjectOptions = { + Bucket: 'bucket', + Key: expectedPath, + }; + const { + path, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + } = (await getPropertiesWrapper({ + path: testPath, + options: { + useAccelerateEndpoint: true, + }, + })) as GetPropertiesWithPathOutput; + expect({ + path, + contentType, + eTag, + lastModified, + metadata, + size, + versionId, + }).toEqual({ + path: expectedPath, + ...expectedResult, + }); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + }, + ); + 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', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('getProperties should return a not found error', async () => { + mockHeadObject.mockRejectedValueOnce( + Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }), + ); + expect.assertions(3); + try { + await getPropertiesWrapper({ path: inputPath }); + } catch (error: any) { + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region: 'region', + userAgentValue: expect.any(String), + }, + { + Bucket: 'bucket', + Key: inputPath, + }, + ); + expect(error.$metadata.httpStatusCode).toBe(404); + } + }); + }); +}); + +describe(`getProperties with path and Expected Bucket Owner`, () => { + const getPropertiesWrapper = (input: GetPropertiesWithPathInput) => + getProperties(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass expectedBucketOwner to headObject', async () => { + const path = 'public/expectedbucketowner_test'; + + await getPropertiesWrapper({ + path, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + ExpectedBucketOwner: validBucketOwner, + Key: path, + }, + ); + }); + + it('headObject should not expose expectedBucketOwner when not provided', async () => { + const path = 'public/expectedbucketowner_test'; + + await getPropertiesWrapper({ + path, + options: {}, + }); + + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: path, + }, + ); + }); + + it('should throw error on invalid bucket owner id', async () => { + const path = 'public/expectedbucketowner_test'; + + await expect( + getPropertiesWrapper({ + path, + options: { + expectedBucketOwner: invalidBucketOwner, + }, + }), + ).rejects.toThrow('Invalid AWS account ID was provided.'); + + expect(headObject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts new file mode 100644 index 00000000000..03ade454d44 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/getUrl.test.ts @@ -0,0 +1,575 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { getUrl } from '../../../../../src/providers/s3/apis/internal/getUrl'; +import { + getPresignedGetObjectUrl, + headObject, +} from '../../../../../src/providers/s3/utils/client/s3data'; +import { + GetUrlInput, + GetUrlWithPathInput, +} from '../../../../../src/providers/s3/types'; +import './testUtils'; +import { BucketInfo } from '../../../../../src/providers/s3/types/options'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); + +const bucket = 'bucket'; +const region = 'region'; +const mockFetchAuthSession = jest.mocked(Amplify.Auth.fetchAuthSession); +const mockGetConfig = jest.mocked(Amplify.getConfig); +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const targetIdentityId = 'targetIdentityId'; +const defaultIdentityId = 'defaultIdentityId'; +const mockURL = new URL('https://google.com'); +const validBucketOwner = '111122223333'; +const invalidBucketOwner = '123'; + +describe('getUrl test with key', () => { + const getUrlWrapper = (input: GetUrlInput) => getUrl(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + describe('Happy cases: With key', () => { + const config = { + credentials, + region, + userAgentValue: expect.any(String), + }; + const key = 'key'; + beforeEach(() => { + jest.mocked(headObject).mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { meta: 'value' }, + $metadata: {} as any, + }); + jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + const testCases: { + options?: { accessLevel?: StorageAccessLevel; targetIdentityId?: string }; + expectedKey: string; + }[] = [ + { + expectedKey: `public/${key}`, + }, + { + options: { accessLevel: 'guest' }, + expectedKey: `public/${key}`, + }, + { + options: { accessLevel: 'private' }, + expectedKey: `private/${defaultIdentityId}/${key}`, + }, + { + options: { accessLevel: 'protected' }, + expectedKey: `protected/${defaultIdentityId}/${key}`, + }, + { + options: { accessLevel: 'protected', targetIdentityId }, + expectedKey: `protected/${targetIdentityId}/${key}`, + }, + ]; + + test.each(testCases)( + 'should getUrl with key $expectedKey', + async ({ options, expectedKey }) => { + const headObjectOptions = { + Bucket: bucket, + Key: expectedKey, + }; + const { url, expiresAt } = await getUrlWrapper({ + key, + options: { + ...options, + validateObjectExistence: true, + }, + }); + const expectedResult = { + url: mockURL, + expiresAt: expect.any(Date), + }; + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + 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(() => { + jest.clearAllMocks(); + }); + it('should return not found error when the object is not found', async () => { + (headObject as jest.Mock).mockImplementation(() => { + throw Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }); + }); + expect.assertions(2); + try { + await getUrlWrapper({ + key: 'invalid_key', + options: { validateObjectExistence: true }, + }); + } catch (error: any) { + expect(headObject).toHaveBeenCalledTimes(1); + expect(error.$metadata?.httpStatusCode).toBe(404); + } + }); + }); +}); + +describe('getUrl test with path', () => { + const getUrlWrapper = (input: GetUrlWithPathInput) => getUrl(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + describe('Happy cases: With path', () => { + const config = { + credentials, + region, + userAgentValue: expect.any(String), + }; + beforeEach(() => { + jest.mocked(headObject).mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { meta: 'value' }, + $metadata: {} as any, + }); + jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + { + path: 'path', + expectedKey: 'path', + }, + { + path: () => 'path', + expectedKey: 'path', + }, + ])( + 'should getUrl with path $path and expectedKey $expectedKey', + async ({ path, expectedKey }) => { + const headObjectOptions = { + Bucket: bucket, + Key: expectedKey, + }; + const { url, expiresAt } = await getUrlWrapper({ + path, + options: { + validateObjectExistence: true, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + expect({ url, expiresAt }).toEqual({ + url: mockURL, + expiresAt: expect.any(Date), + }); + }, + ); + + 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 = { + credentials, + region, + userAgentValue: expect.any(String), + }; + beforeEach(() => { + jest.mocked(headObject).mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { meta: 'value' }, + $metadata: {} as any, + }); + jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + { + path: 'path', + expectedKey: 'path', + contentDisposition: 'inline; filename="example.txt"', + contentType: 'text/plain', + }, + { + path: () => 'path', + expectedKey: 'path', + contentDisposition: { + type: 'attachment' as const, + filename: 'example.pdf', + }, + contentType: 'application/pdf', + }, + ])( + 'should getUrl with path $path and expectedKey $expectedKey and content disposition and content type', + async ({ path, expectedKey, contentDisposition, contentType }) => { + const headObjectOptions = { + Bucket: bucket, + Key: expectedKey, + }; + const { url, expiresAt } = await getUrlWrapper({ + path, + options: { + validateObjectExistence: true, + contentDisposition, + contentType, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + expect({ url, expiresAt }).toEqual({ + url: mockURL, + expiresAt: expect.any(Date), + }); + }, + ); + }); + describe('Error cases: With invalid Content Disposition', () => { + const config = { + credentials, + region, + userAgentValue: expect.any(String), + }; + beforeEach(() => { + jest.mocked(headObject).mockResolvedValue({ + ContentLength: 100, + ContentType: 'text/plain', + ETag: 'etag', + LastModified: new Date('01-01-1980'), + Metadata: { meta: 'value' }, + $metadata: {} as any, + }); + jest.mocked(getPresignedGetObjectUrl).mockResolvedValue(mockURL); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test.each([ + { + path: 'path', + expectedKey: 'path', + contentDisposition: { + type: 'invalid' as 'attachment' | 'inline', + filename: '"example.txt', + }, + }, + { + path: 'path', + expectedKey: 'path', + contentDisposition: { + type: 'invalid' as 'attachment' | 'inline', + }, + }, + ])( + 'should ignore for invalid content disposition: $contentDisposition', + async ({ path, expectedKey, contentDisposition }) => { + const headObjectOptions = { + Bucket: bucket, + Key: expectedKey, + }; + const { url, expiresAt } = await getUrlWrapper({ + path, + options: { + validateObjectExistence: true, + contentDisposition, + }, + }); + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + config, + headObjectOptions, + ); + expect({ url, expiresAt }).toEqual({ + url: mockURL, + expiresAt: expect.any(Date), + }); + }, + ); + }); + describe('Error cases : With path', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + it('should return not found error when the object is not found', async () => { + (headObject as jest.Mock).mockImplementation(() => { + throw Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }); + }); + expect.assertions(2); + try { + await getUrlWrapper({ + path: 'invalid_key', + options: { validateObjectExistence: true }, + }); + } catch (error: any) { + expect(headObject).toHaveBeenCalledTimes(1); + expect(error.$metadata?.httpStatusCode).toBe(404); + } + }); + }); +}); + +describe(`getURL with path and Expected Bucket Owner`, () => { + const getUrlWrapper = (input: GetUrlWithPathInput) => getUrl(Amplify, input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass expectedBucketOwner to getPresignedGetObjectUrl', async () => { + const path = 'public/expectedbucketowner_test'; + + await getUrlWrapper({ + path, + options: { + expiresIn: 300, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + ExpectedBucketOwner: validBucketOwner, + Key: path, + }, + ); + }); + + it('getPresignedGetObjectUrl should not expose expectedBucketOwner when not provided', async () => { + const path = 'public/expectedbucketowner_test'; + + await getUrlWrapper({ + path, + options: { + expiresIn: 300, + }, + }); + + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: path, + }, + ); + }); + + it('should throw error on invalid bucket owner id', async () => { + const path = 'public/expectedbucketowner_test'; + + await expect( + getUrlWrapper({ + path, + options: { + expectedBucketOwner: invalidBucketOwner, + }, + }), + ).rejects.toThrow('Invalid AWS account ID was provided.'); + + expect(getPresignedGetObjectUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/list.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/list.test.ts new file mode 100644 index 00000000000..e861652a90e --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/list.test.ts @@ -0,0 +1,1107 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { listObjectsV2 } from '../../../../../src/providers/s3/utils/client/s3data'; +import { list } from '../../../../../src/providers/s3/apis/internal/list'; +import { + ListAllInput, + ListAllWithPathInput, + ListAllWithPathOutput, + ListPaginateInput, + ListPaginateOutput, + ListPaginateWithPathInput, + ListPaginateWithPathOutput, +} from '../../../../../src/providers/s3/types'; +import './testUtils'; +import { ListObjectsV2CommandInput } from '../../../../../src/providers/s3/utils/client/s3data/types'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); +const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); +const mockListObject = listObjectsV2 as jest.Mock; +const inputKey = 'path/itemsKey'; +const bucket = 'bucket'; +const region = 'region'; +const nextToken = 'nextToken'; +const targetIdentityId = 'targetIdentityId'; +const defaultIdentityId = 'defaultIdentityId'; +const etagValue = 'eTag'; +const lastModifiedValue = 'lastModified'; +const sizeValue = 'size'; +const validBucketOwner = '111122223333'; +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const listObjectClientConfig = { + credentials, + region, + userAgentValue: expect.any(String), +}; +const listObjectClientBaseResultItem = { + ETag: etagValue, + LastModified: lastModifiedValue, + Size: sizeValue, +}; +const listResultItem = { + eTag: etagValue, + lastModified: lastModifiedValue, + size: sizeValue, +}; +const mockListObjectsV2ApiWithPages = (pages: number) => { + let methodCalls = 0; + mockListObject.mockClear(); + mockListObject.mockImplementation(async (_, input) => { + let token: string | undefined; + methodCalls++; + if (methodCalls > pages) { + fail(`listObjectsV2 calls are more than expected. Expected ${pages}`); + } + if (input.ContinuationToken === undefined || methodCalls < pages) { + token = nextToken; + } + + return { + ...mockListResponse(input), + Contents: [{ ...listObjectClientBaseResultItem, Key: input.Prefix }], + NextContinuationToken: token, + }; + }); +}; +const mockListResponse = (listParams: ListObjectsV2CommandInput) => ({ + Name: listParams.Bucket, + Delimiter: listParams.Delimiter, + MaxKeys: listParams.MaxKeys, + Prefix: listParams.Prefix, + ContinuationToken: listParams.ContinuationToken, +}); + +describe('list API', () => { + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + describe('Prefix: Happy Cases:', () => { + const listAllWrapper = (input: ListAllInput) => list(Amplify, input); + const listPaginatedWrapper = (input: ListPaginateInput) => + list(Amplify, input); + afterEach(() => { + jest.clearAllMocks(); + }); + + const accessLevelTests: { + prefix?: string; + expectedKey: string; + options?: { + accessLevel?: StorageAccessLevel; + targetIdentityId?: string; + }; + }[] = [ + { + expectedKey: `public/`, + }, + { + options: { accessLevel: 'guest' }, + expectedKey: `public/`, + }, + { + prefix: inputKey, + expectedKey: `public/${inputKey}`, + }, + { + prefix: inputKey, + options: { accessLevel: 'guest' }, + expectedKey: `public/${inputKey}`, + }, + { + prefix: inputKey, + options: { accessLevel: 'private' }, + expectedKey: `private/${defaultIdentityId}/${inputKey}`, + }, + { + prefix: inputKey, + options: { accessLevel: 'protected' }, + expectedKey: `protected/${defaultIdentityId}/${inputKey}`, + }, + { + prefix: inputKey, + options: { accessLevel: 'protected', targetIdentityId }, + expectedKey: `protected/${targetIdentityId}/${inputKey}`, + }, + ]; + + accessLevelTests.forEach(({ prefix, options, expectedKey }) => { + const pathMsg = prefix ? 'custom' : 'default'; + const accessLevelMsg = options?.accessLevel ?? 'default'; + const targetIdentityIdMsg = options?.targetIdentityId + ? `with targetIdentityId` + : ''; + it(`should list objects with pagination, default pageSize, ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [{ ...listObjectClientBaseResultItem, Key: expectedKey }], + NextContinuationToken: nextToken, + }; + }); + const response = (await listPaginatedWrapper({ + prefix, + options, + })) as ListPaginateOutput; + const { key, eTag, size, lastModified } = response.items[0]; + expect(response.items).toHaveLength(1); + expect({ key, eTag, size, lastModified }).toEqual({ + key: prefix ?? '', + ...listResultItem, + }); + expect(response.nextToken).toEqual(nextToken); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: expectedKey, + }), + ); + }); + }); + + accessLevelTests.forEach(({ prefix, options, expectedKey }) => { + const pathMsg = prefix ? 'custom' : 'default'; + const accessLevelMsg = options?.accessLevel ?? 'default'; + const targetIdentityIdMsg = options?.targetIdentityId + ? `with targetIdentityId` + : ''; + it(`should list objects with pagination using pageSize, nextToken, ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [{ ...listObjectClientBaseResultItem, Key: expectedKey }], + NextContinuationToken: nextToken, + }; + }); + const customPageSize = 5; + const response = (await listPaginatedWrapper({ + prefix, + options: { + ...options, + pageSize: customPageSize, + nextToken, + }, + })) as ListPaginateOutput; + const { key, eTag, size, lastModified } = response.items[0]; + expect(response.items).toHaveLength(1); + expect({ key, eTag, size, lastModified }).toEqual({ + key: prefix ?? '', + ...listResultItem, + }); + expect(response.nextToken).toEqual(nextToken); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: expectedKey, + ContinuationToken: nextToken, + MaxKeys: customPageSize, + }), + ); + }); + }); + + accessLevelTests.forEach(({ prefix, options, expectedKey }) => { + const pathMsg = prefix ? 'custom' : 'default'; + const accessLevelMsg = options?.accessLevel ?? 'default'; + const targetIdentityIdMsg = options?.targetIdentityId + ? `with targetIdentityId` + : ''; + it(`should list objects with zero results with ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + IsTruncated: false, + KeyCount: 0, + }; + }); + const response = (await listPaginatedWrapper({ + prefix, + options, + })) as ListPaginateOutput; + expect(response.items).toEqual([]); + + expect(response.nextToken).toEqual(undefined); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: expectedKey, + }), + ); + }); + }); + + accessLevelTests.forEach( + ({ prefix: inputPrefix, options, expectedKey }) => { + const pathMsg = inputPrefix ? 'custom' : 'default'; + const accessLevelMsg = options?.accessLevel ?? 'default'; + const targetIdentityIdMsg = options?.targetIdentityId + ? `with targetIdentityId` + : ''; + it(`should list all objects having three pages with ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { + mockListObjectsV2ApiWithPages(3); + const result = (await listAllWrapper({ + prefix: inputPrefix, + options: { ...options, listAll: true }, + })) as ListPaginateOutput; + const { key, eTag, lastModified, size } = result.items[0]; + expect(result.items).toHaveLength(3); + expect({ key, eTag, lastModified, size }).toEqual({ + ...listResultItem, + key: inputPrefix ?? '', + }); + expect(result).not.toHaveProperty(nextToken); + + // listing three times for three pages + expect(listObjectsV2).toHaveBeenCalledTimes(3); + + // first input receives undefined as the Continuation Token + await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: expectedKey, + MaxKeys: 1000, + ContinuationToken: undefined, + }), + ); + // last input receives TEST_TOKEN as the Continuation Token + await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 3, + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: expectedKey, + MaxKeys: 1000, + ContinuationToken: nextToken, + }), + ); + }); + }, + ); + + describe('bucket passed in options', () => { + it('should override bucket in listObject call when bucket is object', async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: listParams.Prefix + 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), + }, + expect.objectContaining({ + Bucket: mockBucketName, + MaxKeys: 1000, + Prefix: `public/${inputKey}`, + }), + ); + }); + + it('should override bucket in listObject call when bucket is string', async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: listParams.Prefix + 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), + }, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: `public/${inputKey}`, + }), + ); + }); + }); + }); + + describe('Path: Happy Cases:', () => { + const listAllWrapper = (input: ListAllWithPathInput) => + list(Amplify, input); + const listPaginatedWrapper = (input: ListPaginateWithPathInput) => + list(Amplify, input); + const resolvePath = ( + path: string | (({ identityId }: { identityId: string }) => string), + ) => + typeof path === 'string' ? path : path({ identityId: defaultIdentityId }); + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + const pathTestCases = [ + { + path: `public/${inputKey}`, + }, + { + path: ({ identityId }: { identityId: string }) => + `protected/${identityId}/${inputKey}`, + }, + ]; + + it.each(pathTestCases)( + 'should list objects with pagination, default pageSize, custom path', + async ({ path: inputPath }) => { + const resolvedPath = resolvePath(inputPath); + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: resolvePath(inputPath), + }, + ], + NextContinuationToken: nextToken, + }; + }); + const response = (await listPaginatedWrapper({ + path: resolvedPath, + })) as ListPaginateWithPathOutput; + const { path, eTag, lastModified, size } = response.items[0]; + expect(response.items).toHaveLength(1); + expect({ path, eTag, lastModified, size }).toEqual({ + ...listResultItem, + path: resolvedPath, + }); + expect(response.nextToken).toEqual(nextToken); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: resolvePath(inputPath), + }), + ); + }, + ); + + it.each(pathTestCases)( + 'should list objects with pagination using custom pageSize, nextToken and custom path: $path', + async ({ path: inputPath }) => { + const resolvedPath = resolvePath(inputPath); + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + Contents: [ + { + ...listObjectClientBaseResultItem, + Key: resolvePath(inputPath), + }, + ], + NextContinuationToken: nextToken, + }; + }); + const customPageSize = 5; + const response = (await listPaginatedWrapper({ + path: resolvedPath, + options: { + pageSize: customPageSize, + nextToken, + }, + })) as ListPaginateWithPathOutput; + const { path, eTag, lastModified, size } = response.items[0]; + expect(response.items).toHaveLength(1); + expect({ path, eTag, lastModified, size }).toEqual({ + ...listResultItem, + path: resolvedPath, + }); + expect(response.nextToken).toEqual(nextToken); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: resolvePath(inputPath), + ContinuationToken: nextToken, + MaxKeys: customPageSize, + }), + ); + }, + ); + + it.each(pathTestCases)( + 'should list objects with zero results with custom path: $path', + async ({ path }) => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + IsTruncated: false, + KeyCount: 0, + }; + }); + const response = (await listPaginatedWrapper({ + path: resolvePath(path), + })) as ListPaginateWithPathOutput; + expect(response.items).toEqual([]); + + expect(response.nextToken).toEqual(undefined); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: resolvePath(path), + }), + ); + }, + ); + + it.each(pathTestCases)( + 'should list all objects having three pages with custom path: $path', + async ({ path: inputPath }) => { + const resolvedPath = resolvePath(inputPath); + mockListObjectsV2ApiWithPages(3); + const result = (await listAllWrapper({ + path: resolvedPath, + options: { listAll: true }, + })) as ListPaginateWithPathOutput; + + const listResult = { + path: resolvedPath, + ...listResultItem, + }; + const { path, lastModified, eTag, size } = result.items[0]; + expect(result.items).toHaveLength(3); + expect({ path, lastModified, eTag, size }).toEqual(listResult); + expect(result.items).toEqual([listResult, listResult, listResult]); + expect(result).not.toHaveProperty(nextToken); + + // listing three times for three pages + expect(listObjectsV2).toHaveBeenCalledTimes(3); + + // first input receives undefined as the Continuation Token + await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: resolvedPath, + MaxKeys: 1000, + ContinuationToken: undefined, + }), + ); + // last input receives TEST_TOKEN as the Continuation Token + await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 3, + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + Prefix: resolvedPath, + MaxKeys: 1000, + ContinuationToken: nextToken, + }), + ); + }, + ); + + describe('bucket passed in options', () => { + it('should override bucket in listObject call when bucket is object', async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + 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), + }, + expect.objectContaining({ + Bucket: mockBucketName, + MaxKeys: 1000, + Prefix: 'path/', + }), + ); + }); + + it('should override bucket in listObject call when bucket is string', async () => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + 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), + }, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: 'path/', + }), + ); + }); + }); + }); + + describe('Error Cases:', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return a not found error', async () => { + mockListObject.mockRejectedValueOnce( + Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }), + ); + try { + await list(Amplify, {}); + } catch (error: any) { + expect.assertions(3); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: 'public/', + }), + ); + expect(error.$metadata.httpStatusCode).toBe(404); + } + }); + + describe.each([ + { + type: 'Prefix', + mockListFunction: () => list(Amplify, { prefix: 'test/' }), + }, + { + type: 'Path', + mockListFunction: () => list(Amplify, { path: 'test/' }), + }, + ])('$type response validation check', ({ mockListFunction }) => { + it.each([ + { + name: 'missing Delimiter echo', + override: { Delimiter: 'mock-invalid-value' }, + }, + { + name: 'missing MaxKeys echo', + override: { MaxKeys: 'mock-invalid-value' }, + }, + { + name: 'missing Prefix echo', + override: { Prefix: 'mock-invalid-value' }, + }, + { + name: 'missing ContinuationToken echo', + override: { ContinuationToken: 'mock-invalid-value' }, + }, + ])('should throw with $name', async ({ override }) => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + ...override, + }; + }); + + await expect(mockListFunction()).rejects.toThrow( + 'An unknown error has occurred.', + ); + }); + }); + }); + + describe('with delimiter', () => { + const mockedContents = [ + { + Key: 'photos/', + ...listObjectClientBaseResultItem, + }, + { + Key: 'photos/2023.png', + ...listObjectClientBaseResultItem, + }, + { + Key: 'photos/2024.png', + ...listObjectClientBaseResultItem, + }, + ]; + const mockedCommonPrefixes = [ + { Prefix: 'photos/2023/' }, + { Prefix: 'photos/2024/' }, + { Prefix: 'photos/2025/' }, + ]; + + const expectedExcludedSubpaths = mockedCommonPrefixes.map( + ({ Prefix }) => Prefix, + ); + + const mockedPath = 'photos/'; + + beforeEach(() => { + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + CommonPrefixes: mockedCommonPrefixes, + Contents: mockedContents, + NextContinuationToken: nextToken, + KeyCount: 3, + }; + }); + }); + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + + it('should return excludedSubpaths when "exclude" strategy is passed in the request', async () => { + const { items, excludedSubpaths } = (await list(Amplify, { + path: mockedPath, + options: { + subpathStrategy: { strategy: 'exclude' }, + }, + })) as ListPaginateWithPathOutput; + + expect(items).toHaveLength(3); + expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: mockedPath, + Delimiter: '/', + }), + ); + }); + + it('should return excludedSubpaths when "exclude" strategy and listAll are passed in the request', async () => { + mockListObject.mockReset(); + mockListObject.mockImplementationOnce((_, listParams) => { + return { + ...mockListResponse(listParams), + CommonPrefixes: mockedCommonPrefixes, + Contents: mockedContents, + KeyCount: 3, + NextContinuationToken: undefined, + IsTruncated: false, + }; + }); + + const { items, excludedSubpaths } = (await list(Amplify, { + path: mockedPath, + options: { + subpathStrategy: { strategy: 'exclude' }, + listAll: true, + }, + })) as ListAllWithPathOutput; + expect(items).toHaveLength(3); + expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: mockedPath, + Delimiter: '/', + }), + ); + }); + + it('should return excludedSubpaths when "exclude" strategy and pageSize are passed in the request', async () => { + const { items, excludedSubpaths } = (await list(Amplify, { + path: mockedPath, + options: { + subpathStrategy: { strategy: 'exclude' }, + pageSize: 3, + }, + })) as ListPaginateWithPathOutput; + expect(items).toHaveLength(3); + expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 3, + Prefix: mockedPath, + Delimiter: '/', + }), + ); + }); + + it('should listObjectsV2 contain a custom Delimiter when "exclude" with delimiter is passed', async () => { + (await list(Amplify, { + path: mockedPath, + options: { + subpathStrategy: { + strategy: 'exclude', + delimiter: '-', + }, + }, + })) as ListPaginateWithPathOutput; + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: mockedPath, + Delimiter: '-', + }), + ); + }); + + it('should listObjectsV2 contain an undefined Delimiter when "include" strategy is passed', async () => { + await list(Amplify, { + path: mockedPath, + options: { + subpathStrategy: { + strategy: 'include', + }, + }, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: mockedPath, + Delimiter: undefined, + }), + ); + }); + + it('should listObjectsV2 contain an undefined Delimiter when no options are passed', async () => { + await list(Amplify, { + path: mockedPath, + }); + expect(listObjectsV2).toHaveBeenCalledTimes(1); + await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + listObjectClientConfig, + expect.objectContaining({ + Bucket: bucket, + MaxKeys: 1000, + Prefix: mockedPath, + Delimiter: undefined, + }), + ); + }); + }); + + describe(`List with path and Expected Bucket Owner`, () => { + describe(`v1`, () => { + const listAllWrapper = (input: ListAllInput) => list(Amplify, input); + const listPaginatedWrapper = (input: ListPaginateInput) => + list(Amplify, input); + const resolvePath = ( + path: string | (({ identityId }: { identityId: string }) => string), + ) => + typeof path === 'string' + ? path + : path({ identityId: defaultIdentityId }); + const mockPrefix = 'test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + it('should include expectedBucketOwner in headers with listAll call when provided', async () => { + const resolvedPath = resolvePath(mockPrefix); + mockListObjectsV2ApiWithPages(3); + await listAllWrapper({ + prefix: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: true, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + it('should include expectedBucketOwner in headers with paginated call when provided', async () => { + const resolvedPath = resolvePath(mockPrefix); + mockListObjectsV2ApiWithPages(3); + const customPageSize = 5; + await listPaginatedWrapper({ + prefix: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: false, + pageSize: customPageSize, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + + describe(`v2`, () => { + const listAllWrapper = (input: ListAllWithPathInput) => + list(Amplify, input); + const listPaginatedWrapper = (input: ListPaginateWithPathInput) => + list(Amplify, input); + const resolvePath = ( + path: string | (({ identityId }: { identityId: string }) => string), + ) => + typeof path === 'string' + ? path + : path({ identityId: defaultIdentityId }); + const mockPath = 'public/test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + it('should include expectedBucketOwner in headers with listAll call when provided', async () => { + const resolvedPath = resolvePath(mockPath); + mockListObjectsV2ApiWithPages(3); + await listAllWrapper({ + path: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: true, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + Bucket: mockBucket, + MaxKeys: 1000, + Prefix: mockPath, + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + it('should include expectedBucketOwner in headers with paginated call when provided', async () => { + const resolvedPath = resolvePath(mockPath); + mockListObjectsV2ApiWithPages(3); + const customPageSize = 5; + await listPaginatedWrapper({ + path: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: false, + pageSize: customPageSize, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + Bucket: mockBucket, + Prefix: mockPath, + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + }); + + describe.each([ + { + type: 'Prefix', + listFunction: (options?: any) => + list(Amplify, { + prefix: 'some folder with unprintable unicode/', + options, + }), + key: 'key', + }, + { + type: 'Path', + listFunction: (options?: any) => + list(Amplify, { + path: 'public/some folder with unprintable unicode/', + options, + }), + key: 'path', + }, + ])('Encoding for List with $type', ({ listFunction, key }) => { + afterEach(() => { + mockListObject.mockClear(); + }); + it('should decode encoded list output', async () => { + const encodedBadKeys = [ + 'some+folder+with+spaces/', + 'real%0A%0A%0A%0A%0A%0A%0A%0A%0Afunny%0A%0A%0A%0A%0A%0A%0A%0A%0Abiz', + 'some+folder+with+%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86+multibyte+unicode/', + 'bad%3Cdiv%3Ekey', + 'bad%00key', + 'bad%01key', + ]; + + mockListObject.mockReturnValueOnce({ + Name: bucket, + Prefix: 'public/some+folder+with++unprintable+unicode/', + Delimiter: 'bad%08key', + MaxKeys: 1000, + StartAfter: 'bad%7Fbiz/', + EncodingType: 'url', + Contents: encodedBadKeys.map(badKey => ({ + ...listObjectClientBaseResultItem, + Key: key === 'key' ? `public/${badKey}` : badKey, + })), + }); + + const result = await listFunction({ + subpathStrategy: { strategy: 'exclude', delimiter: 'bad\x08key' }, + }); + + expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + Bucket: bucket, + EncodingType: 'url', + }), + ); + + const decodedKeys = [ + 'some folder with spaces/', + 'real\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0afunny\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0a\x0abiz', + 'some folder with おはよう multibyte unicode/', + 'bad
      key', + 'bad\x00key', + 'bad\x01key', + ]; + + const expectedResult = { + items: decodedKeys.map(decodedKey => ({ + [key]: decodedKey, + eTag: 'eTag', + lastModified: 'lastModified', + size: 'size', + })), + nextToken: undefined, + }; + expect(result).toEqual(expectedResult); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/internal/remove.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/remove.test.ts new file mode 100644 index 00000000000..6db2fcb997a --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/internal/remove.test.ts @@ -0,0 +1,337 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; +import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; + +import { deleteObject } from '../../../../../src/providers/s3/utils/client/s3data'; +import { remove } from '../../../../../src/providers/s3/apis/internal/remove'; +import { StorageValidationErrorCode } from '../../../../../src/errors/types/validation'; +import { + RemoveInput, + RemoveOutput, + RemoveWithPathInput, + RemoveWithPathOutput, +} from '../../../../../src/providers/s3/types'; +import './testUtils'; + +jest.mock('../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('@aws-amplify/core', () => ({ + ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { + return { debug: jest.fn() }; + }), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(), + }, + }, +})); +const mockDeleteObject = deleteObject as jest.Mock; +const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; +const mockGetConfig = jest.mocked(Amplify.getConfig); +const inputKey = 'key'; +const bucket = 'bucket'; +const region = 'region'; +const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const credentials: AWSCredentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; +const deleteObjectClientConfig = { + credentials, + region, + userAgentValue: expect.any(String), +}; + +describe('remove API', () => { + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + describe('Happy Cases', () => { + describe('With Key', () => { + const removeWrapper = (input: RemoveInput) => remove(Amplify, input); + + beforeEach(() => { + mockDeleteObject.mockImplementation(() => { + return { + Metadata: { key: 'value' }, + }; + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + const testCases: { + expectedKey: string; + options?: { accessLevel?: StorageAccessLevel }; + }[] = [ + { + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'guest' }, + expectedKey: `public/${inputKey}`, + }, + { + options: { accessLevel: 'private' }, + expectedKey: `private/${defaultIdentityId}/${inputKey}`, + }, + { + options: { accessLevel: 'protected' }, + expectedKey: `protected/${defaultIdentityId}/${inputKey}`, + }, + ]; + + testCases.forEach(({ options, expectedKey }) => { + const accessLevel = options?.accessLevel ?? 'default'; + + it(`should remove object with ${accessLevel} accessLevel`, async () => { + const { key } = (await removeWrapper({ + key: inputKey, + options, + })) as RemoveOutput; + expect(key).toEqual(inputKey); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + deleteObjectClientConfig, + { + Bucket: bucket, + Key: expectedKey, + }, + ); + }); + }); + + 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('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockKey = 'test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + key: mockKey, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + expectedBucketOwner: validBucketOwner, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + expect(deleteObject).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + }); + describe('With Path', () => { + const removeWrapper = (input: RemoveWithPathInput) => + remove(Amplify, input); + beforeEach(() => { + mockDeleteObject.mockImplementation(() => { + return { + Metadata: { key: 'value' }, + }; + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + [ + { + path: `public/${inputKey}`, + }, + { + path: ({ identityId }: { identityId?: string }) => + `protected/${identityId}/${inputKey}`, + }, + ].forEach(({ path: inputPath }) => { + const resolvedPath = + typeof inputPath === 'string' + ? inputPath + : inputPath({ identityId: defaultIdentityId }); + + it(`should remove object for the given path`, async () => { + const { path } = (await removeWrapper({ + path: inputPath, + })) as RemoveWithPathOutput; + expect(path).toEqual(resolvedPath); + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + deleteObjectClientConfig, + { + Bucket: bucket, + Key: resolvedPath, + }, + ); + }); + }); + + 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/', + }, + ); + }); + }); + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockPath = 'public/test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + path: mockPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + expectedBucketOwner: validBucketOwner, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + expect(deleteObject).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + }); + }); + + describe('Error Cases:', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return a not found error', async () => { + mockDeleteObject.mockRejectedValueOnce( + Object.assign(new Error(), { + $metadata: { httpStatusCode: 404 }, + name: 'NotFound', + }), + ); + expect.assertions(3); + const key = 'wrongKey'; + try { + await remove(Amplify, { key }); + } catch (error: any) { + expect(deleteObject).toHaveBeenCalledTimes(1); + await expect(deleteObject).toBeLastCalledWithConfigAndInput( + deleteObjectClientConfig, + { + Bucket: bucket, + Key: `public/${key}`, + }, + ); + expect(error.$metadata.httpStatusCode).toBe(404); + } + }); + it('should throw InvalidStorageOperationInput error when the path is empty', async () => { + expect.assertions(1); + try { + await remove(Amplify, { path: '' }); + } catch (error: any) { + expect(error.name).toBe( + StorageValidationErrorCode.InvalidStorageOperationInput, + ); + } + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/testUtils.ts b/packages/storage/__tests__/providers/s3/apis/internal/testUtils.ts similarity index 100% rename from packages/storage/__tests__/providers/s3/apis/testUtils.ts rename to packages/storage/__tests__/providers/s3/apis/internal/testUtils.ts diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/byteLength.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/byteLength.test.ts similarity index 91% rename from packages/storage/__tests__/providers/s3/apis/uploadData/byteLength.test.ts rename to packages/storage/__tests__/providers/s3/apis/internal/uploadData/byteLength.test.ts index 24b46ac4f0d..eaffab3fcb4 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/byteLength.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/byteLength.test.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { byteLength } from '../../../../../src/providers/s3/apis/uploadData/byteLength'; +import { byteLength } from '../../../../../../src/providers/s3/apis/internal/uploadData/byteLength'; describe('byteLength', () => { it('returns 0 for null or undefined', () => { diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/index.test.ts similarity index 64% rename from packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts rename to packages/storage/__tests__/providers/s3/apis/internal/uploadData/index.test.ts index ad1ce8d4009..9b2c94d1252 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/index.test.ts @@ -1,22 +1,30 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { uploadData } from '../../../../../src/providers/s3/apis'; -import { MAX_OBJECT_SIZE } from '../../../../../src/providers/s3/utils/constants'; -import { createUploadTask } from '../../../../../src/providers/s3/utils'; +import { uploadData } from '../../../../../../src/providers/s3/apis/internal/uploadData'; +import { MAX_OBJECT_SIZE } from '../../../../../../src/providers/s3/utils/constants'; +import { createUploadTask } from '../../../../../../src/providers/s3/utils'; import { StorageValidationErrorCode, validationErrorMap, -} from '../../../../../src/errors/types/validation'; -import { putObjectJob } from '../../../../../src/providers/s3/apis/uploadData/putObjectJob'; -import { getMultipartUploadHandlers } from '../../../../../src/providers/s3/apis/uploadData/multipart'; -import { UploadDataInput, UploadDataWithPathInput } from '../../../../../src'; +} from '../../../../../../src/errors/types/validation'; +import { putObjectJob } from '../../../../../../src/providers/s3/apis/internal/uploadData/putObjectJob'; +import { getMultipartUploadHandlers } from '../../../../../../src/providers/s3/apis/internal/uploadData/multipart'; +import { + UploadDataInput, + UploadDataWithPathInput, +} from '../../../../../../src'; -jest.mock('../../../../../src/providers/s3/utils/'); -jest.mock('../../../../../src/providers/s3/apis/uploadData/putObjectJob'); -jest.mock('../../../../../src/providers/s3/apis/uploadData/multipart'); +jest.mock('../../../../../../src/providers/s3/utils/'); +jest.mock( + '../../../../../../src/providers/s3/apis/internal/uploadData/putObjectJob', +); +jest.mock( + '../../../../../../src/providers/s3/apis/internal/uploadData/multipart', +); const testPath = 'testPath/object'; +const validBucketOwner = '111122223333'; const mockCreateUploadTask = createUploadTask as jest.Mock; const mockPutObjectJob = putObjectJob as jest.Mock; const mockGetMultipartUploadHandlers = ( @@ -47,12 +55,17 @@ describe('uploadData with key', () => { ); }); - it('should NOT throw if data size is unknown', async () => { - uploadData({ - key: 'key', - data: {} as any, - }); - expect(mockCreateUploadTask).toHaveBeenCalled(); + it('should throw if data size is unknown', async () => { + expect(() => + uploadData({ + key: 'key', + data: {} as any, + }), + ).toThrow( + expect.objectContaining( + validationErrorMap[StorageValidationErrorCode.InvalidUploadSource], + ), + ); }); }); @@ -67,6 +80,22 @@ describe('uploadData with key', () => { expect(mockGetMultipartUploadHandlers).not.toHaveBeenCalled(); }); + it('should use putObject for 0 bytes data (e.g. create a folder)', () => { + const testInput = { + key: 'key', + data: '', // 0 bytes + }; + + uploadData(testInput); + + expect(mockPutObjectJob).toHaveBeenCalledWith( + expect.objectContaining(testInput), + expect.any(AbortSignal), + expect.any(Number), + ); + expect(mockGetMultipartUploadHandlers).not.toHaveBeenCalled(); + }); + it('should use uploadTask', async () => { mockPutObjectJob.mockReturnValueOnce('putObjectJob'); mockCreateUploadTask.mockReturnValueOnce('uploadTask'); @@ -142,12 +171,17 @@ describe('uploadData with path', () => { ); }); - it('should NOT throw if data size is unknown', async () => { - uploadData({ - path: testPath, - data: {} as any, - }); - expect(mockCreateUploadTask).toHaveBeenCalled(); + it('should throw if data size is unknown', async () => { + expect(() => + uploadData({ + path: testPath, + data: {} as any, + }), + ).toThrow( + expect.objectContaining( + validationErrorMap[StorageValidationErrorCode.InvalidUploadSource], + ), + ); }); }); @@ -172,7 +206,7 @@ describe('uploadData with path', () => { uploadData(testInput); expect(mockPutObjectJob).toHaveBeenCalledWith( - testInput, + expect.objectContaining(testInput), expect.any(AbortSignal), expect.any(Number), ); @@ -189,7 +223,7 @@ describe('uploadData with path', () => { uploadData(testInput); expect(mockPutObjectJob).toHaveBeenCalledWith( - testInput, + expect.objectContaining(testInput), expect.any(AbortSignal), expect.any(Number), ); @@ -228,7 +262,7 @@ describe('uploadData with path', () => { expect(mockPutObjectJob).not.toHaveBeenCalled(); expect(mockGetMultipartUploadHandlers).toHaveBeenCalledWith( - testInput, + expect.objectContaining(testInput), expect.any(Number), ); }); @@ -251,4 +285,51 @@ describe('uploadData with path', () => { ); }); }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided for singlepartUpload', async () => { + mockPutObjectJob.mockReturnValueOnce('putObjectJob'); + const smallData = 'smallData'; + uploadData({ + path: testPath, + data: smallData, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + expect(mockPutObjectJob).toHaveBeenCalledWith( + expect.objectContaining({ + path: 'testPath/object', + data: 'smallData', + options: expect.objectContaining({ + expectedBucketOwner: '111122223333', + }), + }), + expect.any(Object), + expect.any(Number), + ); + + expect(mockGetMultipartUploadHandlers).not.toHaveBeenCalled(); + }); + it('should include expectedBucketOwner in headers when provided for multipartUpload', async () => { + const biggerData = { size: 5 * 1024 * 1024 + 1 } as any; + const testInput = { + path: testPath, + data: biggerData, + options: { + expectedBucketOwner: validBucketOwner, + }, + }; + uploadData(testInput); + expect(mockGetMultipartUploadHandlers).toHaveBeenCalledWith( + { + ...testInput, + options: expect.objectContaining(testInput.options), + }, + expect.any(Number), + ); + + expect(mockPutObjectJob).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/multipartHandlers.test.ts similarity index 69% rename from packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts rename to packages/storage/__tests__/providers/s3/apis/internal/uploadData/multipartHandlers.test.ts index 8957c9ef764..9bf25707adb 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/multipartHandlers.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/multipartHandlers.test.ts @@ -11,20 +11,27 @@ import { headObject, listParts, uploadPart, -} from '../../../../../src/providers/s3/utils/client'; -import { getMultipartUploadHandlers } from '../../../../../src/providers/s3/apis/uploadData/multipart'; +} from '../../../../../../src/providers/s3/utils/client/s3data'; +import { getMultipartUploadHandlers } from '../../../../../../src/providers/s3/apis/internal/uploadData/multipart'; import { StorageValidationErrorCode, validationErrorMap, -} from '../../../../../src/errors/types/validation'; -import { UPLOADS_STORAGE_KEY } from '../../../../../src/providers/s3/utils/constants'; -import { byteLength } from '../../../../../src/providers/s3/apis/uploadData/byteLength'; -import { CanceledError } from '../../../../../src/errors/CanceledError'; -import { StorageOptions } from '../../../../../src/types'; +} from '../../../../../../src/errors/types/validation'; +import { + CHECKSUM_ALGORITHM_CRC32, + UPLOADS_STORAGE_KEY, +} from '../../../../../../src/providers/s3/utils/constants'; +import { CanceledError } from '../../../../../../src/errors/CanceledError'; +import { StorageOptions } from '../../../../../../src/types'; +import { calculateContentCRC32 } from '../../../../../../src/providers/s3/utils/crc32'; +import { calculateContentMd5 } from '../../../../../../src/providers/s3/utils'; +import { byteLength } from '../../../../../../src/providers/s3/apis/internal/uploadData/byteLength'; + import '../testUtils'; jest.mock('@aws-amplify/core'); -jest.mock('../../../../../src/providers/s3/utils/client'); +jest.mock('../../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('../../../../../../src/providers/s3/utils/crc32'); const credentials: AWSCredentials = { accessKeyId: 'accessKeyId', @@ -37,9 +44,10 @@ const bucket = 'bucket'; const region = 'region'; const defaultKey = 'key'; const defaultContentType = 'application/octet-stream'; -const defaultCacheKey = '8388608_application/octet-stream_bucket_public_key'; +const defaultCacheKey = + 'Jz3O2w==_8388608_application/octet-stream_bucket_public_key'; const testPath = 'testPath/object'; -const testPathCacheKey = `8388608_${defaultContentType}_${bucket}_custom_${testPath}`; +const testPathCacheKey = `Jz3O2w==_8388608_${defaultContentType}_${bucket}_custom_${testPath}`; const mockCreateMultipartUpload = jest.mocked(createMultipartUpload); const mockUploadPart = jest.mocked(uploadPart); @@ -47,11 +55,40 @@ const mockCompleteMultipartUpload = jest.mocked(completeMultipartUpload); const mockAbortMultipartUpload = jest.mocked(abortMultipartUpload); const mockListParts = jest.mocked(listParts); const mockHeadObject = jest.mocked(headObject); +const mockCalculateContentCRC32 = jest.mocked(calculateContentCRC32); const disableAssertionFlag = true; const MB = 1024 * 1024; +jest.mock('../../../../../../src/providers/s3/utils', () => ({ + ...jest.requireActual('../../../../../../src/providers/s3/utils'), + calculateContentMd5: jest.fn(), +})); + +const getZeroDelayTimeout = () => + new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 0); + }); + +const mockCalculateContentCRC32Mock = () => { + mockCalculateContentCRC32.mockReset(); + mockCalculateContentCRC32.mockResolvedValue({ + checksumArrayBuffer: new ArrayBuffer(0), + checksum: 'mockChecksum', + seed: 0, + }); +}; +const mockCalculateContentCRC32Reset = () => { + mockCalculateContentCRC32.mockReset(); + mockCalculateContentCRC32.mockImplementation( + jest.requireActual('../../../../../../src/providers/s3/utils/crc32') + .calculateContentCRC32, + ); +}; + const mockMultipartUploadSuccess = (disableAssertion?: boolean) => { let totalSize = 0; mockCreateMultipartUpload.mockResolvedValueOnce({ @@ -75,7 +112,7 @@ const mockMultipartUploadSuccess = (disableAssertion?: boolean) => { totalBytes: body.byteLength, }); - totalSize += byteLength(input.Body)!; + totalSize += byteLength(input.Body!)!; return { Etag: `etag-${input.PartNumber}`, @@ -149,9 +186,10 @@ describe('getMultipartUploadHandlers with key', () => { }); }); - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); resetS3Mocks(); + mockCalculateContentCRC32Reset(); }); it('should return multipart upload handlers', async () => { @@ -200,11 +238,14 @@ describe('getMultipartUploadHandlers with key', () => { `should upload a %s type body that splits in 2 parts using ${accessLevelMsg} accessLevel`, async (_, twoPartsPayload) => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: defaultKey, - data: twoPartsPayload, - options: options as StorageOptions, - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: twoPartsPayload, + options: options as StorageOptions, + }, + byteLength(twoPartsPayload)!, + ); const result = await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -230,12 +271,88 @@ describe('getMultipartUploadHandlers with key', () => { ); }); + it.each([ + [ + 'file', + new File([getBlob(8 * MB)], 'someName'), + ['JCnBsQ==', 'HELzGQ=='], + ], + ['blob', getBlob(8 * MB), ['JCnBsQ==', 'HELzGQ==']], + ['string', 'Ü'.repeat(4 * MB), ['DL735w==', 'Akga7g==']], + ['arrayBuffer', new ArrayBuffer(8 * MB), ['yTuzdQ==', 'eXJPxg==']], + ['arrayBufferView', new Uint8Array(8 * MB), ['yTuzdQ==', 'eXJPxg==']], + ])( + `should create crc32 for %s type body`, + async (_, twoPartsPayload, expectedCrc32) => { + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: twoPartsPayload, + options: { + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, + }, + byteLength(twoPartsPayload)!, + ); + await multipartUploadJob(); + + /** + * final crc32 calculation calls calculateContentCRC32 3 times + * 1 time for each of the 2 parts + * 1 time to combine the resulting hash for each of the two parts + * + * uploading each part calls calculateContentCRC32 1 time each + * + * 1 time for optionsHash + * + * these steps results in 6 calls in total + */ + expect(calculateContentCRC32).toHaveBeenCalledTimes(6); + expect(calculateContentMd5).not.toHaveBeenCalled(); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ChecksumCRC32: expectedCrc32[0] }), + ); + expect(mockUploadPart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ChecksumCRC32: expectedCrc32[1] }), + ); + }, + ); + + it('should use md5 if no using crc32', async () => { + mockMultipartUploadSuccess(); + Amplify.libraryOptions = { + Storage: { + S3: { + isObjectLockEnabled: true, + }, + }, + }; + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new Uint8Array(8 * MB), + }, + 8 * MB, + ); + await multipartUploadJob(); + expect(calculateContentCRC32).toHaveBeenCalledTimes(1); // (final crc32 calculation = 1 undefined) + expect(calculateContentMd5).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + }); + it('should throw if unsupported payload type is provided', async () => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: defaultKey, - data: 1 as any, - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: 1 as any, + }, + 1, + ); await expect(multipartUploadJob()).rejects.toThrow( expect.objectContaining( validationErrorMap[StorageValidationErrorCode.InvalidUploadSource], @@ -244,6 +361,7 @@ describe('getMultipartUploadHandlers with key', () => { }); it('should upload a body that exceeds the size of default part size and parts count', async () => { + mockCalculateContentCRC32Mock(); let buffer: ArrayBuffer; const file = { __proto__: File.prototype, @@ -264,11 +382,14 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: file, + options: { + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, }, file.size, ); await multipartUploadJob(); - expect(file.slice).toHaveBeenCalledTimes(10_000); // S3 limit of parts count + expect(file.slice).toHaveBeenCalledTimes(10_000 * 2); // S3 limit of parts count double for crc32 calculations expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); expect(mockUploadPart).toHaveBeenCalledTimes(10_000); expect(mockCompleteMultipartUpload).toHaveBeenCalledTimes(1); @@ -309,10 +430,13 @@ describe('getMultipartUploadHandlers with key', () => { mockCreateMultipartUpload.mockReset(); mockCreateMultipartUpload.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: defaultKey, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); }); @@ -322,10 +446,13 @@ describe('getMultipartUploadHandlers with key', () => { mockCompleteMultipartUpload.mockReset(); mockCompleteMultipartUpload.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: defaultKey, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); }); @@ -340,10 +467,13 @@ describe('getMultipartUploadHandlers with key', () => { }); mockUploadPart.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: defaultKey, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); expect(mockUploadPart).toHaveBeenCalledTimes(2); expect(mockCompleteMultipartUpload).not.toHaveBeenCalled(); @@ -355,13 +485,16 @@ describe('getMultipartUploadHandlers with key', () => { const mockBucket = 'bucket-1'; const mockRegion = 'region-1'; mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: 'key', - data: mockData, - options: { - bucket: { bucketName: mockBucket, region: mockRegion }, + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: 'key', + data: mockData, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + }, }, - }); + byteLength(mockData)!, + ); await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -381,13 +514,16 @@ describe('getMultipartUploadHandlers with key', () => { it('should override bucket in putObject call when bucket as string', async () => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - key: 'key', - data: mockData, - options: { - bucket: 'default-bucket', + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: 'key', + data: mockData, + options: { + bucket: 'default-bucket', + }, }, - }); + byteLength(mockData)!, + ); await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -405,6 +541,56 @@ describe('getMultipartUploadHandlers with key', () => { ); }); }); + + describe('cache validation', () => { + it.each([ + { + name: 'wrong part count', + parts: [{ PartNumber: 1 }, { PartNumber: 2 }, { PartNumber: 3 }], + }, + { + name: 'wrong part numbers', + parts: [{ PartNumber: 1 }, { PartNumber: 1 }], + }, + ])('should throw with $name', async ({ parts }) => { + mockMultipartUploadSuccess(); + + const mockDefaultStorage = defaultStorage as jest.Mocked< + typeof defaultStorage + >; + mockDefaultStorage.getItem.mockResolvedValue( + JSON.stringify({ + [defaultCacheKey]: { + uploadId: 'uploadId', + bucket, + key: defaultKey, + finalCrc32: 'mock-crc32', + }, + }), + ); + mockListParts.mockResolvedValue({ + Parts: parts, + $metadata: {}, + }); + + const onProgress = jest.fn(); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + options: { + onProgress, + resumableUploadsCache: mockDefaultStorage, + }, + }, + 8 * MB, + ); + await expect(multipartUploadJob()).rejects.toThrow({ + name: 'Unknown', + message: 'An unknown error has occurred.', + }); + }); + }); }); describe('upload caching', () => { @@ -416,6 +602,23 @@ describe('getMultipartUploadHandlers with key', () => { mockDefaultStorage.setItem.mockReset(); }); + it('should disable upload caching if resumableUploadsCache option is not set', async () => { + mockMultipartUploadSuccess(); + const size = 8 * MB; + const { multipartUploadJob } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(size), + }, + size, + ); + await multipartUploadJob(); + expect(mockDefaultStorage.getItem).not.toHaveBeenCalled(); + expect(mockDefaultStorage.setItem).not.toHaveBeenCalled(); + expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); + expect(mockListParts).not.toHaveBeenCalled(); + }); + it('should send createMultipartUpload request if the upload task is not cached', async () => { mockMultipartUploadSuccess(); const size = 8 * MB; @@ -423,6 +626,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -451,6 +657,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -469,6 +678,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new File([new ArrayBuffer(size)], 'someName'), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -481,7 +693,7 @@ describe('getMultipartUploadHandlers with key', () => { expect(Object.keys(cacheValue)).toEqual([ expect.stringMatching( // \d{13} is the file lastModified property of a file - /someName_\d{13}_8388608_application\/octet-stream_bucket_public_key/, + /someName_\d{13}_Jz3O2w==_8388608_application\/octet-stream_bucket_public_key/, ), ]); }); @@ -504,6 +716,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -522,6 +737,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -549,6 +767,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -571,6 +792,9 @@ describe('getMultipartUploadHandlers with key', () => { { key: defaultKey, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -588,10 +812,13 @@ describe('getMultipartUploadHandlers with key', () => { describe('cancel()', () => { it('should abort in-flight uploadPart requests and throw if upload is canceled', async () => { - const { multipartUploadJob, onCancel } = getMultipartUploadHandlers({ - key: defaultKey, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob, onCancel } = getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); let partCount = 0; mockMultipartUploadCancellation(() => { partCount++; @@ -615,24 +842,41 @@ describe('getMultipartUploadHandlers with key', () => { describe('pause() & resume()', () => { it('should abort in-flight uploadPart requests if upload is paused', async () => { + let pausedOnce = false; + + let resumeTest: () => void; + const waitForPause = new Promise(resolve => { + resumeTest = () => { + resolve(); + }; + }); + const { multipartUploadJob, onPause, onResume } = - getMultipartUploadHandlers({ - key: defaultKey, - data: new ArrayBuffer(8 * MB), - }); + getMultipartUploadHandlers( + { + key: defaultKey, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); let partCount = 0; mockMultipartUploadCancellation(() => { partCount++; - if (partCount === 2) { + if (partCount === 2 && !pausedOnce) { onPause(); // Pause upload at the the last uploadPart call + resumeTest(); + pausedOnce = true; } }); const uploadPromise = multipartUploadJob(); + await waitForPause; + await getZeroDelayTimeout(); onResume(); await uploadPromise; - expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledTimes(3); expect(mockUploadPart.mock.calls[0][0].abortSignal?.aborted).toBe(true); expect(mockUploadPart.mock.calls[1][0].abortSignal?.aborted).toBe(true); + expect(mockUploadPart.mock.calls[2][0].abortSignal?.aborted).toBe(false); }); }); @@ -673,9 +917,7 @@ describe('getMultipartUploadHandlers with key', () => { it('should send progress for cached upload parts', async () => { mockMultipartUploadSuccess(); - const mockDefaultStorage = defaultStorage as jest.Mocked< - typeof defaultStorage - >; + const mockDefaultStorage = jest.mocked(defaultStorage); mockDefaultStorage.getItem.mockResolvedValue( JSON.stringify({ [defaultCacheKey]: { @@ -697,6 +939,7 @@ describe('getMultipartUploadHandlers with key', () => { data: new ArrayBuffer(8 * MB), options: { onProgress, + resumableUploadsCache: mockDefaultStorage, }, }, 8 * MB, @@ -729,9 +972,10 @@ describe('getMultipartUploadHandlers with path', () => { }); }); - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); resetS3Mocks(); + mockCalculateContentCRC32Reset(); }); it('should return multipart upload handlers', async () => { @@ -773,10 +1017,13 @@ describe('getMultipartUploadHandlers with path', () => { `should upload a %s type body that splits into 2 parts to path ${expectedKey}`, async (_, twoPartsPayload) => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: inputPath, - data: twoPartsPayload, - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: inputPath, + data: twoPartsPayload, + }, + byteLength(twoPartsPayload)!, + ); const result = await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -802,12 +1049,88 @@ describe('getMultipartUploadHandlers with path', () => { ); }); + it.each([ + [ + 'file', + new File([getBlob(8 * MB)], 'someName'), + ['JCnBsQ==', 'HELzGQ=='], + ], + ['blob', getBlob(8 * MB), ['JCnBsQ==', 'HELzGQ==']], + ['string', 'Ü'.repeat(4 * MB), ['DL735w==', 'Akga7g==']], + ['arrayBuffer', new ArrayBuffer(8 * MB), ['yTuzdQ==', 'eXJPxg==']], + ['arrayBufferView', new Uint8Array(8 * MB), ['yTuzdQ==', 'eXJPxg==']], + ])( + `should create crc32 for %s type body`, + async (_, twoPartsPayload, expectedCrc32) => { + mockMultipartUploadSuccess(); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: twoPartsPayload, + options: { + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, + }, + byteLength(twoPartsPayload)!, + ); + await multipartUploadJob(); + + /** + * final crc32 calculation calls calculateContentCRC32 3 times + * 1 time for each of the 2 parts + * 1 time to combine the resulting hash for each of the two parts + * + * uploading each part calls calculateContentCRC32 1 time each + * + * 1 time for optionsHash + * + * these steps results in 6 calls in total + */ + expect(calculateContentCRC32).toHaveBeenCalledTimes(6); + expect(calculateContentMd5).not.toHaveBeenCalled(); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ChecksumCRC32: expectedCrc32[0] }), + ); + expect(mockUploadPart).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ ChecksumCRC32: expectedCrc32[1] }), + ); + }, + ); + + it('should use md5 if no using crc32', async () => { + mockMultipartUploadSuccess(); + Amplify.libraryOptions = { + Storage: { + S3: { + isObjectLockEnabled: true, + }, + }, + }; + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new Uint8Array(8 * MB), + }, + 8 * MB, + ); + await multipartUploadJob(); + expect(calculateContentCRC32).toHaveBeenCalledTimes(1); // (final crc32 calculation = 1 undefined) + expect(calculateContentMd5).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledTimes(2); + }); + it('should throw if unsupported payload type is provided', async () => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: testPath, - data: 1 as any, - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: 1 as any, + }, + 1, + ); await expect(multipartUploadJob()).rejects.toThrow( expect.objectContaining( validationErrorMap[StorageValidationErrorCode.InvalidUploadSource], @@ -816,6 +1139,7 @@ describe('getMultipartUploadHandlers with path', () => { }); it('should upload a body that exceeds the size of default part size and parts count', async () => { + mockCalculateContentCRC32Mock(); let buffer: ArrayBuffer; const file = { __proto__: File.prototype, @@ -836,11 +1160,14 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: file, + options: { + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, }, file.size, ); await multipartUploadJob(); - expect(file.slice).toHaveBeenCalledTimes(10_000); // S3 limit of parts count + expect(file.slice).toHaveBeenCalledTimes(10_000 * 2); // S3 limit of parts count double for crc32 calculations expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); expect(mockUploadPart).toHaveBeenCalledTimes(10_000); expect(mockCompleteMultipartUpload).toHaveBeenCalledTimes(1); @@ -881,10 +1208,13 @@ describe('getMultipartUploadHandlers with path', () => { mockCreateMultipartUpload.mockReset(); mockCreateMultipartUpload.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: testPath, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); }); @@ -894,10 +1224,13 @@ describe('getMultipartUploadHandlers with path', () => { mockCompleteMultipartUpload.mockReset(); mockCompleteMultipartUpload.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: testPath, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); }); @@ -912,28 +1245,63 @@ describe('getMultipartUploadHandlers with path', () => { }); mockUploadPart.mockRejectedValueOnce(new Error('error')); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: testPath, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); await expect(multipartUploadJob()).rejects.toThrow('error'); expect(mockUploadPart).toHaveBeenCalledTimes(2); expect(mockCompleteMultipartUpload).not.toHaveBeenCalled(); }); + describe('overwrite prevention', () => { + it('should include if-none-match header in complete request', async () => { + expect.assertions(3); + mockMultipartUploadSuccess(); + + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + options: { preventOverwrite: true }, + }, + 8 * MB, + ); + await multipartUploadJob(); + + await expect( + mockCompleteMultipartUpload, + ).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ + credentials, + region, + }), + expect.objectContaining({ + IfNoneMatch: '*', + }), + ); + }); + }); + 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 }, + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: 'path/', + data: mockData, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + }, }, - }); + byteLength(mockData)!, + ); await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -955,13 +1323,16 @@ describe('getMultipartUploadHandlers with path', () => { }); it('should override bucket in putObject call when bucket as string', async () => { mockMultipartUploadSuccess(); - const { multipartUploadJob } = getMultipartUploadHandlers({ - path: 'path/', - data: mockData, - options: { - bucket: 'default-bucket', + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: 'path/', + data: mockData, + options: { + bucket: 'default-bucket', + }, }, - }); + byteLength(mockData)!, + ); await multipartUploadJob(); await expect( mockCreateMultipartUpload, @@ -982,6 +1353,56 @@ describe('getMultipartUploadHandlers with path', () => { expect(mockCompleteMultipartUpload).toHaveBeenCalledTimes(1); }); }); + + describe('cache validation', () => { + it.each([ + { + name: 'wrong part count', + parts: [{ PartNumber: 1 }, { PartNumber: 2 }, { PartNumber: 3 }], + }, + { + name: 'wrong part numbers', + parts: [{ PartNumber: 1 }, { PartNumber: 1 }], + }, + ])('should throw with $name', async ({ parts }) => { + mockMultipartUploadSuccess(); + + const mockDefaultStorage = defaultStorage as jest.Mocked< + typeof defaultStorage + >; + mockDefaultStorage.getItem.mockResolvedValue( + JSON.stringify({ + [testPathCacheKey]: { + uploadId: 'uploadId', + bucket, + key: defaultKey, + finalCrc32: 'mock-crc32', + }, + }), + ); + mockListParts.mockResolvedValue({ + Parts: parts, + $metadata: {}, + }); + + const onProgress = jest.fn(); + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + options: { + onProgress, + resumableUploadsCache: mockDefaultStorage, + }, + }, + 8 * MB, + ); + await expect(multipartUploadJob()).rejects.toThrow({ + name: 'Unknown', + message: 'An unknown error has occurred.', + }); + }); + }); }); describe('upload caching', () => { @@ -993,6 +1414,23 @@ describe('getMultipartUploadHandlers with path', () => { mockDefaultStorage.setItem.mockReset(); }); + it('should disable upload caching if resumableUploadsCache option is not set', async () => { + mockMultipartUploadSuccess(); + const size = 8 * MB; + const { multipartUploadJob } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(size), + }, + size, + ); + await multipartUploadJob(); + expect(mockDefaultStorage.getItem).not.toHaveBeenCalled(); + expect(mockDefaultStorage.setItem).not.toHaveBeenCalled(); + expect(mockCreateMultipartUpload).toHaveBeenCalledTimes(1); + expect(mockListParts).not.toHaveBeenCalled(); + }); + it('should send createMultipartUpload request if the upload task is not cached', async () => { mockMultipartUploadSuccess(); const size = 8 * MB; @@ -1000,6 +1438,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1028,6 +1469,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1046,6 +1490,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new File([new ArrayBuffer(size)], 'someName'), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1056,12 +1503,10 @@ describe('getMultipartUploadHandlers with path', () => { mockDefaultStorage.setItem.mock.calls[0][1], ); - // \d{13} is the file lastModified property of a file - const lastModifiedRegex = /someName_\d{13}_/; - expect(Object.keys(cacheValue)).toEqual([ expect.stringMatching( - new RegExp(lastModifiedRegex.source + testPathCacheKey), + // \d{13} is the file lastModified property of a file + new RegExp('someName_\\d{13}_' + testPathCacheKey), ), ]); }); @@ -1084,6 +1529,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1102,6 +1550,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1127,6 +1578,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1149,6 +1603,9 @@ describe('getMultipartUploadHandlers with path', () => { { path: testPath, data: new ArrayBuffer(size), + options: { + resumableUploadsCache: mockDefaultStorage, + }, }, size, ); @@ -1166,10 +1623,13 @@ describe('getMultipartUploadHandlers with path', () => { describe('cancel()', () => { it('should abort in-flight uploadPart requests and throw if upload is canceled', async () => { - const { multipartUploadJob, onCancel } = getMultipartUploadHandlers({ - path: testPath, - data: new ArrayBuffer(8 * MB), - }); + const { multipartUploadJob, onCancel } = getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); let partCount = 0; mockMultipartUploadCancellation(() => { partCount++; @@ -1193,24 +1653,41 @@ describe('getMultipartUploadHandlers with path', () => { describe('pause() & resume()', () => { it('should abort in-flight uploadPart requests if upload is paused', async () => { + let pausedOnce = false; + let resumeTest: () => void; + const waitForPause = new Promise(resolve => { + resumeTest = () => { + resolve(); + }; + }); + const { multipartUploadJob, onPause, onResume } = - getMultipartUploadHandlers({ - path: testPath, - data: new ArrayBuffer(8 * MB), - }); + getMultipartUploadHandlers( + { + path: testPath, + data: new ArrayBuffer(8 * MB), + }, + 8 * MB, + ); let partCount = 0; mockMultipartUploadCancellation(() => { partCount++; - if (partCount === 2) { + if (partCount === 2 && !pausedOnce) { onPause(); // Pause upload at the the last uploadPart call + resumeTest(); + pausedOnce = true; } }); const uploadPromise = multipartUploadJob(); + await waitForPause; + await getZeroDelayTimeout(); + onResume(); await uploadPromise; - expect(mockUploadPart).toHaveBeenCalledTimes(2); + expect(mockUploadPart).toHaveBeenCalledTimes(3); expect(mockUploadPart.mock.calls[0][0].abortSignal?.aborted).toBe(true); expect(mockUploadPart.mock.calls[1][0].abortSignal?.aborted).toBe(true); + expect(mockUploadPart.mock.calls[2][0].abortSignal?.aborted).toBe(false); }); }); @@ -1251,9 +1728,8 @@ describe('getMultipartUploadHandlers with path', () => { it('should send progress for cached upload parts', async () => { mockMultipartUploadSuccess(); - const mockDefaultStorage = defaultStorage as jest.Mocked< - typeof defaultStorage - >; + const mockDefaultStorage = jest.mocked(defaultStorage); + mockDefaultStorage.getItem.mockResolvedValue( JSON.stringify({ [testPathCacheKey]: { @@ -1275,6 +1751,7 @@ describe('getMultipartUploadHandlers with path', () => { data: new ArrayBuffer(8 * MB), options: { onProgress, + resumableUploadsCache: mockDefaultStorage, }, }, 8 * MB, diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts similarity index 59% rename from packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts rename to packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts index aa9cf2ff8cd..2665fdef227 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/putObjectJob.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts @@ -4,14 +4,17 @@ import { AWSCredentials } from '@aws-amplify/core/internals/utils'; import { Amplify } from '@aws-amplify/core'; -import { putObject } from '../../../../../src/providers/s3/utils/client'; -import { calculateContentMd5 } from '../../../../../src/providers/s3/utils'; -import { putObjectJob } from '../../../../../src/providers/s3/apis/uploadData/putObjectJob'; +import { putObject } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { calculateContentMd5 } from '../../../../../../src/providers/s3/utils'; +import * as CRC32 from '../../../../../../src/providers/s3/utils/crc32'; +import { putObjectJob } from '../../../../../../src/providers/s3/apis/internal/uploadData/putObjectJob'; import '../testUtils'; +import { UploadDataChecksumAlgorithm } from '../../../../../../src/providers/s3/types/options'; +import { CHECKSUM_ALGORITHM_CRC32 } from '../../../../../../src/providers/s3/utils/constants'; -jest.mock('../../../../../src/providers/s3/utils/client'); -jest.mock('../../../../../src/providers/s3/utils', () => { - const utils = jest.requireActual('../../../../../src/providers/s3/utils'); +jest.mock('../../../../../../src/providers/s3/utils/client/s3data'); +jest.mock('../../../../../../src/providers/s3/utils', () => { + const utils = jest.requireActual('../../../../../../src/providers/s3/utils'); return { ...utils, @@ -40,6 +43,8 @@ const mockFetchAuthSession = jest.mocked(Amplify.Auth.fetchAuthSession); const mockPutObject = jest.mocked(putObject); const bucket = 'bucket'; const region = 'region'; +const data = 'data'; +const dataLength = data.length; mockFetchAuthSession.mockResolvedValue({ credentials, @@ -64,67 +69,84 @@ mockPutObject.mockResolvedValue({ describe('putObjectJob with key', () => { beforeEach(() => { mockPutObject.mockClear(); + jest.spyOn(CRC32, 'calculateContentCRC32').mockRestore(); }); - it('should supply the correct parameters to putObject API handler', async () => { - const abortController = new AbortController(); - const inputKey = 'key'; - const data = 'data'; - const mockContentType = 'contentType'; - const contentDisposition = 'contentDisposition'; - const contentEncoding = 'contentEncoding'; - const mockMetadata = { key: 'value' }; - const onProgress = jest.fn(); - const useAccelerateEndpoint = true; + it.each<{ checksumAlgorithm: UploadDataChecksumAlgorithm | undefined }>([ + { checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32 }, + { checksumAlgorithm: undefined }, + ])( + 'should supply the correct parameters to putObject API handler with checksumAlgorithm as $checksumAlgorithm', + async ({ checksumAlgorithm }) => { + const abortController = new AbortController(); + const inputKey = 'key'; + const mockContentType = 'contentType'; + const contentDisposition = 'contentDisposition'; + const contentEncoding = 'contentEncoding'; + const mockMetadata = { key: 'value' }; + const onProgress = jest.fn(); + const useAccelerateEndpoint = true; - const job = putObjectJob( - { + const job = putObjectJob( + { + key: inputKey, + data, + options: { + contentDisposition, + contentEncoding, + contentType: mockContentType, + metadata: mockMetadata, + onProgress, + useAccelerateEndpoint, + checksumAlgorithm, + }, + }, + abortController.signal, + dataLength, + ); + const result = await job(); + expect(result).toEqual({ key: inputKey, - data, - options: { - contentDisposition, - contentEncoding, - contentType: mockContentType, - metadata: mockMetadata, - onProgress, - useAccelerateEndpoint, + eTag: 'eTag', + versionId: 'versionId', + contentType: 'contentType', + metadata: { key: 'value' }, + size: dataLength, + }); + expect(mockPutObject).toHaveBeenCalledTimes(1); + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + abortSignal: abortController.signal, + onUploadProgress: expect.any(Function), + useAccelerateEndpoint: true, + userAgentValue: expect.any(String), }, - }, - abortController.signal, - ); - const result = await job(); - expect(result).toEqual({ - key: inputKey, - eTag: 'eTag', - versionId: 'versionId', - contentType: 'contentType', - metadata: { key: 'value' }, - size: undefined, - }); - expect(mockPutObject).toHaveBeenCalledTimes(1); - await expect(mockPutObject).toBeLastCalledWithConfigAndInput( - { - credentials, - region, - abortSignal: abortController.signal, - onUploadProgress: expect.any(Function), - useAccelerateEndpoint: true, - userAgentValue: expect.any(String), - }, - { - Bucket: bucket, - Key: `public/${inputKey}`, - Body: data, - ContentType: mockContentType, - ContentDisposition: contentDisposition, - ContentEncoding: contentEncoding, - Metadata: mockMetadata, - ContentMD5: undefined, - }, - ); - }); + { + Bucket: bucket, + Key: `public/${inputKey}`, + Body: data, + ContentType: mockContentType, + ContentDisposition: contentDisposition, + ContentEncoding: contentEncoding, + Metadata: mockMetadata, + + // ChecksumCRC32 is set when putObjectJob() is called with checksumAlgorithm: 'crc-32' + ChecksumCRC32: + checksumAlgorithm === CHECKSUM_ALGORITHM_CRC32 + ? 'rfPzYw==' + : undefined, + }, + ); + }, + ); it('should set ContentMD5 if object lock is enabled', async () => { + jest + .spyOn(CRC32, 'calculateContentCRC32') + .mockResolvedValue(undefined as any); + Amplify.libraryOptions = { Storage: { S3: { @@ -138,6 +160,7 @@ describe('putObjectJob with key', () => { data: 'data', }, new AbortController().signal, + dataLength, ); await job(); expect(calculateContentMd5).toHaveBeenCalledWith('data'); @@ -146,7 +169,6 @@ describe('putObjectJob with key', () => { 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'; @@ -162,6 +184,7 @@ describe('putObjectJob with key', () => { }, }, new AbortController().signal, + dataLength, ); await job(); @@ -183,7 +206,6 @@ describe('putObjectJob with key', () => { it('should override bucket in putObject call when bucket as string', async () => { const abortController = new AbortController(); - const data = 'data'; const job = putObjectJob( { key: 'key', @@ -193,6 +215,7 @@ describe('putObjectJob with key', () => { }, }, new AbortController().signal, + dataLength, ); await job(); @@ -217,22 +240,43 @@ describe('putObjectJob with key', () => { describe('putObjectJob with path', () => { beforeEach(() => { mockPutObject.mockClear(); + jest.spyOn(CRC32, 'calculateContentCRC32').mockRestore(); }); - test.each([ + it.each<{ checksumAlgorithm: UploadDataChecksumAlgorithm | undefined }>([ + { checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32 }, + { checksumAlgorithm: undefined }, + ]); + + test.each<{ + path: string | (() => string); + expectedKey: string; + checksumAlgorithm: UploadDataChecksumAlgorithm | undefined; + }>([ + { + path: testPath, + expectedKey: testPath, + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, + { + path: () => testPath, + expectedKey: testPath, + checksumAlgorithm: CHECKSUM_ALGORITHM_CRC32, + }, { path: testPath, expectedKey: testPath, + checksumAlgorithm: undefined, }, { path: () => testPath, expectedKey: testPath, + checksumAlgorithm: undefined, }, ])( - 'should supply the correct parameters to putObject API handler when path is $path', - async ({ path: inputPath, expectedKey }) => { + 'should supply the correct parameters to putObject API handler when path is $path and checksumAlgorithm is $checksumAlgorithm', + async ({ path: inputPath, expectedKey, checksumAlgorithm }) => { const abortController = new AbortController(); - const data = 'data'; const mockContentType = 'contentType'; const contentDisposition = 'contentDisposition'; const contentEncoding = 'contentEncoding'; @@ -251,9 +295,11 @@ describe('putObjectJob with path', () => { metadata: mockMetadata, onProgress, useAccelerateEndpoint, + checksumAlgorithm, }, }, abortController.signal, + dataLength, ); const result = await job(); expect(result).toEqual({ @@ -262,7 +308,7 @@ describe('putObjectJob with path', () => { versionId: 'versionId', contentType: 'contentType', metadata: { key: 'value' }, - size: undefined, + size: dataLength, }); expect(mockPutObject).toHaveBeenCalledTimes(1); await expect(mockPutObject).toBeLastCalledWithConfigAndInput( @@ -282,13 +328,22 @@ describe('putObjectJob with path', () => { ContentDisposition: contentDisposition, ContentEncoding: contentEncoding, Metadata: mockMetadata, - ContentMD5: undefined, + + // ChecksumCRC32 is set when putObjectJob() is called with checksumAlgorithm: 'crc-32' + ChecksumCRC32: + checksumAlgorithm === CHECKSUM_ALGORITHM_CRC32 + ? 'rfPzYw==' + : undefined, }, ); }, ); it('should set ContentMD5 if object lock is enabled', async () => { + jest + .spyOn(CRC32, 'calculateContentCRC32') + .mockResolvedValue(undefined as any); + Amplify.libraryOptions = { Storage: { S3: { @@ -299,18 +354,40 @@ describe('putObjectJob with path', () => { const job = putObjectJob( { path: testPath, - data: 'data', + data, }, new AbortController().signal, + dataLength, ); await job(); expect(calculateContentMd5).toHaveBeenCalledWith('data'); }); + describe('overwrite prevention', () => { + it('should include if-none-match header', async () => { + const job = putObjectJob( + { + path: testPath, + data, + options: { preventOverwrite: true }, + }, + new AbortController().signal, + dataLength, + ); + await job(); + + await expect(mockPutObject).toBeLastCalledWithConfigAndInput( + expect.objectContaining({ credentials, region }), + expect.objectContaining({ + IfNoneMatch: '*', + }), + ); + }); + }); + 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'; @@ -326,6 +403,7 @@ describe('putObjectJob with path', () => { }, }, new AbortController().signal, + dataLength, ); await job(); @@ -347,7 +425,6 @@ describe('putObjectJob with path', () => { it('should override bucket in putObject call when bucket as string', async () => { const abortController = new AbortController(); - const data = 'data'; const job = putObjectJob( { path: 'path/', @@ -357,6 +434,7 @@ describe('putObjectJob with path', () => { }, }, new AbortController().signal, + dataLength, ); await job(); diff --git a/packages/storage/__tests__/providers/s3/apis/list.test.ts b/packages/storage/__tests__/providers/s3/apis/list.test.ts index e01096a1113..578b74a971b 100644 --- a/packages/storage/__tests__/providers/s3/apis/list.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/list.test.ts @@ -1,860 +1,71 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; -import { listObjectsV2 } from '../../../../src/providers/s3/utils/client'; -import { list } from '../../../../src/providers/s3'; import { ListAllInput, - ListAllOutput, ListAllWithPathInput, - ListAllWithPathOutput, ListPaginateInput, - ListPaginateOutput, ListPaginateWithPathInput, - ListPaginateWithPathOutput, -} from '../../../../src/providers/s3/types'; -import './testUtils'; +} from '../../../../src'; +import { list } from '../../../../src/providers/s3/apis'; +import { list as internalListImpl } from '../../../../src/providers/s3/apis/internal/list'; -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); -const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = jest.mocked(Amplify.getConfig); -const mockListObject = listObjectsV2 as jest.Mock; -const inputKey = 'path/itemsKey'; -const bucket = 'bucket'; -const region = 'region'; -const nextToken = 'nextToken'; -const targetIdentityId = 'targetIdentityId'; -const defaultIdentityId = 'defaultIdentityId'; -const etagValue = 'eTag'; -const lastModifiedValue = 'lastModified'; -const sizeValue = 'size'; -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const listObjectClientConfig = { - credentials, - region, - userAgentValue: expect.any(String), -}; -const listObjectClientBaseResultItem = { - ETag: etagValue, - LastModified: lastModifiedValue, - Size: sizeValue, -}; -const listResultItem = { - eTag: etagValue, - lastModified: lastModifiedValue, - size: sizeValue, -}; -const mockListObjectsV2ApiWithPages = (pages: number) => { - let methodCalls = 0; - mockListObject.mockClear(); - mockListObject.mockImplementation(async (_, input) => { - let token: string | undefined; - methodCalls++; - if (methodCalls > pages) { - fail(`listObjectsV2 calls are more than expected. Expected ${pages}`); - } - if (input.ContinuationToken === undefined || methodCalls < pages) { - token = nextToken; - } +jest.mock('../../../../src/providers/s3/apis/internal/list'); - return { - Contents: [{ ...listObjectClientBaseResultItem, Key: input.Prefix }], - NextContinuationToken: token, - }; - }); -}; +const mockInternalListImpl = jest.mocked(internalListImpl); -describe('list API', () => { - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); +describe('client-side list', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe('Prefix: Happy Cases:', () => { - const listAllWrapper = (input: ListAllInput): Promise => - list(input); - const listPaginatedWrapper = ( - input: ListPaginateInput, - ): Promise => list(input); - afterEach(() => { - jest.clearAllMocks(); - }); - - const accessLevelTests: { - prefix?: string; - expectedKey: string; - options?: { - accessLevel?: StorageAccessLevel; - targetIdentityId?: string; - }; - }[] = [ - { - expectedKey: `public/`, - }, - { - options: { accessLevel: 'guest' }, - expectedKey: `public/`, - }, - { - prefix: inputKey, - expectedKey: `public/${inputKey}`, - }, - { - prefix: inputKey, - options: { accessLevel: 'guest' }, - expectedKey: `public/${inputKey}`, - }, - { - prefix: inputKey, - options: { accessLevel: 'private' }, - expectedKey: `private/${defaultIdentityId}/${inputKey}`, - }, - { - prefix: inputKey, - options: { accessLevel: 'protected' }, - expectedKey: `protected/${defaultIdentityId}/${inputKey}`, - }, - { - prefix: inputKey, - options: { accessLevel: 'protected', targetIdentityId }, - expectedKey: `protected/${targetIdentityId}/${inputKey}`, - }, - ]; - - accessLevelTests.forEach(({ prefix, options, expectedKey }) => { - const pathMsg = prefix ? 'custom' : 'default'; - const accessLevelMsg = options?.accessLevel ?? 'default'; - const targetIdentityIdMsg = options?.targetIdentityId - ? `with targetIdentityId` - : ''; - it(`should list objects with pagination, default pageSize, ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { - mockListObject.mockImplementationOnce(() => { - return { - Contents: [{ ...listObjectClientBaseResultItem, Key: expectedKey }], - NextContinuationToken: nextToken, - }; - }); - const response = await listPaginatedWrapper({ - prefix, - options, - }); - const { key, eTag, size, lastModified } = response.items[0]; - expect(response.items).toHaveLength(1); - expect({ key, eTag, size, lastModified }).toEqual({ - key: prefix ?? '', - ...listResultItem, - }); - expect(response.nextToken).toEqual(nextToken); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: expectedKey, - }, - ); - }); - }); - - accessLevelTests.forEach(({ prefix, options, expectedKey }) => { - const pathMsg = prefix ? 'custom' : 'default'; - const accessLevelMsg = options?.accessLevel ?? 'default'; - const targetIdentityIdMsg = options?.targetIdentityId - ? `with targetIdentityId` - : ''; - it(`should list objects with pagination using pageSize, nextToken, ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { - mockListObject.mockImplementationOnce(() => { - return { - Contents: [{ ...listObjectClientBaseResultItem, Key: expectedKey }], - NextContinuationToken: nextToken, - }; - }); - const customPageSize = 5; - const response = await listPaginatedWrapper({ - prefix, - options: { - ...options, - pageSize: customPageSize, - nextToken, - }, - }); - const { key, eTag, size, lastModified } = response.items[0]; - expect(response.items).toHaveLength(1); - expect({ key, eTag, size, lastModified }).toEqual({ - key: prefix ?? '', - ...listResultItem, - }); - expect(response.nextToken).toEqual(nextToken); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - Prefix: expectedKey, - ContinuationToken: nextToken, - MaxKeys: customPageSize, - }, - ); - }); - }); - - accessLevelTests.forEach(({ prefix, options, expectedKey }) => { - const pathMsg = prefix ? 'custom' : 'default'; - const accessLevelMsg = options?.accessLevel ?? 'default'; - const targetIdentityIdMsg = options?.targetIdentityId - ? `with targetIdentityId` - : ''; - it(`should list objects with zero results with ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { - mockListObject.mockImplementationOnce(() => { - return {}; - }); - const response = await listPaginatedWrapper({ - prefix, - options, - }); - expect(response.items).toEqual([]); - expect(response.nextToken).toEqual(undefined); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: expectedKey, - }, - ); - }); - }); - - accessLevelTests.forEach( - ({ prefix: inputPrefix, options, expectedKey }) => { - const pathMsg = inputPrefix ? 'custom' : 'default'; - const accessLevelMsg = options?.accessLevel ?? 'default'; - const targetIdentityIdMsg = options?.targetIdentityId - ? `with targetIdentityId` - : ''; - it(`should list all objects having three pages with ${pathMsg} path, ${accessLevelMsg} accessLevel ${targetIdentityIdMsg}`, async () => { - mockListObjectsV2ApiWithPages(3); - const result = await listAllWrapper({ - prefix: inputPrefix, - options: { ...options, listAll: true }, - }); - const { key, eTag, lastModified, size } = result.items[0]; - expect(result.items).toHaveLength(3); - expect({ key, eTag, lastModified, size }).toEqual({ - ...listResultItem, - key: inputPrefix ?? '', - }); - expect(result).not.toHaveProperty(nextToken); - - // listing three times for three pages - expect(listObjectsV2).toHaveBeenCalledTimes(3); - - // first input receives undefined as the Continuation Token - await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( - 1, - listObjectClientConfig, - { - Bucket: bucket, - Prefix: expectedKey, - MaxKeys: 1000, - ContinuationToken: undefined, - }, - ); - // last input receives TEST_TOKEN as the Continuation Token - await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( - 3, - listObjectClientConfig, - { - Bucket: bucket, - Prefix: expectedKey, - MaxKeys: 1000, - ContinuationToken: nextToken, - }, - ); - }); - }, - ); - - 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}`, - }, - ); - }); - }); + it('should pass through list all input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalListImpl.mockReturnValue(mockInternalResult); + const input: ListAllInput = { + prefix: 'source-key', + }; + expect(list(input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(Amplify, input); }); - describe('Path: Happy Cases:', () => { - const listAllWrapper = ( - input: ListAllWithPathInput, - ): Promise => list(input); - const listPaginatedWrapper = ( - input: ListPaginateWithPathInput, - ): Promise => list(input); - const resolvePath = ( - path: string | (({ identityId }: { identityId: string }) => string), - ) => - typeof path === 'string' ? path : path({ identityId: defaultIdentityId }); - afterEach(() => { - jest.clearAllMocks(); - mockListObject.mockClear(); - }); - const pathTestCases = [ - { - path: `public/${inputKey}`, + it('should pass through list paginate input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalListImpl.mockReturnValue(mockInternalResult); + const input: ListPaginateInput = { + prefix: 'source-key', + options: { + nextToken: '123', + pageSize: 10, }, - { - path: ({ identityId }: { identityId: string }) => - `protected/${identityId}/${inputKey}`, - }, - ]; - - it.each(pathTestCases)( - 'should list objects with pagination, default pageSize, custom path', - async ({ path: inputPath }) => { - const resolvedPath = resolvePath(inputPath); - mockListObject.mockImplementationOnce(() => { - return { - Contents: [ - { - ...listObjectClientBaseResultItem, - Key: resolvePath(inputPath), - }, - ], - NextContinuationToken: nextToken, - }; - }); - const response = await listPaginatedWrapper({ - path: resolvedPath, - }); - const { path, eTag, lastModified, size } = response.items[0]; - expect(response.items).toHaveLength(1); - expect({ path, eTag, lastModified, size }).toEqual({ - ...listResultItem, - path: resolvedPath, - }); - expect(response.nextToken).toEqual(nextToken); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: resolvePath(inputPath), - }, - ); - }, - ); - - it.each(pathTestCases)( - 'should list objects with pagination using custom pageSize, nextToken and custom path: $path', - async ({ path: inputPath }) => { - const resolvedPath = resolvePath(inputPath); - mockListObject.mockImplementationOnce(() => { - return { - Contents: [ - { - ...listObjectClientBaseResultItem, - Key: resolvePath(inputPath), - }, - ], - NextContinuationToken: nextToken, - }; - }); - const customPageSize = 5; - const response = await listPaginatedWrapper({ - path: resolvedPath, - options: { - pageSize: customPageSize, - nextToken, - }, - }); - const { path, eTag, lastModified, size } = response.items[0]; - expect(response.items).toHaveLength(1); - expect({ path, eTag, lastModified, size }).toEqual({ - ...listResultItem, - path: resolvedPath, - }); - expect(response.nextToken).toEqual(nextToken); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - Prefix: resolvePath(inputPath), - ContinuationToken: nextToken, - MaxKeys: customPageSize, - }, - ); - }, - ); - - it.each(pathTestCases)( - 'should list objects with zero results with custom path: $path', - async ({ path }) => { - mockListObject.mockImplementationOnce(() => { - return {}; - }); - const response = await listPaginatedWrapper({ - path: resolvePath(path), - }); - expect(response.items).toEqual([]); - - expect(response.nextToken).toEqual(undefined); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: resolvePath(path), - }, - ); - }, - ); - - it.each(pathTestCases)( - 'should list objects with CommonPrefix and nextToken in results with custom path: $path', - async ({ path }) => { - mockListObject.mockImplementationOnce(() => { - return { - CommonPrefixes: [ - { Prefix: 'photos/2023/' }, - { Prefix: 'photos/2024/' }, - { Prefix: 'photos/2025/' }, - { Prefix: 'photos/2026/' }, - { Prefix: 'photos/2027/' }, - { Prefix: 'photos/time-traveling/' }, - ], - NextContinuationToken: 'yup_there_is_more', - }; - }); - const response = await listPaginatedWrapper({ - path: resolvePath(path), - }); - expect(response.excludedSubpaths).toEqual([ - 'photos/2023/', - 'photos/2024/', - 'photos/2025/', - 'photos/2026/', - 'photos/2027/', - 'photos/time-traveling/', - ]); - - expect(response.nextToken).toEqual('yup_there_is_more'); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: resolvePath(path), - }, - ); - }, - ); - - it.each(pathTestCases)( - 'should list all objects having three pages with custom path: $path', - async ({ path: inputPath }) => { - const resolvedPath = resolvePath(inputPath); - mockListObjectsV2ApiWithPages(3); - const result = await listAllWrapper({ - path: resolvedPath, - options: { listAll: true }, - }); - - const listResult = { - path: resolvedPath, - ...listResultItem, - }; - const { path, lastModified, eTag, size } = result.items[0]; - expect(result.items).toHaveLength(3); - expect({ path, lastModified, eTag, size }).toEqual(listResult); - expect(result.items).toEqual([listResult, listResult, listResult]); - expect(result).not.toHaveProperty(nextToken); - - // listing three times for three pages - expect(listObjectsV2).toHaveBeenCalledTimes(3); - - // first input receives undefined as the Continuation Token - await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( - 1, - listObjectClientConfig, - { - Bucket: bucket, - Prefix: resolvedPath, - MaxKeys: 1000, - ContinuationToken: undefined, - }, - ); - // last input receives TEST_TOKEN as the Continuation Token - await expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( - 3, - listObjectClientConfig, - { - Bucket: bucket, - Prefix: resolvedPath, - MaxKeys: 1000, - ContinuationToken: nextToken, - }, - ); - }, - ); - - 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/', - }, - ); - }); - }); + }; + expect(list(input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(Amplify, input); }); - describe('Error Cases:', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should return a not found error', async () => { - mockListObject.mockRejectedValueOnce( - Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }), - ); - try { - await list({}); - } catch (error: any) { - expect.assertions(3); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: 'public/', - }, - ); - expect(error.$metadata.httpStatusCode).toBe(404); - } - }); + it('should pass through list all input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalListImpl.mockReturnValue(mockInternalResult); + const input: ListAllWithPathInput = { + path: 'abc', + }; + expect(list(input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(Amplify, input); }); - describe('with delimiter', () => { - const mockedContents = [ - { - Key: 'photos/', - ...listObjectClientBaseResultItem, - }, - { - Key: 'photos/2023.png', - ...listObjectClientBaseResultItem, + it('should pass through list paginate input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalListImpl.mockReturnValue(mockInternalResult); + const input: ListPaginateWithPathInput = { + path: 'abc', + options: { + nextToken: '123', + pageSize: 10, }, - { - Key: 'photos/2024.png', - ...listObjectClientBaseResultItem, - }, - ]; - const mockedCommonPrefixes = [ - { Prefix: 'photos/2023/' }, - { Prefix: 'photos/2024/' }, - { Prefix: 'photos/2025/' }, - ]; - - const expectedExcludedSubpaths = mockedCommonPrefixes.map( - ({ Prefix }) => Prefix, - ); - - const mockedPath = 'photos/'; - - beforeEach(() => { - mockListObject.mockResolvedValueOnce({ - Contents: mockedContents, - CommonPrefixes: mockedCommonPrefixes, - }); - }); - afterEach(() => { - jest.clearAllMocks(); - mockListObject.mockClear(); - }); - - it('should return excludedSubpaths when "exclude" strategy is passed in the request', async () => { - const { items, excludedSubpaths } = await list({ - path: mockedPath, - options: { - subpathStrategy: { strategy: 'exclude' }, - }, - }); - expect(items).toHaveLength(3); - expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: mockedPath, - Delimiter: '/', - }, - ); - }); - - it('should return excludedSubpaths when "exclude" strategy and listAll are passed in the request', async () => { - const { items, excludedSubpaths } = await list({ - path: mockedPath, - options: { - subpathStrategy: { strategy: 'exclude' }, - listAll: true, - }, - }); - expect(items).toHaveLength(3); - expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: mockedPath, - Delimiter: '/', - }, - ); - }); - - it('should return excludedSubpaths when "exclude" strategy and pageSize are passed in the request', async () => { - const { items, excludedSubpaths } = await list({ - path: mockedPath, - options: { - subpathStrategy: { strategy: 'exclude' }, - pageSize: 3, - }, - }); - expect(items).toHaveLength(3); - expect(excludedSubpaths).toEqual(expectedExcludedSubpaths); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 3, - Prefix: mockedPath, - Delimiter: '/', - }, - ); - }); - - it('should listObjectsV2 contain a custom Delimiter when "exclude" with delimiter is passed', async () => { - await list({ - path: mockedPath, - options: { - subpathStrategy: { - strategy: 'exclude', - delimiter: '-', - }, - }, - }); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: mockedPath, - Delimiter: '-', - }, - ); - }); - - it('should listObjectsV2 contain an undefined Delimiter when "include" strategy is passed', async () => { - await list({ - path: mockedPath, - options: { - subpathStrategy: { - strategy: 'include', - }, - }, - }); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: mockedPath, - Delimiter: undefined, - }, - ); - }); - - it('should listObjectsV2 contain an undefined Delimiter when no options are passed', async () => { - await list({ - path: mockedPath, - }); - expect(listObjectsV2).toHaveBeenCalledTimes(1); - await expect(listObjectsV2).toBeLastCalledWithConfigAndInput( - listObjectClientConfig, - { - Bucket: bucket, - MaxKeys: 1000, - Prefix: mockedPath, - Delimiter: undefined, - }, - ); - }); + }; + expect(list(input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(Amplify, input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/remove.test.ts b/packages/storage/__tests__/providers/s3/apis/remove.test.ts index eb3407eb610..8c42aec2f02 100644 --- a/packages/storage/__tests__/providers/s3/apis/remove.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/remove.test.ts @@ -1,292 +1,38 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AWSCredentials } from '@aws-amplify/core/internals/utils'; -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; -import { deleteObject } from '../../../../src/providers/s3/utils/client'; +import { RemoveInput, RemoveWithPathInput } from '../../../../src'; import { remove } from '../../../../src/providers/s3/apis'; -import { StorageValidationErrorCode } from '../../../../src/errors/types/validation'; -import { - RemoveInput, - RemoveOutput, - RemoveWithPathInput, - RemoveWithPathOutput, -} from '../../../../src/providers/s3/types'; -import './testUtils'; +import { remove as internalRemoveImpl } from '../../../../src/providers/s3/apis/internal/remove'; -jest.mock('../../../../src/providers/s3/utils/client'); -jest.mock('@aws-amplify/core', () => ({ - ConsoleLogger: jest.fn().mockImplementation(function ConsoleLogger() { - return { debug: jest.fn() }; - }), - Amplify: { - getConfig: jest.fn(), - Auth: { - fetchAuthSession: jest.fn(), - }, - }, -})); -const mockDeleteObject = deleteObject as jest.Mock; -const mockFetchAuthSession = Amplify.Auth.fetchAuthSession as jest.Mock; -const mockGetConfig = jest.mocked(Amplify.getConfig); -const inputKey = 'key'; -const bucket = 'bucket'; -const region = 'region'; -const defaultIdentityId = 'defaultIdentityId'; -const credentials: AWSCredentials = { - accessKeyId: 'accessKeyId', - sessionToken: 'sessionToken', - secretAccessKey: 'secretAccessKey', -}; -const deleteObjectClientConfig = { - credentials, - region, - userAgentValue: expect.any(String), -}; +jest.mock('../../../../src/providers/s3/apis/internal/remove'); -describe('remove API', () => { - beforeAll(() => { - mockFetchAuthSession.mockResolvedValue({ - credentials, - identityId: defaultIdentityId, - }); - mockGetConfig.mockReturnValue({ - Storage: { - S3: { - bucket, - region, - buckets: { 'default-bucket': { bucketName: bucket, region } }, - }, - }, - }); - }); - describe('Happy Cases', () => { - describe('With Key', () => { - const removeWrapper = (input: RemoveInput): Promise => - remove(input); - - beforeEach(() => { - mockDeleteObject.mockImplementation(() => { - return { - Metadata: { key: 'value' }, - }; - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - const testCases: { - expectedKey: string; - options?: { accessLevel?: StorageAccessLevel }; - }[] = [ - { - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'guest' }, - expectedKey: `public/${inputKey}`, - }, - { - options: { accessLevel: 'private' }, - expectedKey: `private/${defaultIdentityId}/${inputKey}`, - }, - { - options: { accessLevel: 'protected' }, - expectedKey: `protected/${defaultIdentityId}/${inputKey}`, - }, - ]; - - testCases.forEach(({ options, expectedKey }) => { - const accessLevel = options?.accessLevel ?? 'default'; +const mockInternalRemoveImpl = jest.mocked(internalRemoveImpl); - it(`should remove object with ${accessLevel} accessLevel`, async () => { - const { key } = await removeWrapper({ - key: inputKey, - options, - }); - expect(key).toEqual(inputKey); - expect(deleteObject).toHaveBeenCalledTimes(1); - await expect(deleteObject).toBeLastCalledWithConfigAndInput( - deleteObjectClientConfig, - { - Bucket: bucket, - Key: expectedKey, - }, - ); - }); - }); - - 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 = ( - input: RemoveWithPathInput, - ): Promise => remove(input); - beforeEach(() => { - mockDeleteObject.mockImplementation(() => { - return { - Metadata: { key: 'value' }, - }; - }); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - [ - { - path: `public/${inputKey}`, - }, - { - path: ({ identityId }: { identityId?: string }) => - `protected/${identityId}/${inputKey}`, - }, - ].forEach(({ path: inputPath }) => { - const resolvedPath = - typeof inputPath === 'string' - ? inputPath - : inputPath({ identityId: defaultIdentityId }); - - it(`should remove object for the given path`, async () => { - const { path } = await removeWrapper({ path: inputPath }); - expect(path).toEqual(resolvedPath); - expect(deleteObject).toHaveBeenCalledTimes(1); - await expect(deleteObject).toBeLastCalledWithConfigAndInput( - deleteObjectClientConfig, - { - Bucket: bucket, - Key: resolvedPath, - }, - ); - }); - }); +describe('client-side remove', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - 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/', - }, - ); - }); - }); - }); + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalRemoveImpl.mockReturnValue(mockInternalResult); + const input: RemoveInput = { + key: 'source-key', + }; + expect(remove(input)).toEqual(mockInternalResult); + expect(mockInternalRemoveImpl).toBeCalledWith(Amplify, input); }); - describe('Error Cases:', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should return a not found error', async () => { - mockDeleteObject.mockRejectedValueOnce( - Object.assign(new Error(), { - $metadata: { httpStatusCode: 404 }, - name: 'NotFound', - }), - ); - expect.assertions(3); - const key = 'wrongKey'; - try { - await remove({ key }); - } catch (error: any) { - expect(deleteObject).toHaveBeenCalledTimes(1); - await expect(deleteObject).toBeLastCalledWithConfigAndInput( - deleteObjectClientConfig, - { - Bucket: bucket, - Key: `public/${key}`, - }, - ); - expect(error.$metadata.httpStatusCode).toBe(404); - } - }); - it('should throw InvalidStorageOperationInput error when the path is empty', async () => { - expect.assertions(1); - try { - await remove({ path: '' }); - } catch (error: any) { - expect(error.name).toBe( - StorageValidationErrorCode.InvalidStorageOperationInput, - ); - } - }); + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalRemoveImpl.mockReturnValue(mockInternalResult); + const input: RemoveWithPathInput = { + path: 'abc', + }; + expect(remove(input)).toEqual(mockInternalResult); + expect(mockInternalRemoveImpl).toBeCalledWith(Amplify, input); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/server/copy.test.ts b/packages/storage/__tests__/providers/s3/apis/server/copy.test.ts new file mode 100644 index 00000000000..06ce54b5b6b --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/server/copy.test.ts @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { CopyInput, CopyWithPathInput } from '../../../../../src'; +import { copy } from '../../../../../src/providers/s3/apis/server'; +import { copy as internalCopyImpl } from '../../../../../src/providers/s3/apis/internal/copy'; + +jest.mock('../../../../../src/providers/s3/apis/internal/copy'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const mockInternalCopyImpl = jest.mocked(internalCopyImpl); +const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext); +const mockInternalResult = 'RESULT' as any; +const mockAmplifyClass = 'AMPLIFY_CLASS' as any; +const mockAmplifyContextSpec = { + token: { value: Symbol('123') }, +}; + +describe('server-side copy', () => { + beforeEach(() => { + mockGetAmplifyServerContext.mockReturnValue({ + amplify: mockAmplifyClass, + }); + mockInternalCopyImpl.mockReturnValue(mockInternalResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through input with key and output to internal implementation', async () => { + const input: CopyInput = { + source: { + key: 'source-key', + }, + destination: { + key: 'destination-key', + }, + }; + expect(copy(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalCopyImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through input with path and output to internal implementation', async () => { + const input: CopyWithPathInput = { + source: { path: 'abc' }, + destination: { path: 'abc' }, + }; + expect(copy(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalCopyImpl).toBeCalledWith(mockAmplifyClass, input); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/server/getProperties.test.ts b/packages/storage/__tests__/providers/s3/apis/server/getProperties.test.ts new file mode 100644 index 00000000000..9afd1403d55 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/server/getProperties.test.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { + GetPropertiesInput, + GetPropertiesWithPathInput, +} from '../../../../../src'; +import { getProperties } from '../../../../../src/providers/s3/apis/server'; +import { getProperties as internalGetPropertiesImpl } from '../../../../../src/providers/s3/apis/internal/getProperties'; + +jest.mock('../../../../../src/providers/s3/apis/internal/getProperties'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const mockInternalGetPropertiesImpl = jest.mocked(internalGetPropertiesImpl); +const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext); +const mockInternalResult = 'RESULT' as any; +const mockAmplifyClass = 'AMPLIFY_CLASS' as any; +const mockAmplifyContextSpec = { + token: { value: Symbol('123') }, +}; + +describe('server-side getProperties', () => { + beforeEach(() => { + mockGetAmplifyServerContext.mockReturnValue({ + amplify: mockAmplifyClass, + }); + mockInternalGetPropertiesImpl.mockReturnValue(mockInternalResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through input with key and output to internal implementation', async () => { + const input: GetPropertiesInput = { + key: 'source-key', + }; + expect(getProperties(mockAmplifyContextSpec, input)).toEqual( + mockInternalResult, + ); + expect(mockInternalGetPropertiesImpl).toBeCalledWith( + mockAmplifyClass, + input, + ); + }); + + it('should pass through input with path and output to internal implementation', async () => { + const input: GetPropertiesWithPathInput = { + path: 'abc', + }; + expect(getProperties(mockAmplifyContextSpec, input)).toEqual( + mockInternalResult, + ); + expect(mockInternalGetPropertiesImpl).toBeCalledWith( + mockAmplifyClass, + input, + ); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/server/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/server/getUrl.test.ts new file mode 100644 index 00000000000..3dfac7a58dc --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/server/getUrl.test.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { GetUrlInput, GetUrlWithPathInput } from '../../../../../src'; +import { getUrl } from '../../../../../src/providers/s3/apis/server'; +import { getUrl as internalGetUrlImpl } from '../../../../../src/providers/s3/apis/internal/getUrl'; + +jest.mock('../../../../../src/providers/s3/apis/internal/getUrl'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const mockInternalGetUrlImpl = jest.mocked(internalGetUrlImpl); +const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext); +const mockInternalResult = 'RESULT' as any; +const mockAmplifyClass = 'AMPLIFY_CLASS' as any; + +describe('server-side getUrl', () => { + beforeEach(() => { + mockGetAmplifyServerContext.mockReturnValue({ + amplify: mockAmplifyClass, + }); + mockInternalGetUrlImpl.mockReturnValue(mockInternalResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through input with key and output to internal implementation', async () => { + const input: GetUrlInput = { + key: 'source-key', + }; + expect( + getUrl( + { + token: { value: Symbol('123') }, + }, + input, + ), + ).toEqual(mockInternalResult); + expect(mockInternalGetUrlImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through input with path and output to internal implementation', async () => { + const input: GetUrlWithPathInput = { + path: 'abc', + }; + expect( + getUrl( + { + token: { value: Symbol('123') }, + }, + input, + ), + ).toEqual(mockInternalResult); + expect(mockInternalGetUrlImpl).toBeCalledWith(mockAmplifyClass, input); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/server/list.test.ts b/packages/storage/__tests__/providers/s3/apis/server/list.test.ts new file mode 100644 index 00000000000..febd469afa3 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/server/list.test.ts @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { + ListAllInput, + ListAllWithPathInput, + ListPaginateInput, + ListPaginateWithPathInput, +} from '../../../../../src'; +import { list } from '../../../../../src/providers/s3/apis/server'; +import { list as internalListImpl } from '../../../../../src/providers/s3/apis/internal/list'; + +jest.mock('../../../../../src/providers/s3/apis/internal/list'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const mockInternalListImpl = jest.mocked(internalListImpl); +const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext); +const mockInternalResult = 'RESULT' as any; +const mockAmplifyClass = 'AMPLIFY_CLASS' as any; +const mockAmplifyContextSpec = { + token: { value: Symbol('123') }, +}; + +describe('server-side list', () => { + beforeEach(() => { + mockGetAmplifyServerContext.mockReturnValue({ + amplify: mockAmplifyClass, + }); + mockInternalListImpl.mockReturnValue(mockInternalResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through list all input with key and output to internal implementation', async () => { + const input: ListAllInput = { + prefix: 'source-key', + }; + expect(list(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through list paginate input with key and output to internal implementation', async () => { + const input: ListPaginateInput = { + prefix: 'source-key', + options: { + nextToken: '123', + pageSize: 10, + }, + }; + expect(list(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through list all input with path and output to internal implementation', async () => { + const input: ListAllWithPathInput = { + path: 'abc', + }; + expect(list(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through list paginate input with path and output to internal implementation', async () => { + const input: ListPaginateWithPathInput = { + path: 'abc', + options: { + nextToken: '123', + pageSize: 10, + }, + }; + expect(list(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalListImpl).toBeCalledWith(mockAmplifyClass, input); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/server/remove.test.ts b/packages/storage/__tests__/providers/s3/apis/server/remove.test.ts new file mode 100644 index 00000000000..861c3ce0d24 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/server/remove.test.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { RemoveInput, RemoveWithPathInput } from '../../../../../src'; +import { remove } from '../../../../../src/providers/s3/apis/server'; +import { remove as internalRemoveImpl } from '../../../../../src/providers/s3/apis/internal/remove'; + +jest.mock('../../../../../src/providers/s3/apis/internal/remove'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const mockInternalRemoveImpl = jest.mocked(internalRemoveImpl); +const mockGetAmplifyServerContext = jest.mocked(getAmplifyServerContext); +const mockInternalResult = 'RESULT' as any; +const mockAmplifyClass = 'AMPLIFY_CLASS' as any; +const mockAmplifyContextSpec = { + token: { value: Symbol('123') }, +}; + +describe('server-side remove', () => { + beforeEach(() => { + mockGetAmplifyServerContext.mockReturnValue({ + amplify: mockAmplifyClass, + }); + mockInternalRemoveImpl.mockReturnValue(mockInternalResult); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through input with key and output to internal implementation', async () => { + const input: RemoveInput = { + key: 'source-key', + }; + expect(remove(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalRemoveImpl).toBeCalledWith(mockAmplifyClass, input); + }); + + it('should pass through input with path and output to internal implementation', async () => { + const input: RemoveWithPathInput = { + path: 'abc', + }; + expect(remove(mockAmplifyContextSpec, input)).toEqual(mockInternalResult); + expect(mockInternalRemoveImpl).toBeCalledWith(mockAmplifyClass, input); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData.test.ts b/packages/storage/__tests__/providers/s3/apis/uploadData.test.ts new file mode 100644 index 00000000000..c6477b83ae0 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/apis/uploadData.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { defaultStorage } from '@aws-amplify/core'; + +import { uploadData } from '../../../../src/providers/s3/apis'; +import { uploadData as internalUploadDataImpl } from '../../../../src/providers/s3/apis/internal/uploadData'; + +jest.mock('../../../../src/providers/s3/apis/internal/uploadData'); + +const mockInternalUploadDataImpl = jest.mocked(internalUploadDataImpl); + +describe('client-side uploadData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should pass through input with key and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalUploadDataImpl.mockReturnValue(mockInternalResult); + const input = { + key: 'key', + data: 'data', + options: { + accessLevel: 'protected' as const, + }, + }; + expect(uploadData(input)).toEqual(mockInternalResult); + expect(mockInternalUploadDataImpl).toBeCalledWith({ + ...input, + options: { + ...input.options, + resumableUploadsCache: defaultStorage, + }, + }); + }); + + it('should pass through input with path and output to internal implementation', async () => { + const mockInternalResult = 'RESULT' as any; + mockInternalUploadDataImpl.mockReturnValue(mockInternalResult); + const input = { + path: 'path', + data: 'data', + options: { + preventOverwrite: true, + }, + }; + expect(uploadData(input)).toEqual(mockInternalResult); + expect(mockInternalUploadDataImpl).toBeCalledWith({ + ...input, + options: { + ...input.options, + resumableUploadsCache: defaultStorage, + }, + }); + }); +}); 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 022c2f0c1fb..662640e3340 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,11 @@ import { StorageValidationErrorCode, validationErrorMap, } from '../../../../../src/errors/types/validation'; +import { + CallbackPathStorageInput, + DeprecatedStorageInput, +} from '../../../../../src/providers/s3/utils/resolveS3ConfigAndInput'; +import { INVALID_STORAGE_INPUT } from '../../../../../src/errors/constants'; import { BucketInfo } from '../../../../../src/providers/s3/types/options'; import { StorageError } from '../../../../../src/errors/StorageError'; @@ -79,13 +84,11 @@ describe('resolveS3ConfigAndInput', () => { } }); - it('should throw if identityId is not available', async () => { + it('should not throw if identityId is not available', async () => { mockFetchAuthSession.mockResolvedValueOnce({ credentials, }); - await expect(resolveS3ConfigAndInput(Amplify, {})).rejects.toMatchObject( - validationErrorMap[StorageValidationErrorCode.NoIdentityId], - ); + expect(async () => resolveS3ConfigAndInput(Amplify, {})).not.toThrow(); }); it('should resolve bucket from S3 config', async () => { @@ -182,7 +185,7 @@ describe('resolveS3ConfigAndInput', () => { it('should resolve prefix with given access level', async () => { mockDefaultResolvePrefix.mockResolvedValueOnce('prefix'); const { keyPrefix } = await resolveS3ConfigAndInput(Amplify, { - accessLevel: 'someLevel' as any, + options: { accessLevel: 'someLevel' as any }, }); expect(mockDefaultResolvePrefix).toHaveBeenCalledWith({ accessLevel: 'someLevel', @@ -218,6 +221,95 @@ describe('resolveS3ConfigAndInput', () => { expect(keyPrefix).toEqual('prefix'); }); + describe('with locationCredentialsProvider', () => { + const mockLocationCredentialsProvider = jest + .fn() + .mockReturnValue({ credentials }); + it('should resolve credentials without Amplify singleton', async () => { + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + }, + }, + }); + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials({ forceRefresh: true }); + expect(mockLocationCredentialsProvider).toHaveBeenCalledWith({ + forceRefresh: true, + }); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + it('should not throw when path is pass as a string', async () => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + path: 'my-path', + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials(); + expect(mockLocationCredentialsProvider).toHaveBeenCalled(); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + describe('with deprecated or callback paths as inputs', () => { + const key = 'mock-value'; + const prefix = 'mock-value'; + const path = () => 'path'; + const deprecatedInputs: DeprecatedStorageInput[] = [ + { prefix }, + { key }, + { + source: { key }, + destination: { key }, + }, + ]; + const callbackPathInputs: CallbackPathStorageInput[] = [ + { path }, + { + destination: { path }, + source: { path }, + }, + ]; + + const testCases = [...deprecatedInputs, ...callbackPathInputs]; + + it.each(testCases)('should throw when input is %s', async input => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + ...input, + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + if (typeof s3Config.credentials === 'function') { + await expect(s3Config.credentials()).rejects.toThrow( + expect.objectContaining({ + name: INVALID_STORAGE_INPUT, + }), + ); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + }); + }); + it('should resolve bucket and region with overrides when bucket API option is passed', async () => { const bucketInfo: BucketInfo = { bucketName: 'bucket-2', @@ -228,7 +320,7 @@ describe('resolveS3ConfigAndInput', () => { bucket: resolvedBucket, s3Config: { region: resolvedRegion }, } = await resolveS3ConfigAndInput(Amplify, { - bucket: bucketInfo, + options: { bucket: bucketInfo }, }); expect(mockGetConfig).toHaveBeenCalled(); @@ -239,7 +331,7 @@ describe('resolveS3ConfigAndInput', () => { 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', + options: { bucket: 'error-bucket' }, }); } catch (error: any) { expect(error).toBeInstanceOf(StorageError); diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/abortMultipartUpload.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/abortMultipartUpload.ts index 4628c433e51..5eba75535a6 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/abortMultipartUpload.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/abortMultipartUpload.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { abortMultipartUpload } from '../../../../../../../src/providers/s3/utils/client'; +import { abortMultipartUpload } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -42,4 +42,38 @@ const abortMultipartUploadHappyCase: ApiFunctionalTestCase< }, ]; -export default [abortMultipartUploadHappyCase]; +const abortMultipartUploadHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof abortMultipartUpload +> = [ + 'happy case', + 'abortMultipartUpload with custom endpoint', + abortMultipartUpload, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key?uploadId=uploadId', + }), + }), + { + status: 204, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [ + abortMultipartUploadHappyCase, + abortMultipartUploadHappyCaseCustomEndpoint, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/completeMultipartUpload.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/completeMultipartUpload.ts index 125cb505e4c..140267b751b 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/completeMultipartUpload.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/completeMultipartUpload.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { completeMultipartUpload } from '../../../../../../../src/providers/s3/utils/client'; +import { completeMultipartUpload } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -10,6 +10,30 @@ import { expectedMetadata, } from './shared'; +const defaultExpectedRequest = { + url: expect.objectContaining({ + href: 'https://bucket.s3.us-east-1.amazonaws.com/key?uploadId=uploadId', + }), + method: 'POST', + headers: expect.objectContaining({ + 'content-type': 'application/xml', + }), + body: + '' + + '' + + '' + + 'etag1' + + '1' + + 'test-checksum-1' + + '' + + '' + + 'etag2' + + '2' + + 'test-checksum-2' + + '' + + '', +}; + // API reference: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html const completeMultipartUploadHappyCase: ApiFunctionalTestCase< typeof completeMultipartUpload @@ -26,36 +50,18 @@ const completeMultipartUploadHappyCase: ApiFunctionalTestCase< { ETag: 'etag1', PartNumber: 1, + ChecksumCRC32: 'test-checksum-1', }, { ETag: 'etag2', PartNumber: 2, + ChecksumCRC32: 'test-checksum-2', }, ], }, UploadId: 'uploadId', }, - expect.objectContaining({ - url: expect.objectContaining({ - href: 'https://bucket.s3.us-east-1.amazonaws.com/key?uploadId=uploadId', - }), - method: 'POST', - headers: expect.objectContaining({ - 'content-type': 'application/xml', - }), - body: - '' + - '' + - '' + - 'etag1' + - '1' + - '' + - '' + - 'etag2' + - '2' + - '' + - '', - }), + expect.objectContaining(defaultExpectedRequest), { status: 200, headers: { ...DEFAULT_RESPONSE_HEADERS }, @@ -75,7 +81,68 @@ const completeMultipartUploadHappyCase: ApiFunctionalTestCase< }, ]; -// API reference: https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html +const completeMultipartUploadHappyCaseIfNoneMatch: ApiFunctionalTestCase< + typeof completeMultipartUpload +> = [ + 'happy case', + 'completeMultipartUpload - if-none-match', + completeMultipartUpload, + defaultConfig, + { + ...completeMultipartUploadHappyCase[4], + IfNoneMatch: 'mock-if-none-match', + }, + expect.objectContaining({ + ...defaultExpectedRequest, + headers: { + 'content-type': 'application/xml', + 'If-None-Match': 'mock-if-none-match', + }, + }), + completeMultipartUploadHappyCase[6], + completeMultipartUploadHappyCase[7], +]; + +const completeMultipartUploadHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof completeMultipartUpload +> = [ + 'happy case', + 'completeMultipartUpload with custom endpoint', + completeMultipartUpload, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + MultipartUpload: { + Parts: [ + { + ETag: 'etag1', + PartNumber: 1, + ChecksumCRC32: 'test-checksum-1', + }, + ], + }, + UploadId: 'uploadId', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key?uploadId=uploadId', + }), + }), + { + status: 200, + headers: { ...DEFAULT_RESPONSE_HEADERS }, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + const completeMultipartUploadErrorCase: ApiFunctionalTestCase< typeof completeMultipartUpload > = [ @@ -109,7 +176,12 @@ const completeMultipartUploadErrorWith200CodeCase: ApiFunctionalTestCase< 'error case', 'completeMultipartUpload with 200 status', completeMultipartUpload, - { ...defaultConfig, retryDecider: async () => false }, // disable retry + { + ...defaultConfig, + retryDecider: async () => ({ + retryable: false, + }), + }, // disable retry completeMultipartUploadHappyCase[4], completeMultipartUploadHappyCase[5], { @@ -132,6 +204,8 @@ const completeMultipartUploadErrorWith200CodeCase: ApiFunctionalTestCase< export default [ completeMultipartUploadHappyCase, + completeMultipartUploadHappyCaseIfNoneMatch, + completeMultipartUploadHappyCaseCustomEndpoint, completeMultipartUploadErrorCase, completeMultipartUploadErrorWith200CodeCase, ]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/copyObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/copyObject.ts index 746ca373057..8938a2ce9c8 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/copyObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/copyObject.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { copyObject } from '../../../../../../../src/providers/s3/utils/client'; +import { copyObject } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -23,6 +23,8 @@ const copyObjectHappyCase: ApiFunctionalTestCase = [ CacheControl: 'cacheControl', ContentType: 'contentType', ACL: 'acl', + CopySourceIfMatch: 'eTag', + CopySourceIfUnmodifiedSince: new Date(0), }, expect.objectContaining({ url: expect.objectContaining({ @@ -34,6 +36,8 @@ const copyObjectHappyCase: ApiFunctionalTestCase = [ 'cache-control': 'cacheControl', 'content-type': 'contentType', 'x-amz-acl': 'acl', + 'x-amz-copy-source-if-match': 'eTag', + 'x-amz-copy-source-if-unmodified-since': 'Thu, 01 Jan 1970 00:00:00 GMT', }), }), { @@ -54,4 +58,34 @@ const copyObjectHappyCase: ApiFunctionalTestCase = [ }, ]; -export default [copyObjectHappyCase]; +const copyObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof copyObject +> = [ + 'happy case', + 'getObject with custom endpoint', + copyObject, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + CopySource: 'sourceBucket/sourceKey', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key', + }), + }), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; +export default [copyObjectHappyCase, copyObjectHappyCaseCustomEndpoint]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/createMultipartUpload.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/createMultipartUpload.ts index df13908e715..b53ae0b48e8 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/createMultipartUpload.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/createMultipartUpload.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { createMultipartUpload } from '../../../../../../../src/providers/s3/utils/client'; +import { createMultipartUpload } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -42,4 +42,34 @@ const createMultiPartUploadHappyCase: ApiFunctionalTestCase< }, ]; -export default [createMultiPartUploadHappyCase]; +const createMultiPartUploadHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof createMultipartUpload +> = [ + 'happy case', + 'createMultipartUpload with custom endpoint', + createMultipartUpload, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + putObjectRequest, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key?uploads', + }), + }), + { + status: 200, + headers: { ...DEFAULT_RESPONSE_HEADERS }, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [ + createMultiPartUploadHappyCase, + createMultiPartUploadHappyCaseCustomEndpoint, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/deleteObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/deleteObject.ts index f0a4439e13f..0d591b6bfcc 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/deleteObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/deleteObject.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { deleteObject } from '../../../../../../../src/providers/s3/utils/client'; +import { deleteObject } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -36,4 +36,34 @@ const deleteObjectHappyCase: ApiFunctionalTestCase = [ }, ]; -export default [deleteObjectHappyCase]; +const deleteObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof deleteObject +> = [ + 'happy case', + 'deleteObject with custom endpoint', + deleteObject, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key', + }), + }), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [deleteObjectHappyCase, deleteObjectHappyCaseCustomEndpoint]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getDataAccess.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getDataAccess.ts new file mode 100644 index 00000000000..6e944a058a6 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getDataAccess.ts @@ -0,0 +1,172 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getDataAccess } from '../../../../../../../src/providers/s3/utils/client/s3control'; +import { ApiFunctionalTestCase } from '../../testUtils/types'; + +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from './shared'; + +const MOCK_ACCOUNT_ID = 'accountId'; +const MOCK_ACCESS_ID = 'accessId'; +const MOCK_SECRET_ACCESS_KEY = 'secretAccessKey'; +const MOCK_SESSION_TOKEN = 'sessionToken'; +const MOCK_EXPIRATION = '2013-09-17T18:07:53.000Z'; +const MOCK_EXPIRATION_DATE = new Date(MOCK_EXPIRATION); +const MOCK_GRANT_TARGET = 'matchedGrantTarget'; + +// API Reference: https://docs.aws.amazon.com/AmazonS3/latest/API/API_control_GetDataAccess.html +const getDataAccessHappyCase: ApiFunctionalTestCase = [ + 'happy case', + 'getDataAccess', + getDataAccess, + defaultConfig, + { + AccountId: MOCK_ACCOUNT_ID, + Target: 's3://my-bucket/path/to/object.md', + TargetType: 'Object', + DurationSeconds: 100, + Permission: 'READWRITE', + Privilege: 'Default', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.s3-control.us-east-1.amazonaws.com/v20180820/accessgrantsinstance/dataaccess?durationSeconds=100&permission=READWRITE&privilege=Default&target=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2Fobject.md&targetType=Object', + }), + method: 'GET', + headers: expect.objectContaining({ + 'x-amz-account-id': MOCK_ACCOUNT_ID, + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: ` + + + + ${MOCK_ACCESS_ID} + ${MOCK_SECRET_ACCESS_KEY} + ${MOCK_SESSION_TOKEN} + ${MOCK_EXPIRATION} + + ${MOCK_GRANT_TARGET} + + `, + }, + { + $metadata: expect.objectContaining(expectedMetadata), + Credentials: { + AccessKeyId: MOCK_ACCESS_ID, + SecretAccessKey: MOCK_SECRET_ACCESS_KEY, + SessionToken: MOCK_SESSION_TOKEN, + Expiration: MOCK_EXPIRATION_DATE, + }, + MatchedGrantTarget: MOCK_GRANT_TARGET, + }, +]; + +const getDataAccessHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof getDataAccess +> = [ + 'happy case', + 'getDataAccess with custom endpoint', + getDataAccess, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + }, + { + AccountId: MOCK_ACCOUNT_ID, + Target: 's3://my-bucket/path/to/object.md', + Permission: 'READWRITE', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.custom.endpoint.com/v20180820/accessgrantsinstance/dataaccess?permission=READWRITE&target=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2Fobject.md', + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +const getDataAccessErrorCase: ApiFunctionalTestCase = [ + 'error case', + 'getDataAccess', + getDataAccess, + defaultConfig, + getDataAccessHappyCase[4], + getDataAccessHappyCase[5], + { + status: 403, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + + + AccessDenied + Access Denied + + 656c76696e6727732072657175657374 + Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg== + + `, + }, + { + message: 'Access Denied', + name: 'AccessDenied', + }, +]; + +const getDataAccessErrorCaseInvalidCustomEndpoint: ApiFunctionalTestCase< + typeof getDataAccess +> = [ + 'error case', + 'getDataAccess with invalid custom endpoint', + getDataAccess, + { + ...defaultConfig, + customEndpoint: 'http://custom.endpoint.com', + }, + { + AccountId: MOCK_ACCOUNT_ID, + Target: 's3://my-bucket/path/to/object.md', + Permission: 'READWRITE', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.custom.endpoint.com/v20180820/accessgrantsinstance/dataaccess?permission=READWRITE&target=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2Fobject.md', + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: '', + }, + { + message: 'Invalid S3 custom endpoint.', + name: 'InvalidCustomEndpoint', + }, +]; + +export default [ + getDataAccessHappyCase, + getDataAccessHappyCaseCustomEndpoint, + getDataAccessErrorCase, + getDataAccessErrorCaseInvalidCustomEndpoint, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getObject.ts index c6b1e038926..2a2ddf98f68 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/getObject.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { getObject } from '../../../../../../../src/providers/s3/utils/client'; +import { getObject } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -143,14 +143,16 @@ const getObjectHappyCase: ApiFunctionalTestCase = [ }, ]; -const getObjectAccelerateEndpoint: ApiFunctionalTestCase = [ +const getObjectHappyCaseAccelerateEndpoint: ApiFunctionalTestCase< + typeof getObject +> = [ 'happy case', 'getObject with accelerate endpoint', getObject, { ...defaultConfig, useAccelerateEndpoint: true, - } as Parameters[0], + }, { Bucket: 'bucket', Key: 'key', @@ -170,15 +172,17 @@ const getObjectAccelerateEndpoint: ApiFunctionalTestCase = [ }) as any, ]; -const getObjectCustomEndpoint: ApiFunctionalTestCase = [ +const getObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof getObject +> = [ 'happy case', 'getObject with custom endpoint', getObject, { ...defaultConfig, - customEndpoint: 'https://custom.endpoint.com', + customEndpoint: 'custom.endpoint.com', forcePathStyle: true, - } as Parameters[0], + }, { Bucket: 'bucket', Key: 'key', @@ -198,8 +202,100 @@ const getObjectCustomEndpoint: ApiFunctionalTestCase = [ }) as any, ]; +const getObjectErrorCaseAccelerateEndpoint: ApiFunctionalTestCase< + typeof getObject +> = [ + 'error case', + 'getObject with accelerate endpoint and forcePathStyle', + getObject, + { + ...defaultConfig, + useAccelerateEndpoint: true, + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://bucket.s3-accelerate.amazonaws.com/key', + }), + }), + { + status: 400, + headers: DEFAULT_RESPONSE_HEADERS, + body: 'mockBody', + }, + { + message: 'Path style URLs are not supported with S3 Transfer Acceleration.', + name: 'ForcePathStyleEndpointNotSupported', + }, +]; + +const getObjectErrorCaseInvalidCustomEndpoint: ApiFunctionalTestCase< + typeof getObject +> = [ + 'error case', + 'getObject with invalid custom endpoint', + getObject, + { + ...defaultConfig, + customEndpoint: 'http://custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key', + }), + }), + { + status: 400, + headers: DEFAULT_RESPONSE_HEADERS, + body: 'mockBody', + }, + { + message: 'Invalid S3 custom endpoint.', + name: 'InvalidCustomEndpoint', + }, +]; + +const getObjectErrorCaseInvalidBucketName: ApiFunctionalTestCase< + typeof getObject +> = [ + 'error case', + 'getObject with incompatible Dns bucket name', + getObject, + defaultConfig, + { + Bucket: 'incompatibleDnsCompatibleBucketName', + Key: 'key', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://incompatibleDnsCompatibleBucketName.s3.us-east-1.amazonaws.com/key', + }), + }), + { + status: 400, + headers: DEFAULT_RESPONSE_HEADERS, + body: 'mockBody', + }, + { + message: `The bucket name isn't DNS compatible.`, + name: 'DnsIncompatibleBucketName', + }, +]; + export default [ getObjectHappyCase, - getObjectAccelerateEndpoint, - getObjectCustomEndpoint, + getObjectHappyCaseAccelerateEndpoint, + getObjectHappyCaseCustomEndpoint, + getObjectErrorCaseAccelerateEndpoint, + getObjectErrorCaseInvalidCustomEndpoint, + getObjectErrorCaseInvalidBucketName, ]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts index 2275d7ac850..a392e121c8c 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/headObject.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { headObject } from '../../../../../../../src/providers/s3/utils/client'; +import { headObject } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -48,4 +48,34 @@ const headObjectHappyCase: ApiFunctionalTestCase = [ }, ]; -export default [headObjectHappyCase]; +const headObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof headObject +> = [ + 'happy case', + 'headObject with custom endpoint', + headObject, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key', + }), + }), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [headObjectHappyCase, headObjectHappyCaseCustomEndpoint]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/index.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/index.ts index 56a4e1719ae..b5688b18c78 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/index.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/index.ts @@ -12,6 +12,8 @@ import copyObjectCases from './copyObject'; import deleteObjectCases from './deleteObject'; import getObjectCases from './getObject'; import headObjectCases from './headObject'; +import getDataAccess from './getDataAccess'; +import listCallerAccessGrants from './listCallerAccessGrants'; export default [ ...listObjectsV2Cases, @@ -25,4 +27,6 @@ export default [ ...deleteObjectCases, ...getObjectCases, ...headObjectCases, + ...listCallerAccessGrants, + ...getDataAccess, ]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listCallerAccessGrants.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listCallerAccessGrants.ts new file mode 100644 index 00000000000..175e6d8b0da --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listCallerAccessGrants.ts @@ -0,0 +1,206 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { listCallerAccessGrants } from '../../../../../../../src/providers/s3/utils/client/s3control'; +import { ApiFunctionalTestCase } from '../../testUtils/types'; + +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from './shared'; + +const MOCK_ACCOUNT_ID = 'accountId'; +const MOCK_NEXT_TOKEN = 'nextToken'; +const MOCK_APP_ARN = 'appArn'; +const MOCK_GRANT_SCOPE = 's3://my-bucket/path/to/object.md'; +const MOCK_PERMISSION = 'READWRITE'; + +// API Reference: https://docs.aws.amazon.com/AmazonS3/latest/API/API_control_ListAccessGrants.html +const listCallerAccessGrantsHappyCaseSingleGrant: ApiFunctionalTestCase< + typeof listCallerAccessGrants +> = [ + 'happy case', + 'listCallerAccessGrantsHappyCaseSingleGrant', + listCallerAccessGrants, + defaultConfig, + { + AccountId: MOCK_ACCOUNT_ID, + GrantScope: 's3://my-bucket/path/to/', + MaxResults: 50, + NextToken: 'mockToken', + AllowedByApplication: true, + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.s3-control.us-east-1.amazonaws.com/v20180820/accessgrantsinstance/caller/grants?grantscope=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2F&maxResults=50&nextToken=mockToken&allowedByApplication=true', + }), + method: 'GET', + headers: expect.objectContaining({ + 'x-amz-account-id': MOCK_ACCOUNT_ID, + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: ` + + + ${MOCK_NEXT_TOKEN} + + + ${MOCK_APP_ARN} + ${MOCK_GRANT_SCOPE} + ${MOCK_PERMISSION} + + + + `, + }, + { + $metadata: expect.objectContaining(expectedMetadata), + CallerAccessGrantsList: [ + { + ApplicationArn: MOCK_APP_ARN, + GrantScope: MOCK_GRANT_SCOPE, + Permission: MOCK_PERMISSION, + }, + ], + NextToken: MOCK_NEXT_TOKEN, + }, +]; + +const listCallerAccessGrantsHappyCaseMultipleGrants: ApiFunctionalTestCase< + typeof listCallerAccessGrants +> = [ + 'happy case', + 'listCallerAccessGrantsHappyCaseMultipleGrants', + listCallerAccessGrants, + defaultConfig, + { + AccountId: MOCK_ACCOUNT_ID, + GrantScope: 's3://my-bucket/path/to/', + MaxResults: 50, + NextToken: 'mockToken', + AllowedByApplication: true, + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.s3-control.us-east-1.amazonaws.com/v20180820/accessgrantsinstance/caller/grants?grantscope=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2F&maxResults=50&nextToken=mockToken&allowedByApplication=true', + }), + method: 'GET', + headers: expect.objectContaining({ + 'x-amz-account-id': MOCK_ACCOUNT_ID, + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: ` + + + ${MOCK_NEXT_TOKEN} + + + ${MOCK_APP_ARN} + ${MOCK_GRANT_SCOPE} + ${MOCK_PERMISSION} + + + ${MOCK_APP_ARN} + ${MOCK_GRANT_SCOPE} + ${MOCK_PERMISSION} + + + + `, + }, + { + $metadata: expect.objectContaining(expectedMetadata), + CallerAccessGrantsList: [ + { + ApplicationArn: MOCK_APP_ARN, + GrantScope: MOCK_GRANT_SCOPE, + Permission: MOCK_PERMISSION, + }, + { + ApplicationArn: MOCK_APP_ARN, + GrantScope: MOCK_GRANT_SCOPE, + Permission: MOCK_PERMISSION, + }, + ], + NextToken: MOCK_NEXT_TOKEN, + }, +]; + +const listCallerAccessGrantsHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof listCallerAccessGrants +> = [ + 'happy case', + 'listCallerAccessGrants with custom endpoint', + listCallerAccessGrants, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + }, + { + AccountId: MOCK_ACCOUNT_ID, + GrantScope: 's3://my-bucket/path/to/', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://accountid.custom.endpoint.com/v20180820/accessgrantsinstance/caller/grants?grantscope=s3%3A%2F%2Fmy-bucket%2Fpath%2Fto%2F', + }), + }), + { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + }, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +const listCallerAccessGrantsErrorCase: ApiFunctionalTestCase< + typeof listCallerAccessGrants +> = [ + 'error case', + 'listCallerAccessGrants', + listCallerAccessGrants, + defaultConfig, + listCallerAccessGrantsHappyCaseSingleGrant[4], + listCallerAccessGrantsHappyCaseSingleGrant[5], + { + status: 403, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + + + AccessDenied + Access Denied + + 656c76696e6727732072657175657374 + Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg== + + `, + }, + { + message: 'Access Denied', + name: 'AccessDenied', + }, +]; + +export default [ + listCallerAccessGrantsHappyCaseSingleGrant, + listCallerAccessGrantsHappyCaseMultipleGrants, + listCallerAccessGrantsHappyCaseCustomEndpoint, + listCallerAccessGrantsErrorCase, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listObjectsV2.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listObjectsV2.ts index 7524a8daeb6..1a7ff38e323 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listObjectsV2.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listObjectsV2.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { listObjectsV2 } from '../../../../../../../src/providers/s3/utils/client'; +import { listObjectsV2 } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -11,9 +11,11 @@ import { } from './shared'; // API Reference: https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html -const listObjectsV2HappyCase: ApiFunctionalTestCase = [ +const listObjectsV2HappyCaseTruncated: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ 'happy case', - 'listObjectsV2', + 'listObjectsV2 - truncated', listObjectsV2, defaultConfig, { @@ -45,10 +47,10 @@ const listObjectsV2HappyCase: ApiFunctionalTestCase = [ bucket - 205 + 4 ExampleGuide.pdf 1000 - false + true string 1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM= Next1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM= @@ -111,8 +113,8 @@ const listObjectsV2HappyCase: ApiFunctionalTestCase = [ ContinuationToken: '1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=', Delimiter: 'string', EncodingType: 'string', - IsTruncated: false, - KeyCount: 205, + IsTruncated: true, + KeyCount: 4, MaxKeys: 1000, Name: 'bucket', NextContinuationToken: 'Next1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM=', @@ -122,13 +124,92 @@ const listObjectsV2HappyCase: ApiFunctionalTestCase = [ }, ]; -const listObjectsV2ErrorCase: ApiFunctionalTestCase = [ +const listObjectsV2HappyCaseComplete: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + listObjectsV2HappyCaseTruncated[0], + 'listObjectsV2 - complete', + listObjectsV2HappyCaseTruncated[2], + listObjectsV2HappyCaseTruncated[3], + listObjectsV2HappyCaseTruncated[4], + listObjectsV2HappyCaseTruncated[5], + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + + 4 + + ExampleObject.txt + 2013-09-17T18:07:53.000Z + "599bab3ed2c697f1d26842727561fd94" + 857 + REDUCED_REDUNDANCY + + + my-image.jpg + 2009-10-12T17:50:30.000Z + "fba9dede5f27731c9771645a39863328" + 434234 + STANDARD + + 8a6925ce4a7f21c32aa379004fef + string + + + + photos/2006/February/ + + + photos/2006/January/ + + `, + }, + { + CommonPrefixes: [ + { + Prefix: 'photos/2006/February/', + }, + { + Prefix: 'photos/2006/January/', + }, + ], + Contents: [ + { + ETag: '"599bab3ed2c697f1d26842727561fd94"', + Key: 'ExampleObject.txt', + LastModified: new Date('2013-09-17T18:07:53.000Z'), + Size: 857, + StorageClass: 'REDUCED_REDUNDANCY', + }, + { + ETag: '"fba9dede5f27731c9771645a39863328"', + Key: 'my-image.jpg', + LastModified: new Date('2009-10-12T17:50:30.000Z'), + Size: 434234, + StorageClass: 'STANDARD', + Owner: { + ID: '8a6925ce4a7f21c32aa379004fef', + DisplayName: 'string', + }, + }, + ], + KeyCount: 4, + Name: 'bucket', + Prefix: '', + $metadata: expect.objectContaining(expectedMetadata), + }, +]; + +const listObjectsV2ErrorCase403: ApiFunctionalTestCase = [ 'error case', - 'listObjectsV2', + 'listObjectsV2 - 403', listObjectsV2, defaultConfig, - listObjectsV2HappyCase[4], - listObjectsV2HappyCase[5], + listObjectsV2HappyCaseTruncated[4], + listObjectsV2HappyCaseTruncated[5], { status: 403, headers: DEFAULT_RESPONSE_HEADERS, @@ -136,7 +217,7 @@ const listObjectsV2ErrorCase: ApiFunctionalTestCase = [ NoSuchKey The resource you requested does not exist - /mybucket/myfoto.jpg + /mybucket/myfoto.jpg 4442587FB7D0A2F9 `, }, @@ -146,4 +227,376 @@ const listObjectsV2ErrorCase: ApiFunctionalTestCase = [ }, ]; -export default [listObjectsV2HappyCase, listObjectsV2ErrorCase]; +const listObjectsV2ErrorCaseKeyCount: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + listObjectsV2ErrorCase403[0], + 'listObjectsV2 - key count mismatch', + listObjectsV2ErrorCase403[2], + listObjectsV2ErrorCase403[3], + listObjectsV2ErrorCase403[4], + listObjectsV2ErrorCase403[5], + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + + 5 + + ExampleObject.txt + 2013-09-17T18:07:53.000Z + "599bab3ed2c697f1d26842727561fd94" + 857 + REDUCED_REDUNDANCY + + + my-image.jpg + 2009-10-12T17:50:30.000Z + "fba9dede5f27731c9771645a39863328" + 434234 + STANDARD + + 8a6925ce4a7f21c32aa379004fef + string + + + + photos/2006/February/ + + + photos/2006/January/ + + `, + }, + { + message: 'An unknown error has occurred.', + name: 'Unknown', + }, +]; + +const listObjectsV2ErrorCaseMissingToken: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + listObjectsV2ErrorCase403[0], + 'listObjectsV2 - missing next continuation token', + listObjectsV2ErrorCase403[2], + listObjectsV2ErrorCase403[3], + listObjectsV2ErrorCase403[4], + listObjectsV2ErrorCase403[5], + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + + 5 + true + + ExampleObject.txt + 2013-09-17T18:07:53.000Z + "599bab3ed2c697f1d26842727561fd94" + 857 + REDUCED_REDUNDANCY + + + my-image.jpg + 2009-10-12T17:50:30.000Z + "fba9dede5f27731c9771645a39863328" + 434234 + STANDARD + + 8a6925ce4a7f21c32aa379004fef + string + + + + photos/2006/February/ + + + photos/2006/January/ + + `, + }, + { + message: 'An unknown error has occurred.', + name: 'Unknown', + }, +]; + +const listObjectsV2ErrorCaseMissingTruncated: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + listObjectsV2ErrorCase403[0], + 'listObjectsV2 - missing truncated', + listObjectsV2ErrorCase403[2], + listObjectsV2ErrorCase403[3], + listObjectsV2ErrorCase403[4], + listObjectsV2ErrorCase403[5], + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + + 5 + Next1ueGcxLPRx1Tr/XYExHnhbYLgveDs2J/wm36Hy4vbOwM= + + ExampleObject.txt + 2013-09-17T18:07:53.000Z + "599bab3ed2c697f1d26842727561fd94" + 857 + REDUCED_REDUNDANCY + + + my-image.jpg + 2009-10-12T17:50:30.000Z + "fba9dede5f27731c9771645a39863328" + 434234 + STANDARD + + 8a6925ce4a7f21c32aa379004fef + string + + + + photos/2006/February/ + + + photos/2006/January/ + + `, + }, + { + message: 'An unknown error has occurred.', + name: 'Unknown', + }, +]; + +const listObjectsV2HappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + 'happy case', + 'listObjectsV2 with custom endpoint', + listObjectsV2, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Prefix: 'Prefix', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket?list-type=2&prefix=Prefix', + }), + }), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + + 1 + 1000 + false + + ExampleObject.txt + 2013-09-17T18:07:53.000Z + "599bab3ed2c697f1d26842727561fd94" + 857 + REDUCED_REDUNDANCY + + `, + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +const listObjectsV2HappyCaseWithEncoding: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + 'happy case', + 'listObjectsV2 unicode values with encoding', + listObjectsV2, + { + ...defaultConfig, + }, + { + Bucket: 'bucket', + Prefix: 'Prefix', + EncodingType: 'url', + }, + expect.any(Object), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + bucket + some%20folder%20with%20%00%20unprintable%20unicode%2F + bad%08key + bad%01key + 6 + 101 + url + false + + public/bad%3Cdiv%3Ekey + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + bad%00key + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + public/bad%7Fkey + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + public/some%20folder%20with%20spaces%2F + + + public/real%0A%0A%0A%0A%0A%0A%0A%0A%0Afunny%0A%0A%0A%0A%0A%0A%0A%0A%0Abiz%2F + + + public/some%20folder%20with%20%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%20multibyte%20unicode%2F + +`, + }, + expect.objectContaining({ + CommonPrefixes: [ + { + Prefix: 'public/some%20folder%20with%20spaces%2F', + }, + { + Prefix: + 'public/real%0A%0A%0A%0A%0A%0A%0A%0A%0Afunny%0A%0A%0A%0A%0A%0A%0A%0A%0Abiz%2F', + }, + { + Prefix: + 'public/some%20folder%20with%20%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%20multibyte%20unicode%2F', + }, + ], + Contents: [ + { + Key: 'public/bad%3Cdiv%3Ekey', + LastModified: new Date('2024-11-05T18:13:11.000Z'), + ETag: '"c0e066cc5238dd7937e464fe7572b71a"', + Size: 5455, + StorageClass: 'STANDARD', + }, + { + Key: 'bad%00key', + LastModified: new Date('2024-11-05T18:13:11.000Z'), + ETag: '"c0e066cc5238dd7937e464fe7572b71a"', + Size: 5455, + StorageClass: 'STANDARD', + }, + { + Key: 'public/bad%7Fkey', + LastModified: new Date('2024-11-05T18:13:11.000Z'), + ETag: '"c0e066cc5238dd7937e464fe7572b71a"', + Size: 5455, + StorageClass: 'STANDARD', + }, + ], + Prefix: 'some%20folder%20with%20%00%20unprintable%20unicode%2F', + Delimiter: 'bad%08key', + StartAfter: 'bad%01key', + EncodingType: 'url', + Name: 'bucket', + }) as any, +]; + +const listObjectsV2ErrorCaseNoEncoding: ApiFunctionalTestCase< + typeof listObjectsV2 +> = [ + 'error case', + 'listObjectsV2 unicode values without encoding', + listObjectsV2, + { + ...defaultConfig, + }, + { + Bucket: 'bucket', + Prefix: 'Prefix', + EncodingType: undefined, + }, + expect.any(Object), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: ` + + badname + bad\x01key + 5 + 101 + bad\x08key + false + おはよう multibyte unicode + + public/bad
      key + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + bad\x00key + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + public/bad\x7fkey + 2024-11-05T18:13:11.000Z + "c0e066cc5238dd7937e464fe7572b71a" + 5455 + STANDARD + + + public/some folder with spaces/ + + + public/some folder with \x00 unprintable unicode/ + +`, + }, + { + message: 'An unknown error has occurred.', + name: 'Unknown', + }, +]; + +export default [ + listObjectsV2HappyCaseTruncated, + listObjectsV2HappyCaseComplete, + listObjectsV2HappyCaseCustomEndpoint, + listObjectsV2ErrorCaseKeyCount, + listObjectsV2ErrorCaseMissingTruncated, + listObjectsV2ErrorCaseMissingToken, + listObjectsV2ErrorCase403, + listObjectsV2HappyCaseWithEncoding, + listObjectsV2ErrorCaseNoEncoding, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listParts.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listParts.ts index 3e809d12bdc..63f2a37e06c 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listParts.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/listParts.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { listParts } from '../../../../../../../src/providers/s3/utils/client'; +import { listParts } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -38,11 +38,13 @@ const listPartsHappyCase: ApiFunctionalTestCase = [ '1' + 'etag1' + '5242880' + + 'checksum1' + '' + '' + '2' + 'etag2' + '1024' + + 'checksum2' + '' + '', }, @@ -53,15 +55,46 @@ const listPartsHappyCase: ApiFunctionalTestCase = [ { PartNumber: 1, ETag: 'etag1', - Size: 5242880, + ChecksumCRC32: 'checksum1', }, { PartNumber: 2, ETag: 'etag2', - Size: 1024, + ChecksumCRC32: 'checksum2', }, ], }, ]; -export default [listPartsHappyCase]; +const listPartsHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof listParts +> = [ + 'happy case', + 'listParts with custom endpoint', + listParts, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key?uploadId=uploadId', + }), + }), + { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [listPartsHappyCase, listPartsHappyCaseCustomEndpoint]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/putObject.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/putObject.ts index 930870a7c15..6ee6f1e62fa 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/putObject.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/putObject.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { putObject } from '../../../../../../../src/providers/s3/utils/client'; +import { putObject } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -68,7 +68,32 @@ const putObjectHappyCase: ApiFunctionalTestCase = [ }, ]; -const pubObjectDefaultContentType: ApiFunctionalTestCase = [ +const putObjectHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof putObject +> = [ + 'happy case', + 'putObject with custom endpoint', + putObject, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + putObjectRequest, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key', + }), + }), + putObjectSuccessResponse, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +const pubObjectHappyCaseDefaultContentType: ApiFunctionalTestCase< + typeof putObject +> = [ 'happy case', 'putObject default content type', putObject, @@ -86,4 +111,8 @@ const pubObjectDefaultContentType: ApiFunctionalTestCase = [ expect.anything(), ]; -export default [putObjectHappyCase, pubObjectDefaultContentType]; +export default [ + putObjectHappyCase, + putObjectHappyCaseCustomEndpoint, + pubObjectHappyCaseDefaultContentType, +]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/uploadPart.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/uploadPart.ts index b4906b223c2..34d0d6f7f38 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/cases/uploadPart.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/cases/uploadPart.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { uploadPart } from '../../../../../../../src/providers/s3/utils/client'; +import { uploadPart } from '../../../../../../../src/providers/s3/utils/client/s3data'; import { ApiFunctionalTestCase } from '../../testUtils/types'; import { @@ -44,4 +44,36 @@ const uploadPartHappyCase: ApiFunctionalTestCase = [ }, ]; -export default [uploadPartHappyCase]; +const uploadPartHappyCaseCustomEndpoint: ApiFunctionalTestCase< + typeof uploadPart +> = [ + 'happy case', + 'uploadPart with custom endpoint', + uploadPart, + { + ...defaultConfig, + customEndpoint: 'custom.endpoint.com', + forcePathStyle: true, + }, + { + Bucket: 'bucket', + Key: 'key', + PartNumber: 1, + UploadId: 'uploadId', + }, + expect.objectContaining({ + url: expect.objectContaining({ + href: 'https://custom.endpoint.com/bucket/key?partNumber=1&uploadId=uploadId', + }), + }), + { + status: 200, + headers: { ...DEFAULT_RESPONSE_HEADERS, etag: 'etag' }, + body: '', + }, + expect.objectContaining({ + /** skip validating response */ + }) as any, +]; + +export default [uploadPartHappyCase, uploadPartHappyCaseCustomEndpoint]; diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/functional-apis.test.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/functional-apis.test.ts index 62b4aff0cf5..656f8d45ed7 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/functional-apis.test.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/functional-apis.test.ts @@ -68,11 +68,11 @@ describe('S3 APIs functional test', () => { expect.anything(), ); } else { - fail(`${name} ${caseType} should fail`); + throw new Error(`${name} ${caseType} should fail`); } } catch (e) { if (caseType === 'happy case') { - fail(`${name} ${caseType} should succeed: ${e}`); + throw new Error(`${name} ${caseType} should succeed: ${e}`); } else { expect(e).toBeInstanceOf(StorageError); expect(e).toEqual(expect.objectContaining(outputOrError)); diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedGetObjectUrl.test.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedGetObjectUrl.test.ts index ab84fb03eb6..a208859a7c8 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedGetObjectUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/S3/getPresignedGetObjectUrl.test.ts @@ -3,7 +3,7 @@ import { presignUrl } from '@aws-amplify/core/internals/aws-client-utils'; -import { getPresignedGetObjectUrl } from '../../../../../../src/providers/s3/utils/client'; +import { getPresignedGetObjectUrl } from '../../../../../../src/providers/s3/utils/client/s3data'; import { defaultConfigWithStaticCredentials } from './cases/shared'; diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/abortMutipartUpload.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/abortMutipartUpload.test.ts new file mode 100644 index 00000000000..7f62097251c --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/abortMutipartUpload.test.ts @@ -0,0 +1,93 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { abortMultipartUpload } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const abortMultipartUploadSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializeAbortMultipartUploadRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(abortMultipartUploadSuccessResponse as any), + ); + const output = await abortMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'upload-id', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(abortMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + abortMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'upload-id', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts new file mode 100644 index 00000000000..5036a9de6fb --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts @@ -0,0 +1,143 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { completeMultipartUpload } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { validateMultipartUploadXML } from '../../../../../../src/providers/s3/utils/validateMultipartUploadXML'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/validateMultipartUploadXML', +); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const completeMultipartUploadSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('completeMultipartUploadSerializer', () => { + const mockValidateObjectUrl = jest.mocked(validateObjectUrl); + const mockValidateMultipartUploadXML = jest.mocked( + validateMultipartUploadXML, + ); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl and multipartUploadXML is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const output = await completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }); + console.log(output); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockValidateObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }), + ).rejects.toThrow(integrityError); + }); + + it('should fail when multipartUploadXML is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockValidateMultipartUploadXML.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/copyObject.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/copyObject.test.ts new file mode 100644 index 00000000000..01ce54eb16f --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/copyObject.test.ts @@ -0,0 +1,193 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { + copyObject, + validateCopyObjectHeaders, +} from '../../../../../../src/providers/s3/utils/client/s3data/copyObject'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const copyObjectSuccessResponse: any = { + status: 200, + headers: DEFAULT_RESPONSE_HEADERS, + body: '', +}; + +describe('copyObjectSerializer', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is valid', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(copyObjectSuccessResponse), + ); + const output = await copyObject(defaultConfig, { + CopySource: 'mock-source', + Bucket: 'bucket', + Key: 'key', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT valid', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(copyObjectSuccessResponse), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + copyObject(defaultConfig, { + CopySource: 'mock-source', + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); + +describe('validateCopyObjectHeaders', () => { + const baseRequest: any = { CopySource: 'mock-source' }; + const baseHeaders: any = { 'x-amz-copy-source': 'mock-source' }; + + [ + { + description: 'when only correct copy source is provided', + request: baseRequest, + headers: baseHeaders, + expectPass: true, + }, + { + description: 'when optional headers are provided correctly', + request: { + ...baseRequest, + MetadataDirective: 'mock-metadata', + CopySourceIfMatch: 'mock-etag', + CopySourceIfUnmodifiedSince: new Date(0), + }, + headers: { + ...baseHeaders, + 'x-amz-metadata-directive': 'mock-metadata', + 'x-amz-copy-source-if-match': 'mock-etag', + 'x-amz-copy-source-if-unmodified-since': + 'Thu, 01 Jan 1970 00:00:00 GMT', + }, + expectPass: true, + }, + { + description: 'when optional headers are added without request', + request: baseRequest, + headers: { + ...baseHeaders, + 'x-amz-metadata-directive': 'mock-metadata', + 'x-amz-copy-source-if-match': 'mock-etag', + 'x-amz-copy-source-if-unmodified-since': + 'Thu, 01 Jan 1970 00:00:00 GMT', + }, + expectPass: false, + }, + ...[null, undefined, 'wrong-metadata'].map(incorrectHeader => ({ + description: `when metadata is not mapped correctly: ${incorrectHeader}`, + request: { + ...baseRequest, + MetadataDirective: 'mock-metadata', + }, + headers: { + ...baseHeaders, + 'x-amz-metadata-directive': incorrectHeader, + }, + expectPass: false, + })), + ...[null, undefined, 'wrong-etag'].map(incorrectHeader => ({ + description: `when source etag is not mapped correctly: ${incorrectHeader}`, + request: { + ...baseRequest, + CopySourceIfMatch: 'mock-etag', + }, + headers: { + ...baseHeaders, + 'x-amz-copy-source-if-match': incorrectHeader, + }, + expectPass: false, + })), + ...[null, undefined, 'wrong-date'].map(incorrectHeader => ({ + description: `when unmodified since date is not mapped correctly: ${incorrectHeader}`, + request: { + ...baseRequest, + CopySourceIfUnmodifiedSince: new Date(0), + }, + headers: { + ...baseHeaders, + 'x-amz-copy-source-if-unmodified-since': incorrectHeader, + }, + expectPass: false, + })), + ].forEach(({ description, request, headers, expectPass }) => { + describe(description, () => { + if (expectPass) { + it('should pass validation', () => { + try { + validateCopyObjectHeaders(request, headers); + } catch (_) { + fail('test case should succeed'); + } + }); + } else { + it('should fail validation', () => { + expect.assertions(1); + try { + validateCopyObjectHeaders(request, headers); + fail('test case should fail'); + } catch (e: any) { + expect(e.name).toBe('Unknown'); + } + }); + } + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts new file mode 100644 index 00000000000..e705c86cde4 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts @@ -0,0 +1,92 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { createMultipartUpload } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const createMultipartUploadSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('createMultipartUploadSerializer', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(createMultipartUploadSuccessResponse as any), + ); + const output = await createMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + console.log(output); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(createMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + createMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/deleteObject.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/deleteObject.test.ts new file mode 100644 index 00000000000..7c111847ee7 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/deleteObject.test.ts @@ -0,0 +1,92 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { deleteObject } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const deleteObjectSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializeDeleteObjectRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(deleteObjectSuccessResponse as any), + ); + const output = await deleteObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + VersionId: 'versionId', + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(deleteObjectSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + deleteObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/getObject.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/getObject.test.ts new file mode 100644 index 00000000000..f66a507163c --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/getObject.test.ts @@ -0,0 +1,98 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { getObject } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const getObjectSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializeGetObjectRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(getObjectSuccessResponse as any), + ); + const output = await getObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + ETag: 'etag', + VersionId: 'versionId', + Body: expect.objectContaining({ + text: expect.any(Function), + blob: expect.any(Function), + json: expect.any(Function), + }), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(getObjectSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + getObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/headObject.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/headObject.test.ts new file mode 100644 index 00000000000..94295bda943 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/headObject.test.ts @@ -0,0 +1,93 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { headObject } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const headObjectSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializeHeadObjectRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(headObjectSuccessResponse as any), + ); + const output = await headObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + VersionId: 'versionId', + ETag: 'etag', + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(headObjectSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + headObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/putObject.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/putObject.test.ts new file mode 100644 index 00000000000..cd28b8f562a --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/putObject.test.ts @@ -0,0 +1,93 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { putObject } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const putObjectSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializePutObjectRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(putObjectSuccessResponse as any), + ); + const output = await putObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + ETag: 'etag', + VersionId: 'versionId', + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(putObjectSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + putObject(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/uploadPart.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/uploadPart.test.ts new file mode 100644 index 00000000000..3be212c74bb --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/uploadPart.test.ts @@ -0,0 +1,96 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { uploadPart } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const uploadPartSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('serializeUploadPartRequest', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(uploadPartSuccessResponse as any), + ); + const output = await uploadPart(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + PartNumber: 1, + UploadId: 'uploadId', + }); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + ETag: 'etag', + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(uploadPartSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + uploadPart(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + PartNumber: 1, + UploadId: 'uploadId', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/testUtils/types.ts b/packages/storage/__tests__/providers/s3/utils/client/testUtils/types.ts index b47d2ec7695..a3754b41707 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/testUtils/types.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/testUtils/types.ts @@ -3,7 +3,7 @@ import { HttpRequest } from '@aws-amplify/core/internals/aws-client-utils'; -interface MockFetchResponse { +export interface MockFetchResponse { body: BodyInit; headers: HeadersInit; status: number; diff --git a/packages/storage/__tests__/providers/s3/utils/client/utils/createRetryDecider.test.ts b/packages/storage/__tests__/providers/s3/utils/client/utils/createRetryDecider.test.ts new file mode 100644 index 00000000000..935ec823794 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/utils/createRetryDecider.test.ts @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + HttpResponse, + getRetryDecider as getDefaultRetryDecider, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { createRetryDecider } from '../../../../../../src/providers/s3/utils/client/utils'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils'); + +const mockErrorParser = jest.fn(); + +describe('createRetryDecider', () => { + const mockHttpResponse: HttpResponse = { + statusCode: 200, + headers: {}, + body: 'body' as any, + }; + + beforeEach(() => { + jest.mocked(getDefaultRetryDecider).mockReturnValue(async () => { + return { retryable: false }; + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should invoke the default retry decider', async () => { + expect.assertions(3); + const retryDecider = createRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + mockHttpResponse, + undefined, + {}, + ); + expect(getDefaultRetryDecider).toHaveBeenCalledWith(mockErrorParser); + expect(retryable).toBe(false); + expect(isCredentialsExpiredError).toBeFalsy(); + }); + + describe('handling expired token errors', () => { + const mockErrorMessage = 'Token expired'; + it.each(['RequestExpired', 'ExpiredTokenException', 'ExpiredToken'])( + 'should retry if expired credentials error name %s', + async errorName => { + expect.assertions(2); + const parsedError = { + name: errorName, + message: mockErrorMessage, + $metadata: {}, + }; + mockErrorParser.mockResolvedValue(parsedError); + const retryDecider = createRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + { ...mockHttpResponse, statusCode: 400 }, + undefined, + {}, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBe(true); + }, + ); + + it('should retry if error message indicates invalid credentials', async () => { + expect.assertions(2); + const parsedError = { + name: 'InvalidSignature', + message: 'Auth token in request is expired.', + $metadata: {}, + }; + mockErrorParser.mockResolvedValue(parsedError); + const retryDecider = createRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + { ...mockHttpResponse, statusCode: 400 }, + undefined, + {}, + ); + expect(retryable).toBe(true); + expect(isCredentialsExpiredError).toBe(true); + }); + + it('should not retry if invalid credentials error has been retried previously', async () => { + expect.assertions(2); + const parsedError = { + name: 'RequestExpired', + message: mockErrorMessage, + $metadata: {}, + }; + mockErrorParser.mockResolvedValue(parsedError); + const retryDecider = createRetryDecider(mockErrorParser); + const { retryable, isCredentialsExpiredError } = await retryDecider( + { ...mockHttpResponse, statusCode: 400 }, + undefined, + { isCredentialsExpired: true }, + ); + expect(retryable).toBe(false); + expect(isCredentialsExpiredError).toBe(true); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts b/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts new file mode 100644 index 00000000000..84cc8cf10c7 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts @@ -0,0 +1,71 @@ +import { + bothNilOrEqual, + isEqual, + isNil, + isObject, +} from '../../../../../../src/providers/s3/utils/client/utils/integrityHelpers'; + +describe('isNil', () => { + it.each([ + ['undefined', undefined, true], + ['null', null, true], + ['object', {}, false], + ['string', 'string', false], + ['empty string', '', false], + ['false', false, false], + ])('should correctly evaluate %s', (_, input, expected) => { + expect(isNil(input)).toBe(expected); + }); +}); + +describe('bothNilorEqual', () => { + it.each([ + ['both undefined', undefined, undefined, true], + ['both null', null, null, true], + ['null and undefined', null, undefined, true], + ['both equal', 'mock', 'mock', true], + ['undefined and falsy', undefined, '', false], + ['truthy and null', 'mock', null, false], + ['different strings', 'mock-1', 'mock-2', false], + ])( + 'should correctly compare %s', + (_, original: any, output: any, expected) => { + expect(bothNilOrEqual(original, output)).toBe(expected); + }, + ); +}); + +describe('Integrity Helpers Tests', () => { + describe('isObjectLike', () => { + // Generate all test cases for isObjectLike function here + test.each([ + [{}, true], + [{ a: 1 }, true], + [[1, 2, 3], false], + [null, false], + [undefined, false], + ['', false], + [1, false], + ])('isObjectLike(%p) = %p', (value, expected) => { + expect(isObject(value)).toBe(expected); + }); + }); + + describe('isEqual', () => { + test.each([ + [1, 1, true], + [1, 2, false], + [1, '1', false], + ['1', '1', true], + ['1', '2', false], + [{ a: 1 }, { a: 1 }, true], + [{ a: 1 }, { a: 2 }, false], + [{ a: 1 }, { b: 1 }, false], + [[1, 2], [1, 2], true], + [[1, 2], [2, 1], false], + [[1, 2], [1, 2, 3], false], + ])('isEqual(%p, %p) = %p', (a, b, expected) => { + expect(isEqual(a, b)).toBe(expected); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/crc32.test.ts b/packages/storage/__tests__/providers/s3/utils/crc32.test.ts new file mode 100644 index 00000000000..28058a1fc1d --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/crc32.test.ts @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + TextDecoder as TextDecoderPolyfill, + TextEncoder as TextEncoderPolyfill, +} from 'node:util'; + +import { calculateContentCRC32 } from '../../../../src/providers/s3/utils/crc32'; + +global.TextEncoder = TextEncoderPolyfill as any; +global.TextDecoder = TextDecoderPolyfill as any; + +const MB = 1024 * 1024; +const getBlob = (size: number) => new Blob(['1'.repeat(size)]); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +describe('calculate crc32', () => { + describe.each([ + { + type: 'file', + size: '4B', + data: new File(['data'], 'someName'), + expected: { + checksum: 'rfPzYw==', + checksumArrayBuffer: new Uint8Array([173, 243, 243, 99]).buffer, + seed: 2918445923, + }, + }, + { + type: 'blob', + size: '4B', + data: new Blob(['data']), + expected: { + checksum: 'rfPzYw==', + checksumArrayBuffer: new Uint8Array([173, 243, 243, 99]).buffer, + seed: 2918445923, + }, + }, + { + type: 'string', + size: '4B', + data: 'data', + expected: { + checksum: 'rfPzYw==', + checksumArrayBuffer: new Uint8Array([173, 243, 243, 99]).buffer, + seed: 2918445923, + }, + }, + { + type: 'arrayBuffer', + size: '4B', + data: encoder.encode('data').buffer, + expected: { + checksum: 'rfPzYw==', + checksumArrayBuffer: new Uint8Array([173, 243, 243, 99]).buffer, + seed: 2918445923, + }, + }, + { + type: 'arrayBufferView', + size: '4B', + data: new DataView(encoder.encode('1234 data 5678').buffer, 5, 4), + expected: { + checksum: 'rfPzYw==', + checksumArrayBuffer: new Uint8Array([173, 243, 243, 99]).buffer, + seed: 2918445923, + }, + }, + { + type: 'file', + size: '8MB', + data: new File([getBlob(8 * MB)], 'someName'), + expected: { + checksum: '/YBlgg==', + checksumArrayBuffer: new Uint8Array([253, 128, 101, 130]).buffer, + seed: 4253050242, + }, + }, + { + type: 'blob', + size: '8MB', + data: getBlob(8 * MB), + expected: { + checksum: '/YBlgg==', + checksumArrayBuffer: new Uint8Array([253, 128, 101, 130]).buffer, + seed: 4253050242, + }, + }, + { + type: 'string', + size: '8MB', + data: '1'.repeat(8 * MB), + expected: { + checksum: '/YBlgg==', + checksumArrayBuffer: new Uint8Array([253, 128, 101, 130]).buffer, + seed: 4253050242, + }, + }, + { + type: 'arrayBuffer', + size: '8MB', + data: encoder.encode('1'.repeat(8 * MB)).buffer, + expected: { + checksum: '/YBlgg==', + checksumArrayBuffer: new Uint8Array([253, 128, 101, 130]).buffer, + seed: 4253050242, + }, + }, + { + type: 'arrayBufferView', + size: '8MB', + data: encoder.encode('1'.repeat(8 * MB)), + expected: { + checksum: '/YBlgg==', + checksumArrayBuffer: new Uint8Array([253, 128, 101, 130]).buffer, + seed: 4253050242, + }, + }, + ])('output for data type of $type with size $size', ({ data, expected }) => { + it('should match expected checksum results', async () => { + const result = (await calculateContentCRC32(data))!; + expect(result.checksum).toEqual(expected.checksum); + expect(result.seed).toEqual(expected.seed); + expect(decoder.decode(result.checksumArrayBuffer)).toEqual( + decoder.decode(expected.checksumArrayBuffer), + ); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.native.test.ts b/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.native.test.ts new file mode 100644 index 00000000000..d0de37089b9 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.native.test.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + TextDecoder as TextDecoderPolyfill, + TextEncoder as TextEncoderPolyfill, +} from 'node:util'; + +import { getCombinedCrc32 } from '../../../../src/providers/s3/utils/getCombinedCrc32.native'; +import { byteLength } from '../../../../src/providers/s3/apis/internal/uploadData/byteLength'; + +global.TextEncoder = TextEncoderPolyfill as any; +global.TextDecoder = TextDecoderPolyfill as any; + +const MB = 1024 * 1024; +const getBlob = (size: number) => new Blob(['1'.repeat(size)]); +const encoder = new TextEncoder(); + +describe('calculate crc32', () => { + describe.each([ + { + type: 'file', + size: '4B', + data: new File(['data'], 'someName'), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'blob', + size: '4B', + data: new Blob(['data']), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'string', + size: '4B', + data: 'data', + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'arrayBuffer', + size: '4B', + data: new Uint8Array(encoder.encode('data')).buffer, + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'arrayBufferView', + size: '4B', + data: new DataView(encoder.encode('1234 data 5678').buffer, 5, 4), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'file', + size: '8MB', + data: new File([getBlob(8 * MB)], 'someName'), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'blob', + size: '8MB', + data: getBlob(8 * MB), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'string', + size: '8MB', + data: '1'.repeat(8 * MB), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'arrayBuffer', + size: '8MB', + data: new Uint8Array(encoder.encode('1'.repeat(8 * MB))).buffer, + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'arrayBufferView', + size: '8MB', + data: encoder.encode('1'.repeat(8 * MB)), + expected: { + checksum: 'hwOICA==-2', + }, + }, + ])('output for data type of $type with size $size', ({ data, expected }) => { + it('should match expected checksum results', async () => { + expect((await getCombinedCrc32(data, byteLength(data)))!).toEqual( + expected.checksum, + ); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.test.ts b/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.test.ts new file mode 100644 index 00000000000..299bd8d90e5 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/getCombinedCrc32.test.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + TextDecoder as TextDecoderPolyfill, + TextEncoder as TextEncoderPolyfill, +} from 'node:util'; + +import { getCombinedCrc32 } from '../../../../src/providers/s3/utils/getCombinedCrc32'; +import { byteLength } from '../../../../src/providers/s3/apis/internal/uploadData/byteLength'; + +global.TextEncoder = TextEncoderPolyfill as any; +global.TextDecoder = TextDecoderPolyfill as any; + +const MB = 1024 * 1024; +const getBlob = (size: number) => new Blob(['1'.repeat(size)]); +const encoder = new TextEncoder(); + +describe('calculate crc32', () => { + describe.each([ + { + type: 'file', + size: '4B', + data: new File(['data'], 'someName'), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'blob', + size: '4B', + data: new Blob(['data']), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'string', + size: '4B', + data: 'data', + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'arrayBuffer', + size: '4B', + data: new Uint8Array(encoder.encode('data')).buffer, + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'arrayBufferView', + size: '4B', + data: new DataView(encoder.encode('1234 data 5678').buffer, 5, 4), + expected: { + checksum: 'wu1R0Q==-1', + }, + }, + { + type: 'file', + size: '8MB', + data: new File([getBlob(8 * MB)], 'someName'), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'blob', + size: '8MB', + data: getBlob(8 * MB), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'string', + size: '8MB', + data: '1'.repeat(8 * MB), + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'arrayBuffer', + size: '8MB', + data: new Uint8Array(encoder.encode('1'.repeat(8 * MB))).buffer, + expected: { + checksum: 'hwOICA==-2', + }, + }, + { + type: 'arrayBufferView', + size: '8MB', + data: encoder.encode('1'.repeat(8 * MB)), + expected: { + checksum: 'hwOICA==-2', + }, + }, + ])('output for data type of $type with size $size', ({ data, expected }) => { + it('should match expected checksum results', async () => { + expect((await getCombinedCrc32(data, byteLength(data)))!).toEqual( + expected.checksum, + ); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/md5.native.test.ts b/packages/storage/__tests__/providers/s3/utils/md5.native.test.ts deleted file mode 100644 index ec70d0a8e14..00000000000 --- a/packages/storage/__tests__/providers/s3/utils/md5.native.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Buffer } from 'buffer'; - -import { Md5 } from '@smithy/md5-js'; - -import { calculateContentMd5 } from '../../../../src/providers/s3/utils/md5.native'; -import { toBase64 } from '../../../../src/providers/s3/utils/client/utils'; - -jest.mock('@smithy/md5-js'); -jest.mock('../../../../src/providers/s3/utils/client/utils'); -jest.mock('buffer'); - -interface MockFileReader { - error?: any; - result?: any; - onload?(): void; - onabort?(): void; - onerror?(): void; - readAsArrayBuffer?(): void; - readAsDataURL?(): void; -} - -// The FileReader in React Native 0.71 did not support `readAsArrayBuffer`. This native implementation accomodates this -// by attempting to use `readAsArrayBuffer` and changing the file reading strategy if it throws an error. -// TODO: This file should be removable when we drop support for React Native 0.71 -describe('calculateContentMd5 (native)', () => { - const stringContent = 'string-content'; - const base64data = 'base-64-data'; - const fileReaderResult = new ArrayBuffer(8); - const fileReaderBase64Result = `data:foo/bar;base64,${base64data}`; - const fileReaderError = new Error(); - // assert mocks - const mockBufferFrom = Buffer.from as jest.Mock; - const mockToBase64 = toBase64 as jest.Mock; - const mockMd5 = Md5 as jest.Mock; - // create mocks - const mockSuccessfulFileReader: MockFileReader = { - readAsArrayBuffer: jest.fn(() => { - mockSuccessfulFileReader.result = fileReaderResult; - mockSuccessfulFileReader.onload?.(); - }), - }; - const mockAbortedFileReader: MockFileReader = { - readAsArrayBuffer: jest.fn(() => { - mockAbortedFileReader.onabort?.(); - }), - }; - const mockFailedFileReader: MockFileReader = { - readAsArrayBuffer: jest.fn(() => { - mockFailedFileReader.error = fileReaderError; - mockFailedFileReader.onerror?.(); - }), - }; - const mockPartialFileReader: MockFileReader = { - readAsArrayBuffer: jest.fn(() => { - throw new Error('Not implemented'); - }), - readAsDataURL: jest.fn(() => { - mockPartialFileReader.result = fileReaderBase64Result; - mockPartialFileReader.onload?.(); - }), - }; - - beforeAll(() => { - mockBufferFrom.mockReturnValue(fileReaderResult); - }); - - afterEach(() => { - jest.clearAllMocks(); - mockMd5.mockReset(); - }); - - it.each([ - { type: 'string', content: stringContent }, - { type: 'ArrayBuffer view', content: new Uint8Array() }, - { type: 'ArrayBuffer', content: new ArrayBuffer(8) }, - ])('calculates MD5 for content type: $type', async ({ content }) => { - await calculateContentMd5(content); - const [mockMd5Instance] = mockMd5.mock.instances; - expect(mockMd5Instance.update.mock.calls[0][0]).toBe(content); - expect(mockToBase64).toHaveBeenCalled(); - }); - - it('calculates MD5 for content type: blob', async () => { - Object.defineProperty(global, 'FileReader', { - writable: true, - value: jest.fn(() => mockSuccessfulFileReader), - }); - await calculateContentMd5(new Blob([stringContent])); - const [mockMd5Instance] = mockMd5.mock.instances; - expect(mockMd5Instance.update.mock.calls[0][0]).toBe(fileReaderResult); - expect(mockSuccessfulFileReader.readAsArrayBuffer).toHaveBeenCalled(); - expect(mockToBase64).toHaveBeenCalled(); - }); - - it('rejects on file reader abort', async () => { - Object.defineProperty(global, 'FileReader', { - writable: true, - value: jest.fn(() => mockAbortedFileReader), - }); - await expect( - calculateContentMd5(new Blob([stringContent])), - ).rejects.toThrow('Read aborted'); - expect(mockAbortedFileReader.readAsArrayBuffer).toHaveBeenCalled(); - expect(mockToBase64).not.toHaveBeenCalled(); - }); - - it('rejects on file reader error', async () => { - Object.defineProperty(global, 'FileReader', { - writable: true, - value: jest.fn(() => mockFailedFileReader), - }); - await expect( - calculateContentMd5(new Blob([stringContent])), - ).rejects.toThrow(fileReaderError); - expect(mockFailedFileReader.readAsArrayBuffer).toHaveBeenCalled(); - expect(mockToBase64).not.toHaveBeenCalled(); - }); - - it('tries again using a different strategy if readAsArrayBuffer is unavailable', async () => { - Object.defineProperty(global, 'FileReader', { - writable: true, - value: jest.fn(() => mockPartialFileReader), - }); - await calculateContentMd5(new Blob([stringContent])); - const [mockMd5Instance] = mockMd5.mock.instances; - expect(mockMd5Instance.update.mock.calls[0][0]).toBe(fileReaderResult); - expect(mockPartialFileReader.readAsDataURL).toHaveBeenCalled(); - expect(mockBufferFrom).toHaveBeenCalledWith(base64data, 'base64'); - expect(mockToBase64).toHaveBeenCalled(); - }); -}); diff --git a/packages/storage/__tests__/providers/s3/utils/readFile.native.test.ts b/packages/storage/__tests__/providers/s3/utils/readFile.native.test.ts new file mode 100644 index 00000000000..cdd9aeff616 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/readFile.native.test.ts @@ -0,0 +1,119 @@ +import { Buffer } from 'buffer'; + +import { readFile } from '../../../../src/providers/s3/utils/readFile.native'; + +jest.mock('buffer', () => ({ + Buffer: { + from: jest.fn(() => new Uint8Array()), + }, +})); + +describe('readFile', () => { + let mockFileReader: any; + + beforeEach(() => { + mockFileReader = { + onload: null, + onabort: null, + onerror: null, + readAsArrayBuffer: jest.fn(), + readAsDataURL: jest.fn(), + result: null, + }; + + (global as any).FileReader = jest.fn(() => mockFileReader); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should read file as ArrayBuffer when supported', async () => { + const mockFile = new Blob(['test content']); + const mockArrayBuffer = new ArrayBuffer(8); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(mockFileReader.readAsArrayBuffer).toHaveBeenCalledWith(mockFile); + expect(result).toBe(mockArrayBuffer); + }); + + it('should fallback to readAsDataURL when readAsArrayBuffer is not supported', async () => { + const mockFile = new Blob(['test content']); + const mockBase64Data = 'base64encodeddata'; + const mockDataURL = `data:application/octet-stream;base64,${mockBase64Data}`; + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + throw new Error('readAsArrayBuffer not supported'); + }); + + mockFileReader.readAsDataURL.mockImplementation(() => { + mockFileReader.result = mockDataURL; + mockFileReader.onload(); + }); + + await readFile(mockFile); + + expect(mockFileReader.readAsArrayBuffer).toHaveBeenCalledWith(mockFile); + expect(mockFileReader.readAsDataURL).toHaveBeenCalledWith(mockFile); + expect(Buffer.from).toHaveBeenCalledWith(mockBase64Data, 'base64'); + }); + + it('should reject when read is aborted', async () => { + const mockFile = new Blob(['test content']); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.onabort(); + }); + + await expect(readFile(mockFile)).rejects.toThrow('Read aborted'); + }); + + it('should reject when an error occurs during reading', async () => { + const mockFile = new Blob(['test content']); + const mockError = new Error('Read error'); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.error = mockError; + mockFileReader.onerror(); + }); + + await expect(readFile(mockFile)).rejects.toThrow(mockError); + }); + + it('should handle empty files', async () => { + const mockFile = new Blob([]); + const mockArrayBuffer = new ArrayBuffer(0); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBe(0); + }); + + it('should handle large files', async () => { + const largeContent = 'a'.repeat(1024 * 1024 * 10); // 10MB of data + const mockFile = new Blob([largeContent]); + const mockArrayBuffer = new ArrayBuffer(1024 * 1024 * 10); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(result).toBe(mockArrayBuffer); + expect(result.byteLength).toBe(1024 * 1024 * 10); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/readFile.test.ts b/packages/storage/__tests__/providers/s3/utils/readFile.test.ts new file mode 100644 index 00000000000..81baac510fc --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/readFile.test.ts @@ -0,0 +1,90 @@ +import { readFile } from '../../../../src/providers/s3/utils/readFile'; + +describe('readFile', () => { + let mockFileReader: any; + + beforeEach(() => { + mockFileReader = { + onload: null, + onabort: null, + onerror: null, + readAsArrayBuffer: jest.fn(), + readAsDataURL: jest.fn(), + result: null, + }; + + (global as any).FileReader = jest.fn(() => mockFileReader); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should read file as ArrayBuffer when supported', async () => { + const mockFile = new Blob(['test content']); + const mockArrayBuffer = new ArrayBuffer(8); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(mockFileReader.readAsArrayBuffer).toHaveBeenCalledWith(mockFile); + expect(result).toBe(mockArrayBuffer); + }); + + it('should reject when read is aborted', async () => { + const mockFile = new Blob(['test content']); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.onabort(); + }); + + await expect(readFile(mockFile)).rejects.toThrow('Read aborted'); + }); + + it('should reject when an error occurs during reading', async () => { + const mockFile = new Blob(['test content']); + const mockError = new Error('Read error'); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.error = mockError; + mockFileReader.onerror(); + }); + + await expect(readFile(mockFile)).rejects.toThrow(mockError); + }); + + it('should handle empty files', async () => { + const mockFile = new Blob([]); + const mockArrayBuffer = new ArrayBuffer(0); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(result).toBeInstanceOf(ArrayBuffer); + expect(result.byteLength).toBe(0); + }); + + it('should handle large files', async () => { + const largeContent = 'a'.repeat(1024 * 1024 * 10); // 10MB of data + const mockFile = new Blob([largeContent]); + const mockArrayBuffer = new ArrayBuffer(1024 * 1024 * 10); + + mockFileReader.readAsArrayBuffer.mockImplementation(() => { + mockFileReader.result = mockArrayBuffer; + mockFileReader.onload(); + }); + + const result = await readFile(mockFile); + + expect(result).toBe(mockArrayBuffer); + expect(result.byteLength).toBe(1024 * 1024 * 10); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts b/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts new file mode 100644 index 00000000000..ae3c1cfa5b6 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts @@ -0,0 +1,186 @@ +import { IntegrityError } from '../../../../src/errors/IntegrityError'; +import { validateMultipartUploadXML } from '../../../../src/providers/s3/utils/validateMultipartUploadXML'; + +describe('validateMultipartUploadXML', () => { + test.each([ + { + description: 'should NOT throw an error 1 valid part', + xml: ` + + 1 + checksumValue + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: true, + }, + { + description: 'should NOT throw an error 2 valid parts', + xml: ` + + 1 + checksumValue + + + 2 + checksumValue + + `, + input: { + Parts: [ + { PartNumber: 1, ChecksumCRC32: 'checksumValue' }, + { PartNumber: 2, ChecksumCRC32: 'checksumValue' }, + ], + }, + success: true, + }, + { + description: 'should throw an error if the XML is not valid', + xml: '>InvalidXML/<', + input: {}, + success: false, + notIntegrityError: true, + }, + { + description: + 'should throw an integrity error if the XML does not contain Part', + xml: '', + input: {}, + success: false, + }, + { + description: + 'should throw an integrity error when we have more parts than sent', + xml: ` + + 1 + checksumValue + + + 2 + checksumValue + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error when we have less parts than sent', + xml: ` + + 1 + checksumValue + + `, + input: { + Parts: [ + { PartNumber: 1, ChecksumCRC32: 'checksumValue' }, + { PartNumber: 2, ChecksumCRC32: 'checksumValue' }, + ], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching PartNumber', + xml: ` + + 2 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: 'should throw an integrity error with not matching ETag', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ETag: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumCRC32', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumCRC32C', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32C: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumSHA1', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumSHA1: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumSHA256', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumSHA256: 'checksumValue' }], + }, + success: false, + }, + ])(`$description`, ({ input, xml, success, notIntegrityError }) => { + if (success) { + expect(() => { + validateMultipartUploadXML(input, xml); + }).not.toThrow(); + } else if (notIntegrityError) { + expect(() => { + validateMultipartUploadXML(input, xml); + }).toThrow(); + } else { + expect(() => { + validateMultipartUploadXML(input, xml); + }).toThrow(IntegrityError); + } + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/validateObjectUrl.test.ts b/packages/storage/__tests__/providers/s3/utils/validateObjectUrl.test.ts new file mode 100644 index 00000000000..5b751dd0ed1 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/validateObjectUrl.test.ts @@ -0,0 +1,174 @@ +import { validateObjectUrl } from '../../../../src/providers/s3/utils/validateObjectUrl'; + +describe('validateObjectUrl', () => { + const bucket = 'bucket'; + const key = 'key/eresa/rre'; + const bucketWithDots = 'bucket.with.dots'; + const objectContainingUrl = new URL( + `https://bucket.s3.amz.com/${key}?params=params`, + ); + const objectContainingUrlPathStyle = new URL( + `https://s3.amz.com/bucket/${key}?params=params`, + ); + const objectContainingUrlWithDots = new URL( + `https://s3.amz.com/bucket.with.dots/${key}?params=params`, + ); + + test.each([ + { + description: 'bucket without dots', + input: { + bucketName: bucket, + key, + objectContainingUrl, + }, + success: true, + }, + { + description: 'bucket without dots path style url', + input: { + bucketName: bucket, + key, + objectContainingUrl: objectContainingUrlPathStyle, + }, + success: true, + }, + { + description: 'bucket with dots', + input: { + bucketName: bucketWithDots, + key, + objectContainingUrl: objectContainingUrlWithDots, + }, + success: true, + }, + { + description: 'directory bucket', + input: { + bucketName: 'bucket--use1-az2--x-s3', + key, + objectContainingUrl: new URL( + `https://bucket--use1-az2--x-s3.s3.amz.com/${key}?params=params`, + ), + }, + success: true, + }, + { + description: 'bucket without dots, wrong presigned url', + input: { + bucketName: bucket, + key, + objectContainingUrl: objectContainingUrlWithDots, + }, + success: false, + }, + { + description: 'bucket with dots, wrong presigned url', + input: { + bucketName: bucketWithDots, + key, + objectContainingUrl, + }, + success: false, + }, + { + description: 'bucket and key equal', + input: { + bucketName: bucket, + key: bucket, + objectContainingUrl: new URL( + 'https://bucket.s3.amz.com/bucket?params=params', + ), + }, + success: true, + }, + { + description: 'bucket repeated in url', + input: { + bucketName: bucket, + key, + objectContainingUrl: new URL( + `https://bucketbucket.s3.amz.com/${key}?params=params`, + ), + }, + success: false, + }, + { + description: 'bucket uppercase and presigned lowercase', + input: { + bucketName: 'BUCKET', + key, + objectContainingUrl: new URL( + `https://bucket.s3.amz.com/${key}?params=params`, + ), + }, + success: false, + }, + { + description: 'bucket with dots uppercase and presigned lowercase', + input: { + bucketName: 'B.U.C.K.E.T', + key, + objectContainingUrl: new URL( + `https://s3.amz.com/b.u.c.k.e.t/${key}?params=params`, + ), + }, + success: false, + }, + { + description: 'key uppercase and presigned lowercase', + input: { + bucketName: bucket, + key: 'KEY', + objectContainingUrl: new URL( + 'https://bucket.s3.amz.com/bucket?params=params', + ), + }, + success: false, + }, + { + description: 'key lowercase and presigned uppercase', + input: { + bucketName: bucket, + key: 'key', + objectContainingUrl: new URL( + `https://bucket.s3.amz.com/${key.toUpperCase()}?params=params`, + ), + }, + success: false, + }, + { + description: 'missing bucket', + input: { key, objectContainingUrl }, + success: false, + }, + { + description: 'missing key', + input: { bucketName: bucket, objectContainingUrl }, + success: false, + }, + { + description: 'missing objectContainingUrl', + input: { bucketName: bucket, key, objectContainingUrl: undefined }, + success: false, + }, + ])(`$description`, ({ input, success }) => { + if (success) { + expect(() => { + validateObjectUrl({ + bucketName: input.bucketName, + key: input.key, + objectURL: input.objectContainingUrl, + }); + }).not.toThrow(); + } else { + expect(() => { + validateObjectUrl({ + bucketName: input.bucketName, + key: input.key, + objectURL: input.objectContainingUrl, + }); + }).toThrow('An unknown error has occurred.'); + } + }); +}); diff --git a/packages/storage/internals/package.json b/packages/storage/internals/package.json new file mode 100644 index 00000000000..169011166f3 --- /dev/null +++ b/packages/storage/internals/package.json @@ -0,0 +1,7 @@ +{ + "name": "@aws-amplify/storage/internals", + "types": "../dist/esm/internals/index.d.ts", + "main": "../dist/cjs/internals/index.js", + "module": "../dist/esm/internals/index.mjs", + "sideEffects": false +} diff --git a/packages/storage/package.json b/packages/storage/package.json index 2243eed63d1..9edd4ebb72b 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,6 +1,6 @@ { "name": "@aws-amplify/storage", - "version": "6.6.16", + "version": "6.7.1", "description": "Storage category of aws-amplify", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.mjs", @@ -40,6 +40,9 @@ "s3": [ "./dist/esm/providers/s3/index.d.ts" ], + "internals": [ + "./dist/esm/internals/index.d.ts" + ], "server": [ "./dist/esm/server.d.ts" ], @@ -61,6 +64,7 @@ "files": [ "dist/cjs", "dist/esm", + "internals", "src", "server", "s3" @@ -69,6 +73,7 @@ "@aws-sdk/types": "3.398.0", "@smithy/md5-js": "2.0.7", "buffer": "4.9.2", + "crc-32": "1.2.2", "fast-xml-parser": "^4.4.1", "tslib": "^2.5.0" }, @@ -79,6 +84,11 @@ "require": "./dist/cjs/index.js", "react-native": "./src/index.ts" }, + "./internals": { + "types": "./dist/esm/internals/index.d.ts", + "import": "./dist/esm/internals/index.mjs", + "require": "./dist/cjs/internals/index.js" + }, "./server": { "types": "./dist/esm/server.d.ts", "import": "./dist/esm/server.mjs", @@ -101,8 +111,9 @@ "@aws-amplify/core": "^6.1.0" }, "devDependencies": { - "@aws-amplify/core": "6.5.3", + "@aws-amplify/core": "6.7.0", "@aws-amplify/react-native": "1.1.6", + "@types/node": "20.14.12", "typescript": "5.0.2" } } diff --git a/packages/storage/src/errors/IntegrityError.ts b/packages/storage/src/errors/IntegrityError.ts new file mode 100644 index 00000000000..c3c973e0b73 --- /dev/null +++ b/packages/storage/src/errors/IntegrityError.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + AmplifyErrorCode, + AmplifyErrorParams, +} from '@aws-amplify/core/internals/utils'; + +import { StorageError } from './StorageError'; + +export class IntegrityError extends StorageError { + constructor( + params: AmplifyErrorParams = { + name: AmplifyErrorCode.Unknown, + message: 'An unknown error has occurred.', + recoverySuggestion: + 'This may be a bug. Please reach out to library authors.', + }, + ) { + super(params); + + // TODO: Delete the following 2 lines after we change the build target to >= es2015 + this.constructor = IntegrityError; + Object.setPrototypeOf(this, IntegrityError.prototype); + } +} diff --git a/packages/storage/src/errors/constants.ts b/packages/storage/src/errors/constants.ts new file mode 100644 index 00000000000..ca127c2e623 --- /dev/null +++ b/packages/storage/src/errors/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const INVALID_STORAGE_INPUT = 'InvalidStorageInput'; diff --git a/packages/storage/src/errors/types/validation.ts b/packages/storage/src/errors/types/validation.ts index 7fb1bd89765..a56662adec4 100644 --- a/packages/storage/src/errors/types/validation.ts +++ b/packages/storage/src/errors/types/validation.ts @@ -17,10 +17,17 @@ export enum StorageValidationErrorCode { InvalidCopyOperationStorageBucket = 'InvalidCopyOperationStorageBucket', InvalidStorageOperationPrefixInput = 'InvalidStorageOperationPrefixInput', InvalidStorageOperationInput = 'InvalidStorageOperationInput', + InvalidAWSAccountID = 'InvalidAWSAccountID', InvalidStoragePathInput = 'InvalidStoragePathInput', InvalidUploadSource = 'InvalidUploadSource', ObjectIsTooLarge = 'ObjectIsTooLarge', UrlExpirationMaxLimitExceed = 'UrlExpirationMaxLimitExceed', + InvalidLocationCredentialsCacheSize = 'InvalidLocationCredentialsCacheSize', + LocationCredentialsStoreDestroyed = 'LocationCredentialsStoreDestroyed', + InvalidS3Uri = 'InvalidS3Uri', + InvalidCustomEndpoint = 'InvalidCustomEndpoint', + ForcePathStyleEndpointNotSupported = 'ForcePathStyleEndpointNotSupported', + DnsIncompatibleBucketName = 'DnsIncompatibleBucketName', } export const validationErrorMap: AmplifyErrorMap = { @@ -66,12 +73,24 @@ export const validationErrorMap: AmplifyErrorMap = { message: 'Path or key parameter must be specified in the input. Both can not be specified at the same time.', }, + [StorageValidationErrorCode.InvalidAWSAccountID]: { + message: 'Invalid AWS account ID was provided.', + }, [StorageValidationErrorCode.InvalidStorageOperationPrefixInput]: { message: 'Both path and prefix can not be specified at the same time.', }, [StorageValidationErrorCode.InvalidStoragePathInput]: { message: 'Input `path` does not allow a leading slash (/).', }, + [StorageValidationErrorCode.InvalidLocationCredentialsCacheSize]: { + message: 'locationCredentialsCacheSize must be a positive integer.', + }, + [StorageValidationErrorCode.LocationCredentialsStoreDestroyed]: { + message: `Location-specific credentials store has been destroyed.`, + }, + [StorageValidationErrorCode.InvalidS3Uri]: { + message: 'Invalid S3 URI.', + }, [StorageValidationErrorCode.InvalidStorageBucket]: { message: 'Unable to lookup bucket from provided name in Amplify configuration.', @@ -79,4 +98,13 @@ export const validationErrorMap: AmplifyErrorMap = { [StorageValidationErrorCode.InvalidCopyOperationStorageBucket]: { message: 'Missing bucket option in either source or destination.', }, + [StorageValidationErrorCode.InvalidCustomEndpoint]: { + message: 'Invalid S3 custom endpoint.', + }, + [StorageValidationErrorCode.ForcePathStyleEndpointNotSupported]: { + message: 'Path style URLs are not supported with S3 Transfer Acceleration.', + }, + [StorageValidationErrorCode.DnsIncompatibleBucketName]: { + message: `The bucket name isn't DNS compatible.`, + }, }; diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 45bf9734a66..a2f040d5766 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -53,3 +53,5 @@ export { TransferProgressEvent } from './types'; export { isCancelError } from './errors/CanceledError'; export { StorageError } from './errors/StorageError'; + +export { DEFAULT_PART_SIZE } from './providers/s3/utils/constants'; diff --git a/packages/storage/src/internals/apis/copy.ts b/packages/storage/src/internals/apis/copy.ts new file mode 100644 index 00000000000..3286ab99462 --- /dev/null +++ b/packages/storage/src/internals/apis/copy.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { copy as copyInternal } from '../../providers/s3/apis/internal/copy'; +import { CopyInput } from '../types/inputs'; +import { CopyOutput } from '../types/outputs'; + +/** + * @internal + */ +export const copy = (input: CopyInput) => + copyInternal(Amplify, { + source: { + path: input.source.path, + bucket: input.source.bucket, + eTag: input.source.eTag, + notModifiedSince: input.source.notModifiedSince, + expectedBucketOwner: input.source.expectedBucketOwner, + }, + destination: { + path: input.destination.path, + bucket: input.destination.bucket, + expectedBucketOwner: input.destination.expectedBucketOwner, + }, + options: { + // Advanced options + locationCredentialsProvider: input.options?.locationCredentialsProvider, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `copyInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as Promise; diff --git a/packages/storage/src/internals/apis/downloadData.ts b/packages/storage/src/internals/apis/downloadData.ts new file mode 100644 index 00000000000..bd862d9d9b4 --- /dev/null +++ b/packages/storage/src/internals/apis/downloadData.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { downloadData as downloadDataInternal } from '../../providers/s3/apis/internal/downloadData'; +import { DownloadDataInput } from '../types/inputs'; +import { DownloadDataOutput } from '../types/outputs'; + +/** + * @internal + */ +export const downloadData = (input: DownloadDataInput): DownloadDataOutput => + downloadDataInternal({ + path: input.path, + options: { + useAccelerateEndpoint: input?.options?.useAccelerateEndpoint, + bucket: input?.options?.bucket, + locationCredentialsProvider: input?.options?.locationCredentialsProvider, + bytesRange: input?.options?.bytesRange, + onProgress: input?.options?.onProgress, + expectedBucketOwner: input?.options?.expectedBucketOwner, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `downloadDataInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as DownloadDataOutput; diff --git a/packages/storage/src/internals/apis/getDataAccess.ts b/packages/storage/src/internals/apis/getDataAccess.ts new file mode 100644 index 00000000000..070bf617078 --- /dev/null +++ b/packages/storage/src/internals/apis/getDataAccess.ts @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyErrorCode, + StorageAction, +} from '@aws-amplify/core/internals/utils'; +import { CredentialsProviderOptions } from '@aws-amplify/core/internals/aws-client-utils'; + +import { getStorageUserAgentValue } from '../../providers/s3/utils/userAgent'; +import { getDataAccess as getDataAccessClient } from '../../providers/s3/utils/client/s3control'; +import { StorageError } from '../../errors/StorageError'; +import { GetDataAccessInput } from '../types/inputs'; +import { GetDataAccessOutput } from '../types/outputs'; +import { logger } from '../../utils'; +import { DEFAULT_CRED_TTL } from '../utils/constants'; + +/** + * @internal + */ +export const getDataAccess = async ( + input: GetDataAccessInput, +): Promise => { + const targetType = input.scope.endsWith('*') ? undefined : 'Object'; + const clientCredentialsProvider = async ( + options?: CredentialsProviderOptions, + ) => { + const { credentials } = await input.credentialsProvider(options); + + return credentials; + }; + + const result = await getDataAccessClient( + { + credentials: clientCredentialsProvider, + customEndpoint: input.customEndpoint, + region: input.region, + userAgentValue: getStorageUserAgentValue(StorageAction.GetDataAccess), + }, + { + AccountId: input.accountId, + Target: input.scope, + Permission: input.permission, + TargetType: targetType, + DurationSeconds: DEFAULT_CRED_TTL, + }, + ); + + const grantCredentials = result.Credentials; + + // Ensure that S3 returned credentials (this shouldn't happen) + if ( + !grantCredentials || + !grantCredentials.AccessKeyId || + !grantCredentials.SecretAccessKey || + !grantCredentials.SessionToken || + !grantCredentials.Expiration + ) { + throw new StorageError({ + name: AmplifyErrorCode.Unknown, + message: 'Service did not return valid temporary credentials.', + }); + } else { + logger.debug(`Retrieved credentials for: ${result.MatchedGrantTarget}`); + } + + const { + AccessKeyId: accessKeyId, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + Expiration: expiration, + } = grantCredentials; + + return { + credentials: { + accessKeyId, + secretAccessKey, + sessionToken, + expiration, + }, + scope: result.MatchedGrantTarget, + }; +}; diff --git a/packages/storage/src/internals/apis/getProperties.ts b/packages/storage/src/internals/apis/getProperties.ts new file mode 100644 index 00000000000..213e184edae --- /dev/null +++ b/packages/storage/src/internals/apis/getProperties.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { getProperties as getPropertiesInternal } from '../../providers/s3/apis/internal/getProperties'; +import { GetPropertiesInput } from '../types/inputs'; +import { GetPropertiesOutput } from '../types/outputs'; + +/** + * @internal + */ +export const getProperties = ( + input: GetPropertiesInput, +): Promise => + getPropertiesInternal(Amplify, { + path: input.path, + options: { + useAccelerateEndpoint: input?.options?.useAccelerateEndpoint, + bucket: input?.options?.bucket, + locationCredentialsProvider: input?.options?.locationCredentialsProvider, + expectedBucketOwner: input?.options?.expectedBucketOwner, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `getPropertiesInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as Promise; diff --git a/packages/storage/src/internals/apis/getUrl.ts b/packages/storage/src/internals/apis/getUrl.ts new file mode 100644 index 00000000000..ef82f107c67 --- /dev/null +++ b/packages/storage/src/internals/apis/getUrl.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { getUrl as getUrlInternal } from '../../providers/s3/apis/internal/getUrl'; +import { GetUrlInput } from '../types/inputs'; +import { GetUrlOutput } from '../types/outputs'; + +/** + * @internal + */ +export const getUrl = (input: GetUrlInput) => + getUrlInternal(Amplify, { + path: input.path, + options: { + useAccelerateEndpoint: input?.options?.useAccelerateEndpoint, + bucket: input?.options?.bucket, + validateObjectExistence: input?.options?.validateObjectExistence, + expiresIn: input?.options?.expiresIn, + contentDisposition: input?.options?.contentDisposition, + contentType: input?.options?.contentType, + expectedBucketOwner: input?.options?.expectedBucketOwner, + + // Advanced options + locationCredentialsProvider: input?.options?.locationCredentialsProvider, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `getPropertiesInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as Promise; diff --git a/packages/storage/src/internals/apis/list.ts b/packages/storage/src/internals/apis/list.ts new file mode 100644 index 00000000000..60c9184bd7f --- /dev/null +++ b/packages/storage/src/internals/apis/list.ts @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { list as listInternal } from '../../providers/s3/apis/internal/list'; +import { ListAllInput, ListInput, ListPaginateInput } from '../types/inputs'; +import { + ListAllWithPathOutput, + ListPaginateWithPathOutput, +} from '../../providers/s3'; +import { ListOutput } from '../types/outputs'; + +/** + * @internal + */ +export function list(input: ListAllInput): Promise; +/** + * @internal + */ +export function list( + input: ListPaginateInput, +): Promise; +/** + * @internal + */ +export function list(input: ListInput): Promise { + return listInternal(Amplify, { + path: input.path, + options: { + bucket: input.options?.bucket, + subpathStrategy: input.options?.subpathStrategy, + useAccelerateEndpoint: input.options?.useAccelerateEndpoint, + listAll: input.options?.listAll, + expectedBucketOwner: input.options?.expectedBucketOwner, + + // Pagination options + nextToken: (input as ListPaginateInput).options?.nextToken, + pageSize: (input as ListPaginateInput).options?.pageSize, + // Advanced options + locationCredentialsProvider: input.options?.locationCredentialsProvider, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `listInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + } as ListInput) as Promise; +} diff --git a/packages/storage/src/internals/apis/listCallerAccessGrants.ts b/packages/storage/src/internals/apis/listCallerAccessGrants.ts new file mode 100644 index 00000000000..47fee1c051a --- /dev/null +++ b/packages/storage/src/internals/apis/listCallerAccessGrants.ts @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageAction } from '@aws-amplify/core/internals/utils'; +import { CredentialsProviderOptions } from '@aws-amplify/core/internals/aws-client-utils'; + +import { logger } from '../../utils'; +import { listCallerAccessGrants as listCallerAccessGrantsClient } from '../../providers/s3/utils/client/s3control'; +import { StorageError } from '../../errors/StorageError'; +import { getStorageUserAgentValue } from '../../providers/s3/utils/userAgent'; +import { LocationType } from '../types/common'; +import { LocationAccess } from '../types/credentials'; +import { ListCallerAccessGrantsInput } from '../types/inputs'; +import { ListCallerAccessGrantsOutput } from '../types/outputs'; +import { MAX_PAGE_SIZE } from '../utils/constants'; + +/** + * @internal + */ +export const listCallerAccessGrants = async ( + input: ListCallerAccessGrantsInput, +): Promise => { + const { + credentialsProvider, + accountId, + region, + nextToken, + pageSize, + customEndpoint, + } = input; + + logger.debug(`listing available locations from account ${input.accountId}`); + + if (!!pageSize && pageSize > MAX_PAGE_SIZE) { + logger.debug(`defaulting pageSize to ${MAX_PAGE_SIZE}.`); + } + + const clientCredentialsProvider = async ( + options?: CredentialsProviderOptions, + ) => { + const { credentials } = await credentialsProvider(options); + + return credentials; + }; + + const { CallerAccessGrantsList, NextToken } = + await listCallerAccessGrantsClient( + { + credentials: clientCredentialsProvider, + customEndpoint, + region, + userAgentValue: getStorageUserAgentValue( + StorageAction.ListCallerAccessGrants, + ), + }, + { + AccountId: accountId, + NextToken: nextToken, + MaxResults: pageSize ?? MAX_PAGE_SIZE, + AllowedByApplication: true, + }, + ); + + const accessGrants: LocationAccess[] = + CallerAccessGrantsList?.map(grant => { + assertGrantScope(grant.GrantScope); + + return { + scope: grant.GrantScope, + permission: grant.Permission!, + type: parseGrantType(grant.GrantScope!), + }; + }) ?? []; + + return { + locations: accessGrants, + nextToken: NextToken, + }; +}; + +const parseGrantType = (grantScope: string): LocationType => { + const bucketScopeReg = /^s3:\/\/(.*)\/\*$/; + const possibleBucketName = grantScope.match(bucketScopeReg)?.[1]; + if (!grantScope.endsWith('*')) { + return 'OBJECT'; + } else if ( + grantScope.endsWith('/*') && + possibleBucketName && + possibleBucketName.indexOf('/') === -1 + ) { + return 'BUCKET'; + } else { + return 'PREFIX'; + } +}; + +function assertGrantScope(value: unknown): asserts value is string { + if (typeof value !== 'string' || !value.startsWith('s3://')) { + throw new StorageError({ + name: 'InvalidGrantScope', + message: `Expected a valid grant scope, got ${value}`, + }); + } +} diff --git a/packages/storage/src/internals/apis/listPaths/getHighestPrecedenceUserGroup.ts b/packages/storage/src/internals/apis/listPaths/getHighestPrecedenceUserGroup.ts new file mode 100644 index 00000000000..82303a9d0d8 --- /dev/null +++ b/packages/storage/src/internals/apis/listPaths/getHighestPrecedenceUserGroup.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type UserGroupConfig = Record>[]; + +/** + * Given the Cognito user groups associated to current user session + * and all the user group configurations defined by backend. + * This function returns the user group with the highest precedence. + * Reference: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html#assigning-precedence-values-to-groups + * + * @param {UserGroupConfig} userGroupsFromConfig - User groups with their precedence values based on Amplify outputs. + * @param {string[]} currentUserGroups - The list of current user's groups. + * @returns {string | undefined} - The user group with the highest precedence (0), or undefined if no matching group is found. + */ +export const getHighestPrecedenceUserGroup = ( + userGroupsFromConfig?: UserGroupConfig, + currentUserGroups?: string[], +): string | undefined => { + if (userGroupsFromConfig && currentUserGroups) { + const precedenceMap = userGroupsFromConfig.reduce( + (acc, group) => { + Object.entries(group).forEach(([key, value]) => { + acc[key] = value.precedence; + }); + + return acc; + }, + {} as Record, + ); + + const sortedUserGroup = currentUserGroups + .filter(group => + Object.prototype.hasOwnProperty.call(precedenceMap, group), + ) + .sort((a, b) => precedenceMap[a] - precedenceMap[b]); + + return sortedUserGroup[0]; + } + + return undefined; +}; diff --git a/packages/storage/src/internals/apis/listPaths/index.ts b/packages/storage/src/internals/apis/listPaths/index.ts new file mode 100644 index 00000000000..cf04534bf0f --- /dev/null +++ b/packages/storage/src/internals/apis/listPaths/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { listPaths } from './listPaths'; diff --git a/packages/storage/src/internals/apis/listPaths/listPaths.ts b/packages/storage/src/internals/apis/listPaths/listPaths.ts new file mode 100644 index 00000000000..2add687dfa4 --- /dev/null +++ b/packages/storage/src/internals/apis/listPaths/listPaths.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; + +import { ListPathsOutput } from '../../types/credentials'; + +import { resolveLocationsForCurrentSession } from './resolveLocationsForCurrentSession'; +import { getHighestPrecedenceUserGroup } from './getHighestPrecedenceUserGroup'; + +export const listPaths = async (): Promise => { + const { buckets } = Amplify.getConfig().Storage!.S3!; + const { groups } = Amplify.getConfig().Auth!.Cognito; + + if (!buckets) { + return { locations: [] }; + } + + const { tokens, identityId } = await fetchAuthSession(); + const currentUserGroups = tokens?.accessToken.payload['cognito:groups'] as + | string[] + | undefined; + + const userGroupToUse = getHighestPrecedenceUserGroup( + groups, + currentUserGroups, + ); + + const locations = resolveLocationsForCurrentSession({ + buckets, + isAuthenticated: !!tokens, + identityId, + userGroup: userGroupToUse, + }); + + return { locations }; +}; diff --git a/packages/storage/src/internals/apis/listPaths/resolveLocationsForCurrentSession.ts b/packages/storage/src/internals/apis/listPaths/resolveLocationsForCurrentSession.ts new file mode 100644 index 00000000000..49121692ef3 --- /dev/null +++ b/packages/storage/src/internals/apis/listPaths/resolveLocationsForCurrentSession.ts @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { PathAccess } from '../../types/credentials'; +import { BucketInfo } from '../../../providers/s3/types/options'; +import { ENTITY_IDENTITY_URL } from '../../utils/constants'; +import { StorageAccess } from '../../types/common'; + +const resolvePermissions = ( + accessRule: Record, + isAuthenticated: boolean, + groups?: string, +) => { + if (!isAuthenticated) { + return { + permission: accessRule.guest, + }; + } + if (groups) { + const selectedKey = Object.keys(accessRule).find(access => + access.includes(groups), + ); + + return { + permission: selectedKey ? accessRule[selectedKey] : undefined, + }; + } + + return { + permission: accessRule.authenticated, + }; +}; + +export const resolveLocationsForCurrentSession = ({ + buckets, + isAuthenticated, + identityId, + userGroup, +}: { + buckets: Record; + isAuthenticated: boolean; + identityId?: string; + userGroup?: string; +}): PathAccess[] => { + const locations: PathAccess[] = []; + + for (const [, bucketInfo] of Object.entries(buckets)) { + const { bucketName, paths } = bucketInfo; + if (!paths) { + continue; + } + + for (const [path, accessRules] of Object.entries(paths)) { + const shouldIncludeEntityIdPath = + !userGroup && + path.includes(ENTITY_IDENTITY_URL) && + isAuthenticated && + identityId; + + if (shouldIncludeEntityIdPath) { + locations.push({ + type: 'PREFIX', + permission: accessRules.entityidentity as StorageAccess[], + bucket: bucketName, + prefix: path.replace(ENTITY_IDENTITY_URL, identityId), + }); + } + + const location = { + type: 'PREFIX', + ...resolvePermissions(accessRules, isAuthenticated, userGroup), + bucket: bucketName, + prefix: path, + }; + + if (location.permission) locations.push(location as PathAccess); + } + } + + return locations; +}; diff --git a/packages/storage/src/internals/apis/remove.ts b/packages/storage/src/internals/apis/remove.ts new file mode 100644 index 00000000000..96530325e2c --- /dev/null +++ b/packages/storage/src/internals/apis/remove.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { remove as removeInternal } from '../../providers/s3/apis/internal/remove'; +import { RemoveInput } from '../types/inputs'; +import { RemoveOutput } from '../types/outputs'; + +/** + * @internal + */ +export const remove = (input: RemoveInput): Promise => + removeInternal(Amplify, { + path: input.path, + options: { + useAccelerateEndpoint: input?.options?.useAccelerateEndpoint, + bucket: input?.options?.bucket, + expectedBucketOwner: input?.options?.expectedBucketOwner, + locationCredentialsProvider: input?.options?.locationCredentialsProvider, + customEndpoint: input?.options?.customEndpoint, + }, + // Type casting is necessary because `removeInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as Promise; diff --git a/packages/storage/src/internals/apis/uploadData.ts b/packages/storage/src/internals/apis/uploadData.ts new file mode 100644 index 00000000000..44456edf510 --- /dev/null +++ b/packages/storage/src/internals/apis/uploadData.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UploadDataInput } from '../types/inputs'; +import { UploadDataOutput } from '../types/outputs'; +import { uploadData as uploadDataInternal } from '../../providers/s3/apis/internal/uploadData'; + +/** + * @internal + */ +export const uploadData = (input: UploadDataInput) => { + const { data, path, options } = input; + + return uploadDataInternal({ + path, + data, + options: { + useAccelerateEndpoint: options?.useAccelerateEndpoint, + bucket: options?.bucket, + onProgress: options?.onProgress, + contentDisposition: options?.contentDisposition, + contentEncoding: options?.contentEncoding, + contentType: options?.contentType, + metadata: options?.metadata, + preventOverwrite: options?.preventOverwrite, + expectedBucketOwner: options?.expectedBucketOwner, + checksumAlgorithm: options?.checksumAlgorithm, + + // Advanced options + locationCredentialsProvider: options?.locationCredentialsProvider, + customEndpoint: options?.customEndpoint, + }, + // Type casting is necessary because `uploadDataInternal` supports both Gen1 and Gen2 signatures, but here + // given in input can only be Gen2 signature, the return can only ben Gen2 signature. + }) as UploadDataOutput; +}; diff --git a/packages/storage/src/internals/index.ts b/packages/storage/src/internals/index.ts new file mode 100644 index 00000000000..197b899b424 --- /dev/null +++ b/packages/storage/src/internals/index.ts @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { StorageSubpathStrategy } from '../types/options'; + +export { Permission, LocationType, StorageAccess } from './types/common'; + +/* +Internal APIs +*/ +export { + GetDataAccessInput, + ListCallerAccessGrantsInput, + GetPropertiesInput, + GetUrlInput, + CopyInput, + ListInput, + ListAllInput, + ListPaginateInput, + RemoveInput, + UploadDataInput, + DownloadDataInput, +} from './types/inputs'; +export { + GetDataAccessOutput, + ListCallerAccessGrantsOutput, + GetPropertiesOutput, + GetUrlOutput, + RemoveOutput, + UploadDataOutput, + DownloadDataOutput, + ListOutput, + CopyOutput, +} from './types/outputs'; + +export { getDataAccess } from './apis/getDataAccess'; +export { listCallerAccessGrants } from './apis/listCallerAccessGrants'; +export { list } from './apis/list'; +export { getProperties } from './apis/getProperties'; +export { getUrl } from './apis/getUrl'; +export { remove } from './apis/remove'; +export { uploadData } from './apis/uploadData'; +export { downloadData } from './apis/downloadData'; +export { copy } from './apis/copy'; + +/** Default Auth exports */ +export { listPaths } from './apis/listPaths'; + +/* +CredentialsStore exports +*/ +export { + CredentialsLocation, + ListLocations, + LocationAccess, + LocationCredentials, + ListLocationsInput, + ListLocationsOutput, + CredentialsProvider, + ListPathsOutput, +} from './types/credentials'; + +export { + AWSTemporaryCredentials, + LocationCredentialsProvider, +} from '../providers/s3/types/options'; + +/** + * Internal util functions + */ +export { assertValidationError } from '../errors/utils/assertValidationError'; + +/** + * Utility types + */ +export { + StorageValidationErrorCode, + validationErrorMap, +} from '../errors/types/validation'; diff --git a/packages/storage/src/internals/types/common.ts b/packages/storage/src/internals/types/common.ts new file mode 100644 index 00000000000..32671db5660 --- /dev/null +++ b/packages/storage/src/internals/types/common.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * @internal + */ +export type Permission = 'READ' | 'READWRITE' | 'WRITE'; + +/** + * @internal + */ +export type LocationType = 'BUCKET' | 'PREFIX' | 'OBJECT'; + +/** + * @internal + */ +export type Privilege = 'Default' | 'Minimal'; + +/** + * @internal + */ +export type PrefixType = 'Object'; + +/** + * @internal + */ +export type StorageAccess = 'read' | 'get' | 'list' | 'write' | 'delete'; diff --git a/packages/storage/src/internals/types/credentials.ts b/packages/storage/src/internals/types/credentials.ts new file mode 100644 index 00000000000..9b825650e62 --- /dev/null +++ b/packages/storage/src/internals/types/credentials.ts @@ -0,0 +1,102 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AWSTemporaryCredentials, + LocationCredentialsProvider, +} from '../../providers/s3/types/options'; + +import { LocationType, Permission, StorageAccess } from './common'; + +/** + * @internal + */ +export type CredentialsProvider = LocationCredentialsProvider; + +export interface LocationCredentials extends Partial { + /** + * AWS credentials which can be used to access the specified location. + */ + readonly credentials: AWSTemporaryCredentials; +} + +/** + * @internal + */ +export interface ListLocationsInput { + pageSize?: number; + nextToken?: string; +} + +/** + * @internal + */ +export interface ListLocationsOutput { + locations: LocationAccess[]; + nextToken?: string; +} + +/** + * @internal + */ +export type ListLocations = ( + input?: ListLocationsInput, +) => Promise; + +/** + * @internal + */ +export interface LocationScope { + /** + * Scope of storage location. For S3 service, it's the S3 path of the data to + * which the access is granted. It can be in following formats: + * + * @example Bucket 's3:///*' + * @example Prefix 's3:///*' + * @example Object 's3:////' + */ + readonly scope: string; +} + +/** + * @internal + */ +export interface CredentialsLocation extends LocationScope { + /** + * The type of access granted to your Storage data. Can be either of READ, + * WRITE or READWRITE + */ + readonly permission: Permission; +} + +/** + * @internal + */ +export interface LocationAccess extends CredentialsLocation { + /** + * Parse location type parsed from scope format: + * * BUCKET: `'s3:///*'` + * * PREFIX: `'s3:///*'` + * * OBJECT: `'s3:////'` + */ + readonly type: LocationType; +} + +/** + * @internal + */ +export interface PathAccess { + /** The Amplify backend mandates that all paths conclude with '/*', + * which means the only applicable type in this context is 'PREFIX'. */ + type: 'PREFIX'; + permission: StorageAccess[]; + bucket: string; + prefix: string; +} + +/** + * @internal + */ +export interface ListPathsOutput { + locations: PathAccess[]; +} diff --git a/packages/storage/src/internals/types/inputs.ts b/packages/storage/src/internals/types/inputs.ts new file mode 100644 index 00000000000..a79d171ff08 --- /dev/null +++ b/packages/storage/src/internals/types/inputs.ts @@ -0,0 +1,142 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + StorageCopyInputWithPath, + StorageOperationInputWithPath, + StorageOperationOptionsInput, +} from '../../types/inputs'; +import { + CopyWithPathInput, + DownloadDataWithPathInput, + GetPropertiesWithPathInput, + GetUrlWithPathInput, + ListAllWithPathInput, + ListPaginateWithPathInput, + RemoveWithPathInput, + UploadDataWithPathInput, +} from '../../providers/s3'; + +import { CredentialsProvider, ListLocationsInput } from './credentials'; +import { Permission, PrefixType, Privilege } from './common'; + +/** + * @internal + */ +export interface ListCallerAccessGrantsInput extends ListLocationsInput { + accountId: string; + credentialsProvider: CredentialsProvider; + customEndpoint?: string; + region: string; +} + +/** + * @internal + */ +export interface GetDataAccessInput { + accountId: string; + credentialsProvider: CredentialsProvider; + customEndpoint?: string; + durationSeconds?: number; + permission: Permission; + prefixType?: PrefixType; + privilege?: Privilege; + region: string; + scope: string; +} + +export interface AdvancedOptions { + locationCredentialsProvider?: CredentialsProvider; + customEndpoint?: string; +} + +/** + * @internal + */ +export type ListAllInput = ExtendInputWithAdvancedOptions< + ListAllWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type ListPaginateInput = ExtendInputWithAdvancedOptions< + ListPaginateWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type ListInput = ListAllInput | ListPaginateInput; + +/** + * @internal + */ +export type RemoveInput = ExtendInputWithAdvancedOptions< + RemoveWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type GetPropertiesInput = ExtendInputWithAdvancedOptions< + GetPropertiesWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type GetUrlInput = ExtendInputWithAdvancedOptions< + GetUrlWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type CopyInput = ExtendCopyInputWithAdvancedOptions< + CopyWithPathInput, + AdvancedOptions +>; + +export type UploadDataInput = ExtendInputWithAdvancedOptions< + UploadDataWithPathInput, + AdvancedOptions +>; + +/** + * @internal + */ +export type DownloadDataInput = ExtendInputWithAdvancedOptions< + DownloadDataWithPathInput, + AdvancedOptions +>; + +/** + * Generic types that extend the public non-copy API input types with extended + * options. This is a temporary solution to support advanced options from internal APIs. + */ +type ExtendInputWithAdvancedOptions = + InputType extends StorageOperationInputWithPath & + StorageOperationOptionsInput + ? InputType & { + options?: PublicInputOptionsType & ExtendedOptionsType; + } + : never; + +/** + * Generic types that extend the public copy API input type with extended options. + * This is a temporary solution to support advanced options from internal APIs. + */ +type ExtendCopyInputWithAdvancedOptions = + InputType extends StorageCopyInputWithPath + ? { + source: InputType['source']; + destination: InputType['destination']; + options?: ExtendedOptionsType; + } + : never; diff --git a/packages/storage/src/internals/types/options.ts b/packages/storage/src/internals/types/options.ts new file mode 100644 index 00000000000..cf1406c9425 --- /dev/null +++ b/packages/storage/src/internals/types/options.ts @@ -0,0 +1,2 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 diff --git a/packages/storage/src/internals/types/outputs.ts b/packages/storage/src/internals/types/outputs.ts new file mode 100644 index 00000000000..d4ac9cc915a --- /dev/null +++ b/packages/storage/src/internals/types/outputs.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + CopyWithPathOutput, + DownloadDataWithPathOutput, + GetPropertiesWithPathOutput, + GetUrlWithPathOutput, + ListAllWithPathOutput, + ListPaginateWithPathOutput, + RemoveWithPathOutput, + UploadDataWithPathOutput, +} from '../../providers/s3/types'; + +import { ListLocationsOutput, LocationCredentials } from './credentials'; + +/** + * @internal + */ +export type CopyOutput = CopyWithPathOutput; + +/** + * @internal + */ +export type DownloadDataOutput = DownloadDataWithPathOutput; + +/** + * @internal + */ +export type GetDataAccessOutput = LocationCredentials; + +/** + * @internal + */ +export type GetPropertiesOutput = GetPropertiesWithPathOutput; + +/** + * @internal + */ +export type GetUrlOutput = GetUrlWithPathOutput; + +/** + * @internal + */ +export type RemoveOutput = RemoveWithPathOutput; + +/** + * @internal + */ +export type ListOutput = ListAllWithPathOutput | ListPaginateWithPathOutput; + +/** + * @internal + */ +export type UploadDataOutput = UploadDataWithPathOutput; + +/** + * @internal + */ +export type ListCallerAccessGrantsOutput = ListLocationsOutput; diff --git a/packages/storage/src/internals/utils/constants.ts b/packages/storage/src/internals/utils/constants.ts new file mode 100644 index 00000000000..1bc620efb95 --- /dev/null +++ b/packages/storage/src/internals/utils/constants.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_CRED_TTL = 15 * 60; // 15 minutes +export const MAX_PAGE_SIZE = 1000; + +// eslint-disable-next-line no-template-curly-in-string +export const ENTITY_IDENTITY_URL = '${cognito-identity.amazonaws.com:sub}'; diff --git a/packages/storage/src/providers/s3/apis/downloadData.ts b/packages/storage/src/providers/s3/apis/downloadData.ts index 7c98ee2b857..0eeca69899f 100644 --- a/packages/storage/src/providers/s3/apis/downloadData.ts +++ b/packages/storage/src/providers/s3/apis/downloadData.ts @@ -1,26 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Amplify } from '@aws-amplify/core'; -import { StorageAction } from '@aws-amplify/core/internals/utils'; - import { DownloadDataInput, DownloadDataOutput, DownloadDataWithPathInput, DownloadDataWithPathOutput, } from '../types'; -import { resolveS3ConfigAndInput } from '../utils/resolveS3ConfigAndInput'; -import { createDownloadTask, validateStorageOperationInput } from '../utils'; -import { getObject } from '../utils/client'; -import { getStorageUserAgentValue } from '../utils/userAgent'; -import { logger } from '../../../utils'; -import { - StorageDownloadDataOutput, - StorageItemWithKey, - StorageItemWithPath, -} from '../../../types'; -import { STORAGE_INPUT_KEY } from '../utils/constants'; + +import { downloadData as downloadDataInternal } from './internal/downloadData'; /** * Download S3 object data to memory @@ -89,77 +77,8 @@ export function downloadData( *``` */ export function downloadData(input: DownloadDataInput): DownloadDataOutput; - export function downloadData( input: DownloadDataInput | DownloadDataWithPathInput, ) { - const abortController = new AbortController(); - - const downloadTask = createDownloadTask({ - job: downloadDataJob(input, abortController.signal), - onCancel: (message?: string) => { - abortController.abort(message); - }, - }); - - return downloadTask; + return downloadDataInternal(input); } - -const downloadDataJob = - ( - downloadDataInput: DownloadDataInput | DownloadDataWithPathInput, - abortSignal: AbortSignal, - ) => - async (): Promise< - StorageDownloadDataOutput - > => { - const { options: downloadDataOptions } = downloadDataInput; - const { bucket, keyPrefix, s3Config, identityId } = - await resolveS3ConfigAndInput(Amplify, downloadDataOptions); - const { inputType, objectKey } = validateStorageOperationInput( - downloadDataInput, - identityId, - ); - const finalKey = - inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; - - logger.debug(`download ${objectKey} from ${finalKey}.`); - - const { - Body: body, - LastModified: lastModified, - ContentLength: size, - ETag: eTag, - Metadata: metadata, - VersionId: versionId, - ContentType: contentType, - } = await getObject( - { - ...s3Config, - abortSignal, - onDownloadProgress: downloadDataOptions?.onProgress, - userAgentValue: getStorageUserAgentValue(StorageAction.DownloadData), - }, - { - Bucket: bucket, - Key: finalKey, - ...(downloadDataOptions?.bytesRange && { - Range: `bytes=${downloadDataOptions.bytesRange.start}-${downloadDataOptions.bytesRange.end}`, - }), - }, - ); - - const result = { - body, - lastModified, - size, - contentType, - eTag, - metadata, - versionId, - }; - - return inputType === STORAGE_INPUT_KEY - ? { key: objectKey, ...result } - : { path: objectKey, ...result }; - }; diff --git a/packages/storage/src/providers/s3/apis/internal/copy.ts b/packages/storage/src/providers/s3/apis/internal/copy.ts index 5035f897017..85898ea228f 100644 --- a/packages/storage/src/providers/s3/apis/internal/copy.ts +++ b/packages/storage/src/providers/s3/apis/internal/copy.ts @@ -14,13 +14,16 @@ import { ResolvedS3Config, StorageBucket } from '../../types/options'; import { isInputWithPath, resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { StorageValidationErrorCode } from '../../../../errors/types/validation'; import { assertValidationError } from '../../../../errors/utils/assertValidationError'; -import { copyObject } from '../../utils/client'; +import { copyObject } from '../../utils/client/s3data'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; +// TODO: Remove this interface when we move to public advanced APIs. +import { CopyInput as CopyWithPathInputWithAdvancedOptions } from '../../../../internals'; const isCopyInputWithPath = ( input: CopyInput | CopyWithPathInput, @@ -44,7 +47,7 @@ const storageBucketAssertion = ( export const copy = async ( amplify: AmplifyClassV6, - input: CopyInput | CopyWithPathInput, + input: CopyInput | CopyWithPathInputWithAdvancedOptions, ): Promise => { return isCopyInputWithPath(input) ? copyWithPath(amplify, input) @@ -53,21 +56,34 @@ export const copy = async ( const copyWithPath = async ( amplify: AmplifyClassV6, - input: CopyWithPathInput, + input: CopyWithPathInputWithAdvancedOptions, ): Promise => { const { source, destination } = input; storageBucketAssertion(source.bucket, destination.bucket); - const { bucket: sourceBucket, identityId } = await resolveS3ConfigAndInput( - amplify, - input.source, - ); + const { bucket: sourceBucket } = await resolveS3ConfigAndInput(amplify, { + path: input.source.path, + options: { + locationCredentialsProvider: input.options?.locationCredentialsProvider, + ...input.source, + }, + }); - const { s3Config, bucket: destBucket } = await resolveS3ConfigAndInput( - amplify, - input.destination, - ); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. + // The bucket, region, credentials of s3 client are resolved from destination. + // Whereas the source bucket and path are a input parameter of S3 copy operation. + const { + s3Config, + bucket: destBucket, + identityId, + } = await resolveS3ConfigAndInput(amplify, { + path: input.destination.path, + options: { + locationCredentialsProvider: input.options?.locationCredentialsProvider, + customEndpoint: input.options?.customEndpoint, + ...input.destination, + }, + }); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. assertValidationError(!!source.path, StorageValidationErrorCode.NoSourcePath); assertValidationError( @@ -83,7 +99,8 @@ const copyWithPath = async ( destination, identityId, ); - + validateBucketOwnerID(source.expectedBucketOwner); + validateBucketOwnerID(destination.expectedBucketOwner); const finalCopySource = `${sourceBucket}/${sourcePath}`; const finalCopyDestination = destinationPath; logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`); @@ -93,6 +110,10 @@ const copyWithPath = async ( destination: finalCopyDestination, bucket: destBucket, s3Config, + notModifiedSince: input.source.notModifiedSince, + eTag: input.source.eTag, + expectedSourceBucketOwner: input.source?.expectedBucketOwner, + expectedBucketOwner: input.destination?.expectedBucketOwner, }); return { path: finalCopyDestination }; @@ -112,15 +133,35 @@ export const copyWithKey = async ( !!destination.key, StorageValidationErrorCode.NoDestinationKey, ); + validateBucketOwnerID(source.expectedBucketOwner); + validateBucketOwnerID(destination.expectedBucketOwner); const { bucket: sourceBucket, keyPrefix: sourceKeyPrefix } = - await resolveS3ConfigAndInput(amplify, source); - + await resolveS3ConfigAndInput(amplify, { + ...input, + options: { + // @ts-expect-error: 'options' does not exist on type 'CopyInput'. In case of JS users set the location + // credentials provider option, resolveS3ConfigAndInput will throw validation error. + locationCredentialsProvider: input.options?.locationCredentialsProvider, + ...input.source, + }, + }); + + // The bucket, region, credentials of s3 client are resolved from destination. + // Whereas the source bucket and path are a input parameter of S3 copy operation. const { s3Config, bucket: destBucket, keyPrefix: destinationKeyPrefix, - } = await resolveS3ConfigAndInput(amplify, destination); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. + } = await resolveS3ConfigAndInput(amplify, { + ...input, + options: { + // @ts-expect-error: 'options' does not exist on type 'CopyInput'. In case of JS users set the location + // credentials provider option, resolveS3ConfigAndInput will throw validation error. + locationCredentialsProvider: input.options?.locationCredentialsProvider, + ...input.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 = `${sourceBucket}/${sourceKeyPrefix}${source.key}`; @@ -132,6 +173,10 @@ export const copyWithKey = async ( destination: finalCopyDestination, bucket: destBucket, s3Config, + notModifiedSince: input.source.notModifiedSince, + eTag: input.source.eTag, + expectedSourceBucketOwner: input.source?.expectedBucketOwner, + expectedBucketOwner: input.destination?.expectedBucketOwner, }); return { @@ -144,11 +189,19 @@ const serviceCopy = async ({ destination, bucket, s3Config, + notModifiedSince, + eTag, + expectedSourceBucketOwner, + expectedBucketOwner, }: { source: string; destination: string; bucket: string; s3Config: ResolvedS3Config; + notModifiedSince?: Date; + eTag?: string; + expectedSourceBucketOwner?: string; + expectedBucketOwner?: string; }) => { await copyObject( { @@ -160,6 +213,10 @@ const serviceCopy = async ({ CopySource: source, Key: destination, MetadataDirective: 'COPY', // Copies over metadata like contentType as well + CopySourceIfMatch: eTag, + CopySourceIfUnmodifiedSince: notModifiedSince, + ExpectedSourceBucketOwner: expectedSourceBucketOwner, + ExpectedBucketOwner: expectedBucketOwner, }, ); }; diff --git a/packages/storage/src/providers/s3/apis/internal/downloadData.ts b/packages/storage/src/providers/s3/apis/internal/downloadData.ts new file mode 100644 index 00000000000..f1d804b323d --- /dev/null +++ b/packages/storage/src/providers/s3/apis/internal/downloadData.ts @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { StorageAction } from '@aws-amplify/core/internals/utils'; + +import { resolveS3ConfigAndInput } from '../../utils/resolveS3ConfigAndInput'; +import { + createDownloadTask, + validateBucketOwnerID, + validateStorageOperationInput, +} from '../../utils'; +import { getObject } from '../../utils/client/s3data'; +import { getStorageUserAgentValue } from '../../utils/userAgent'; +import { logger } from '../../../../utils'; +import { DownloadDataInput, DownloadDataWithPathInput } from '../../types'; +import { STORAGE_INPUT_KEY } from '../../utils/constants'; +import { + StorageDownloadDataOutput, + StorageItemWithKey, + StorageItemWithPath, +} from '../../../../types'; +// TODO: Remove this interface when we move to public advanced APIs. +import { DownloadDataInput as DownloadDataWithPathInputWithAdvancedOptions } from '../../../../internals/types/inputs'; + +export const downloadData = ( + input: DownloadDataInput | DownloadDataWithPathInputWithAdvancedOptions, +) => { + const abortController = new AbortController(); + const downloadTask = createDownloadTask({ + job: downloadDataJob(input, abortController.signal), + onCancel: (message?: string) => { + abortController.abort(message); + }, + }); + + return downloadTask; +}; + +const downloadDataJob = + ( + downloadDataInput: DownloadDataInput | DownloadDataWithPathInput, + abortSignal: AbortSignal, + ) => + async (): Promise< + StorageDownloadDataOutput + > => { + const { options: downloadDataOptions } = downloadDataInput; + const { bucket, keyPrefix, s3Config, identityId } = + await resolveS3ConfigAndInput(Amplify, downloadDataInput); + const { inputType, objectKey } = validateStorageOperationInput( + downloadDataInput, + identityId, + ); + validateBucketOwnerID(downloadDataOptions?.expectedBucketOwner); + const finalKey = + inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; + logger.debug(`download ${objectKey} from ${finalKey}.`); + const { + Body: body, + LastModified: lastModified, + ContentLength: size, + ETag: eTag, + Metadata: metadata, + VersionId: versionId, + ContentType: contentType, + } = await getObject( + { + ...s3Config, + abortSignal, + onDownloadProgress: downloadDataOptions?.onProgress, + userAgentValue: getStorageUserAgentValue(StorageAction.DownloadData), + }, + { + Bucket: bucket, + Key: finalKey, + ...(downloadDataOptions?.bytesRange && { + Range: `bytes=${downloadDataOptions.bytesRange.start}-${downloadDataOptions.bytesRange.end}`, + }), + ExpectedBucketOwner: downloadDataOptions?.expectedBucketOwner, + }, + ); + const result = { + body, + lastModified, + size, + contentType, + eTag, + metadata, + versionId, + }; + + return inputType === STORAGE_INPUT_KEY + ? { key: objectKey, ...result } + : { path: objectKey, ...result }; + }; diff --git a/packages/storage/src/providers/s3/apis/internal/getProperties.ts b/packages/storage/src/providers/s3/apis/internal/getProperties.ts index 3b61460d89b..981c32cb827 100644 --- a/packages/storage/src/providers/s3/apis/internal/getProperties.ts +++ b/packages/storage/src/providers/s3/apis/internal/getProperties.ts @@ -7,30 +7,34 @@ import { StorageAction } from '@aws-amplify/core/internals/utils'; import { GetPropertiesInput, GetPropertiesOutput, - GetPropertiesWithPathInput, GetPropertiesWithPathOutput, } from '../../types'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; -import { headObject } from '../../utils/client'; +import { headObject } from '../../utils/client/s3data'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; import { STORAGE_INPUT_KEY } from '../../utils/constants'; +// TODO: Remove this interface when we move to public advanced APIs. +import { GetPropertiesInput as GetPropertiesWithPathInputWithAdvancedOptions } from '../../../../internals'; export const getProperties = async ( amplify: AmplifyClassV6, - input: GetPropertiesInput | GetPropertiesWithPathInput, + input: GetPropertiesInput | GetPropertiesWithPathInputWithAdvancedOptions, action?: StorageAction, ): Promise => { - const { options: getPropertiesOptions } = input; const { s3Config, bucket, keyPrefix, identityId } = - await resolveS3ConfigAndInput(amplify, getPropertiesOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, ); + + validateBucketOwnerID(input.options?.expectedBucketOwner); + const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; @@ -45,6 +49,7 @@ export const getProperties = async ( { Bucket: bucket, Key: finalKey, + ExpectedBucketOwner: input.options?.expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index a5c319a1389..db2315ddb78 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -4,16 +4,12 @@ import { AmplifyClassV6 } from '@aws-amplify/core'; import { StorageAction } from '@aws-amplify/core/internals/utils'; -import { - GetUrlInput, - GetUrlOutput, - GetUrlWithPathInput, - GetUrlWithPathOutput, -} from '../../types'; +import { GetUrlInput, GetUrlOutput, GetUrlWithPathOutput } from '../../types'; import { StorageValidationErrorCode } from '../../../../errors/types/validation'; -import { getPresignedGetObjectUrl } from '../../utils/client'; +import { getPresignedGetObjectUrl } from '../../utils/client/s3data'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { assertValidationError } from '../../../../errors/utils/assertValidationError'; @@ -23,20 +19,23 @@ import { STORAGE_INPUT_KEY, } from '../../utils/constants'; import { constructContentDisposition } from '../../utils/constructContentDisposition'; +// TODO: Remove this interface when we move to public advanced APIs. +import { GetUrlInput as GetUrlWithPathInputWithAdvancedOptions } from '../../../../internals'; import { getProperties } from './getProperties'; export const getUrl = async ( amplify: AmplifyClassV6, - input: GetUrlInput | GetUrlWithPathInput, + input: GetUrlInput | GetUrlWithPathInputWithAdvancedOptions, ): Promise => { const { options: getUrlOptions } = input; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, getUrlOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, ); + validateBucketOwnerID(getUrlOptions?.expectedBucketOwner); const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; @@ -83,6 +82,7 @@ export const getUrl = async ( ...(getUrlOptions?.contentType && { ResponseContentType: getUrlOptions.contentType, }), + ExpectedBucketOwner: getUrlOptions?.expectedBucketOwner, }, ), expiresAt: new Date(Date.now() + urlExpirationInSec * 1000), diff --git a/packages/storage/src/providers/s3/apis/internal/list.ts b/packages/storage/src/providers/s3/apis/internal/list.ts index 5b41b1f3a23..ef8d277b48d 100644 --- a/packages/storage/src/providers/s3/apis/internal/list.ts +++ b/packages/storage/src/providers/s3/apis/internal/list.ts @@ -5,19 +5,17 @@ import { AmplifyClassV6 } from '@aws-amplify/core'; import { StorageAction } from '@aws-amplify/core/internals/utils'; import { - ListAllInput, ListAllOutput, - ListAllWithPathInput, ListAllWithPathOutput, ListOutputItem, ListOutputItemWithPath, - ListPaginateInput, ListPaginateOutput, - ListPaginateWithPathInput, ListPaginateWithPathOutput, } from '../../types'; import { resolveS3ConfigAndInput, + urlDecode, + validateBucketOwnerID, validateStorageOperationInputWithPrefix, } from '../../utils'; import { @@ -29,11 +27,15 @@ import { ListObjectsV2Input, ListObjectsV2Output, listObjectsV2, -} from '../../utils/client'; +} from '../../utils/client/s3data'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; import { DEFAULT_DELIMITER, STORAGE_INPUT_PREFIX } from '../../utils/constants'; -import { CommonPrefix } from '../../utils/client/types'; +import { CommonPrefix } from '../../utils/client/s3data/types'; +import { IntegrityError } from '../../../../errors/IntegrityError'; +import { ListAllInput, ListPaginateInput } from '../../types/inputs'; +// TODO: Remove this interface when we move to public advanced APIs. +import { ListInput as ListWithPathInputAndAdvancedOptions } from '../../../../internals/types/inputs'; const MAX_PAGE_SIZE = 1000; @@ -45,11 +47,7 @@ interface ListInputArgs { export const list = async ( amplify: AmplifyClassV6, - input: - | ListAllInput - | ListPaginateInput - | ListAllWithPathInput - | ListPaginateWithPathInput, + input: ListAllInput | ListPaginateInput | ListWithPathInputAndAdvancedOptions, ): Promise< | ListAllOutput | ListPaginateOutput @@ -62,12 +60,13 @@ export const list = async ( bucket, keyPrefix: generatedPrefix, identityId, - } = await resolveS3ConfigAndInput(amplify, options); + } = await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInputWithPrefix( input, identityId, ); + validateBucketOwnerID(options.expectedBucketOwner); const isInputWithPrefix = inputType === STORAGE_INPUT_PREFIX; // @ts-expect-error pageSize and nextToken should not coexist with listAll @@ -86,6 +85,8 @@ export const list = async ( MaxKeys: options?.listAll ? undefined : options?.pageSize, ContinuationToken: options?.listAll ? undefined : options?.nextToken, Delimiter: getDelimiter(options), + ExpectedBucketOwner: options?.expectedBucketOwner, + EncodingType: 'url', }; logger.debug(`listing items from "${listParams.Prefix}"`); @@ -160,14 +161,18 @@ const _listWithPrefix = async ({ listParamsClone, ); - if (!response?.Contents) { + const listOutput = decodeEncodedElements(response); + + validateEchoedElements(listParamsClone, listOutput); + + if (!listOutput?.Contents) { return { items: [], }; } return { - items: response.Contents.map(item => ({ + items: listOutput.Contents.map(item => ({ key: generatedPrefix ? item.Key!.substring(generatedPrefix.length) : item.Key!, @@ -175,7 +180,7 @@ const _listWithPrefix = async ({ lastModified: item.LastModified, size: item.Size, })), - nextToken: response.NextContinuationToken, + nextToken: listOutput.NextContinuationToken, }; }; @@ -220,11 +225,7 @@ const _listWithPath = async ({ listParamsClone.MaxKeys = MAX_PAGE_SIZE; } - const { - Contents: contents, - NextContinuationToken: nextContinuationToken, - CommonPrefixes: commonPrefixes, - }: ListObjectsV2Output = await listObjectsV2( + const response = await listObjectsV2( { ...s3Config, userAgentValue: getStorageUserAgentValue(StorageAction.List), @@ -232,6 +233,16 @@ const _listWithPath = async ({ listParamsClone, ); + const listOutput = decodeEncodedElements(response); + + validateEchoedElements(listParamsClone, listOutput); + + const { + Contents: contents, + NextContinuationToken: nextContinuationToken, + CommonPrefixes: commonPrefixes, + }: ListObjectsV2Output = listOutput; + const excludedSubpaths = commonPrefixes && mapCommonPrefixesToExcludedSubpaths(commonPrefixes); @@ -274,3 +285,61 @@ const getDelimiter = ( return options?.subpathStrategy?.delimiter ?? DEFAULT_DELIMITER; } }; + +const validateEchoedElements = ( + listInput: ListObjectsV2Input, + listOutput: ListObjectsV2Output, +) => { + const validEchoedParameters = + listInput.Bucket === listOutput.Name && + listInput.Delimiter === listOutput.Delimiter && + listInput.MaxKeys === listOutput.MaxKeys && + listInput.Prefix === listOutput.Prefix && + listInput.ContinuationToken === listOutput.ContinuationToken; + + if (!validEchoedParameters) { + throw new IntegrityError(); + } +}; + +/** + * Decodes URL-encoded elements in the S3 `ListObjectsV2Output` response when `EncodingType` is `'url'`. + * Applies to values for 'Delimiter', 'Prefix', 'StartAfter' and 'Key' in the response. + */ +const decodeEncodedElements = ( + listOutput: ListObjectsV2Output, +): ListObjectsV2Output => { + if (listOutput.EncodingType !== 'url') { + return listOutput; + } + + const decodedListOutput = { ...listOutput }; + + // Decode top-level properties + (['Delimiter', 'Prefix', 'StartAfter'] as const).forEach(prop => { + const value = listOutput[prop]; + if (typeof value === 'string') { + decodedListOutput[prop] = urlDecode(value); + } + }); + + // Decode 'Key' in each item of 'Contents', if it exists + if (listOutput.Contents) { + decodedListOutput.Contents = listOutput.Contents.map(content => ({ + ...content, + Key: content.Key ? urlDecode(content.Key) : content.Key, + })); + } + + // Decode 'Prefix' in each item of 'CommonPrefixes', if it exists + if (listOutput.CommonPrefixes) { + decodedListOutput.CommonPrefixes = listOutput.CommonPrefixes.map( + content => ({ + ...content, + Prefix: content.Prefix ? urlDecode(content.Prefix) : content.Prefix, + }), + ); + } + + return decodedListOutput; +}; diff --git a/packages/storage/src/providers/s3/apis/internal/remove.ts b/packages/storage/src/providers/s3/apis/internal/remove.ts index bc0fa4a2ade..e751b6bbb61 100644 --- a/packages/storage/src/providers/s3/apis/internal/remove.ts +++ b/packages/storage/src/providers/s3/apis/internal/remove.ts @@ -4,33 +4,31 @@ import { AmplifyClassV6 } from '@aws-amplify/core'; import { StorageAction } from '@aws-amplify/core/internals/utils'; -import { - RemoveInput, - RemoveOutput, - RemoveWithPathInput, - RemoveWithPathOutput, -} from '../../types'; +import { RemoveInput, RemoveOutput, RemoveWithPathOutput } from '../../types'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; -import { deleteObject } from '../../utils/client'; +import { deleteObject } from '../../utils/client/s3data'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; import { STORAGE_INPUT_KEY } from '../../utils/constants'; +// TODO: Remove this interface when we move to public advanced APIs. +import { RemoveInput as RemoveWithPathInputWithAdvancedOptions } from '../../../../internals'; export const remove = async ( amplify: AmplifyClassV6, - input: RemoveInput | RemoveWithPathInput, + input: RemoveInput | RemoveWithPathInputWithAdvancedOptions, ): Promise => { - const { options = {} } = input ?? {}; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, options); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, ); + validateBucketOwnerID(input.options?.expectedBucketOwner); let finalKey; if (inputType === STORAGE_INPUT_KEY) { @@ -49,6 +47,7 @@ export const remove = async ( { Bucket: bucket, Key: finalKey, + ExpectedBucketOwner: input.options?.expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/uploadData/byteLength.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/byteLength.ts similarity index 65% rename from packages/storage/src/providers/s3/apis/uploadData/byteLength.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/byteLength.ts index 9b6ea87e42f..4caeb3c1973 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/byteLength.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/byteLength.ts @@ -8,16 +8,9 @@ export const byteLength = (input?: any): number | undefined => { if (input === null || input === undefined) return 0; if (typeof input === 'string') { - let len = input.length; + const blob = new Blob([input]); - for (let i = len - 1; i >= 0; i--) { - const code = input.charCodeAt(i); - if (code > 0x7f && code <= 0x7ff) len++; - else if (code > 0x7ff && code <= 0xffff) len += 2; - if (code >= 0xdc00 && code <= 0xdfff) i--; // trail surrogate - } - - return len; + return blob.size; } else if (typeof input.byteLength === 'number') { // handles Uint8Array, ArrayBuffer, Buffer, and ArrayBufferView return input.byteLength; @@ -26,6 +19,5 @@ export const byteLength = (input?: any): number | undefined => { return input.size; } - // TODO: support Node.js stream size when Node.js runtime is supported out-of-box. return undefined; }; diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/index.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/index.ts new file mode 100644 index 00000000000..fca640f4b44 --- /dev/null +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/index.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createUploadTask } from '../../../utils'; +import { assertValidationError } from '../../../../../errors/utils/assertValidationError'; +import { StorageValidationErrorCode } from '../../../../../errors/types/validation'; +import { DEFAULT_PART_SIZE, MAX_OBJECT_SIZE } from '../../../utils/constants'; + +import { byteLength } from './byteLength'; +import { SinglePartUploadDataInput, putObjectJob } from './putObjectJob'; +import { + MultipartUploadDataInput, + getMultipartUploadHandlers, +} from './multipart'; + +export const uploadData = ( + input: SinglePartUploadDataInput | MultipartUploadDataInput, +) => { + const { data } = input; + + const dataByteLength = byteLength(data); + // Using InvalidUploadSource error code because the input data must NOT be any + // of permitted Blob, string, ArrayBuffer(View) if byteLength could not be determined. + assertValidationError( + dataByteLength !== undefined, + StorageValidationErrorCode.InvalidUploadSource, + ); + assertValidationError( + dataByteLength <= MAX_OBJECT_SIZE, + StorageValidationErrorCode.ObjectIsTooLarge, + ); + + if (dataByteLength <= DEFAULT_PART_SIZE) { + // Single part upload + const abortController = new AbortController(); + + return createUploadTask({ + isMultipartUpload: false, + job: putObjectJob(input, abortController.signal, dataByteLength), + onCancel: (message?: string) => { + abortController.abort(message); + }, + }); + } else { + // Multipart upload + const { multipartUploadJob, onPause, onResume, onCancel } = + getMultipartUploadHandlers(input, dataByteLength); + + return createUploadTask({ + isMultipartUpload: true, + job: multipartUploadJob, + onCancel: (message?: string) => { + onCancel(message); + }, + onPause, + onResume, + }); + } +}; diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/calculatePartSize.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/calculatePartSize.ts similarity index 83% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/calculatePartSize.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/calculatePartSize.ts index 8089038d6e1..1d5d5c98ac7 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/calculatePartSize.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/calculatePartSize.ts @@ -1,7 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DEFAULT_PART_SIZE, MAX_PARTS_COUNT } from '../../../utils/constants'; +import { + DEFAULT_PART_SIZE, + MAX_PARTS_COUNT, +} from '../../../../utils/constants'; export const calculatePartSize = (totalSize?: number): number => { if (!totalSize) { diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/getDataChunker.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/getDataChunker.ts similarity index 90% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/getDataChunker.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/getDataChunker.ts index aab240be148..ff9270c8e74 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/getDataChunker.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/getDataChunker.ts @@ -1,12 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { StorageUploadDataPayload } from '../../../../../types'; +import { StorageUploadDataPayload } from '../../../../../../types'; import { StorageValidationErrorCode, validationErrorMap, -} from '../../../../../errors/types/validation'; -import { StorageError } from '../../../../../errors/StorageError'; +} from '../../../../../../errors/types/validation'; +import { StorageError } from '../../../../../../errors/StorageError'; import { calculatePartSize } from './calculatePartSize'; diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/index.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/index.ts new file mode 100644 index 00000000000..576f715dd79 --- /dev/null +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/index.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + getMultipartUploadHandlers, + MultipartUploadDataInput, +} from './uploadHandlers'; diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/initialUpload.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts similarity index 56% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/initialUpload.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts index 25338b2003f..2437cf0d4b7 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/initialUpload.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts @@ -1,13 +1,22 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { StorageAccessLevel } from '@aws-amplify/core'; +import { + KeyValueStorageInterface, + StorageAccessLevel, +} from '@aws-amplify/core'; -import { ContentDisposition, ResolvedS3Config } from '../../../types/options'; -import { StorageUploadDataPayload } from '../../../../../types'; -import { Part, createMultipartUpload } from '../../../utils/client'; -import { logger } from '../../../../../utils'; -import { constructContentDisposition } from '../../../utils/constructContentDisposition'; +import { + ContentDisposition, + ResolvedS3Config, + UploadDataChecksumAlgorithm, +} from '../../../../types/options'; +import { StorageUploadDataPayload } from '../../../../../../types'; +import { Part, createMultipartUpload } from '../../../../utils/client/s3data'; +import { logger } from '../../../../../../utils'; +import { constructContentDisposition } from '../../../../utils/constructContentDisposition'; +import { CHECKSUM_ALGORITHM_CRC32 } from '../../../../utils/constants'; +import { getCombinedCrc32 } from '../../../../utils/getCombinedCrc32.native'; import { cacheMultipartUpload, @@ -19,6 +28,7 @@ interface LoadOrCreateMultipartUploadOptions { s3Config: ResolvedS3Config; data: StorageUploadDataPayload; bucket: string; + size: number; accessLevel?: StorageAccessLevel; keyPrefix?: string; key: string; @@ -26,13 +36,17 @@ interface LoadOrCreateMultipartUploadOptions { contentDisposition?: string | ContentDisposition; contentEncoding?: string; metadata?: Record; - size?: number; abortSignal?: AbortSignal; + checksumAlgorithm?: UploadDataChecksumAlgorithm; + optionsHash: string; + resumableUploadsCache?: KeyValueStorageInterface; + expectedBucketOwner?: string; } interface LoadOrCreateMultipartUploadResult { uploadId: string; cachedParts: Part[]; + finalCrc32?: string; } /** @@ -54,6 +68,10 @@ export const loadOrCreateMultipartUpload = async ({ contentEncoding, metadata, abortSignal, + checksumAlgorithm, + optionsHash, + resumableUploadsCache, + expectedBucketOwner, }: LoadOrCreateMultipartUploadOptions): Promise => { const finalKey = keyPrefix !== undefined ? keyPrefix + key : key; @@ -62,10 +80,14 @@ export const loadOrCreateMultipartUpload = async ({ parts: Part[]; uploadId: string; uploadCacheKey: string; + finalCrc32?: string; } | undefined; - if (size === undefined) { - logger.debug('uploaded data size cannot be determined, skipping cache.'); + + if (!resumableUploadsCache) { + logger.debug( + 'uploaded cache instance cannot be determined, skipping cache.', + ); cachedUpload = undefined; } else { const uploadCacheKey = getUploadsCacheKey({ @@ -75,6 +97,7 @@ export const loadOrCreateMultipartUpload = async ({ bucket, accessLevel, key, + optionsHash, }); const cachedUploadParts = await findCachedUploadParts({ @@ -82,6 +105,7 @@ export const loadOrCreateMultipartUpload = async ({ cacheKey: uploadCacheKey, bucket, finalKey, + resumableUploadsCache, }); cachedUpload = cachedUploadParts ? { ...cachedUploadParts, uploadCacheKey } @@ -92,8 +116,14 @@ export const loadOrCreateMultipartUpload = async ({ return { uploadId: cachedUpload.uploadId, cachedParts: cachedUpload.parts, + finalCrc32: cachedUpload.finalCrc32, }; } else { + const finalCrc32 = + checksumAlgorithm === CHECKSUM_ALGORITHM_CRC32 + ? await getCombinedCrc32(data, size) + : undefined; + const { UploadId } = await createMultipartUpload( { ...s3Config, @@ -106,34 +136,34 @@ export const loadOrCreateMultipartUpload = async ({ ContentDisposition: constructContentDisposition(contentDisposition), ContentEncoding: contentEncoding, Metadata: metadata, + ChecksumAlgorithm: finalCrc32 ? 'CRC32' : undefined, + ExpectedBucketOwner: expectedBucketOwner, }, ); - if (size === undefined) { - logger.debug('uploaded data size cannot be determined, skipping cache.'); - return { + if (resumableUploadsCache) { + const uploadCacheKey = getUploadsCacheKey({ + size, + contentType, + file: data instanceof File ? data : undefined, + bucket, + accessLevel, + key, + optionsHash, + }); + await cacheMultipartUpload(resumableUploadsCache, uploadCacheKey, { uploadId: UploadId!, - cachedParts: [], - }; + bucket, + key, + finalCrc32, + fileName: data instanceof File ? data.name : '', + }); } - const uploadCacheKey = getUploadsCacheKey({ - size, - contentType, - file: data instanceof File ? data : undefined, - bucket, - accessLevel, - key, - }); - await cacheMultipartUpload(uploadCacheKey, { - uploadId: UploadId!, - bucket, - key, - fileName: data instanceof File ? data.name : '', - }); return { uploadId: UploadId!, cachedParts: [], + finalCrc32, }; } }; diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/progressTracker.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/progressTracker.ts similarity index 94% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/progressTracker.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/progressTracker.ts index 0347d76dfcd..92edaf7a748 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/progressTracker.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/progressTracker.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { TransferProgressEvent } from '../../../../../types'; +import { TransferProgressEvent } from '../../../../../../types'; interface ConcurrentUploadsProgressTrackerOptions { size?: number; diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadCache.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadCache.ts similarity index 70% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/uploadCache.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadCache.ts index e5619655f3b..22f80e741a7 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadCache.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadCache.ts @@ -4,13 +4,12 @@ import { KeyValueStorageInterface, StorageAccessLevel, - defaultStorage, } from '@aws-amplify/core'; -import { UPLOADS_STORAGE_KEY } from '../../../utils/constants'; -import { ResolvedS3Config } from '../../../types/options'; -import { Part, listParts } from '../../../utils/client'; -import { logger } from '../../../../../utils'; +import { UPLOADS_STORAGE_KEY } from '../../../../utils/constants'; +import { ResolvedS3Config } from '../../../../types/options'; +import { Part, listParts } from '../../../../utils/client/s3data'; +import { logger } from '../../../../../../utils'; const ONE_HOUR = 1000 * 60 * 60; @@ -19,6 +18,7 @@ interface FindCachedUploadPartsOptions { s3Config: ResolvedS3Config; bucket: string; finalKey: string; + resumableUploadsCache: KeyValueStorageInterface; } /** @@ -26,6 +26,7 @@ interface FindCachedUploadPartsOptions { * with ListParts API. If the cached upload is expired(1 hour), return null. */ export const findCachedUploadParts = async ({ + resumableUploadsCache, cacheKey, s3Config, bucket, @@ -33,8 +34,9 @@ export const findCachedUploadParts = async ({ }: FindCachedUploadPartsOptions): Promise<{ parts: Part[]; uploadId: string; + finalCrc32?: string; } | null> => { - const cachedUploads = await listCachedUploadTasks(defaultStorage); + const cachedUploads = await listCachedUploadTasks(resumableUploadsCache); if ( !cachedUploads[cacheKey] || cachedUploads[cacheKey].lastTouched < Date.now() - ONE_HOUR // Uploads are cached for 1 hour @@ -45,7 +47,7 @@ export const findCachedUploadParts = async ({ const cachedUpload = cachedUploads[cacheKey]; cachedUpload.lastTouched = Date.now(); - await defaultStorage.setItem( + await resumableUploadsCache.setItem( UPLOADS_STORAGE_KEY, JSON.stringify(cachedUploads), ); @@ -60,10 +62,11 @@ export const findCachedUploadParts = async ({ return { parts: Parts, uploadId: cachedUpload.uploadId, + finalCrc32: cachedUpload.finalCrc32, }; } catch (e) { logger.debug('failed to list cached parts, removing cached upload.'); - await removeCachedUpload(cacheKey); + await removeCachedUpload(resumableUploadsCache, cacheKey); return null; } @@ -74,15 +77,18 @@ interface FileMetadata { fileName: string; key: string; uploadId: string; + finalCrc32?: string; // Unix timestamp in ms lastTouched: number; } const listCachedUploadTasks = async ( - kvStorage: KeyValueStorageInterface, + resumableUploadsCache: KeyValueStorageInterface, ): Promise> => { try { - return JSON.parse((await kvStorage.getItem(UPLOADS_STORAGE_KEY)) ?? '{}'); + return JSON.parse( + (await resumableUploadsCache.getItem(UPLOADS_STORAGE_KEY)) ?? '{}', + ); } catch (e) { logger.debug('failed to parse cached uploads record.'); @@ -97,6 +103,7 @@ interface UploadsCacheKeyOptions { accessLevel?: StorageAccessLevel; key: string; file?: File; + optionsHash: string; } /** @@ -111,6 +118,7 @@ export const getUploadsCacheKey = ({ bucket, accessLevel, key, + optionsHash, }: UploadsCacheKeyOptions) => { let levelStr; const resolvedContentType = @@ -123,7 +131,7 @@ export const getUploadsCacheKey = ({ levelStr = accessLevel === 'guest' ? 'public' : accessLevel; } - const baseId = `${size}_${resolvedContentType}_${bucket}_${levelStr}_${key}`; + const baseId = `${optionsHash}_${size}_${resolvedContentType}_${bucket}_${levelStr}_${key}`; if (file) { return `${file.name}_${file.lastModified}_${baseId}`; @@ -133,24 +141,28 @@ export const getUploadsCacheKey = ({ }; export const cacheMultipartUpload = async ( + resumableUploadsCache: KeyValueStorageInterface, cacheKey: string, fileMetadata: Omit, ): Promise => { - const cachedUploads = await listCachedUploadTasks(defaultStorage); + const cachedUploads = await listCachedUploadTasks(resumableUploadsCache); cachedUploads[cacheKey] = { ...fileMetadata, lastTouched: Date.now(), }; - await defaultStorage.setItem( + await resumableUploadsCache.setItem( UPLOADS_STORAGE_KEY, JSON.stringify(cachedUploads), ); }; -export const removeCachedUpload = async (cacheKey: string): Promise => { - const cachedUploads = await listCachedUploadTasks(defaultStorage); +export const removeCachedUpload = async ( + resumableUploadsCache: KeyValueStorageInterface, + cacheKey: string, +): Promise => { + const cachedUploads = await listCachedUploadTasks(resumableUploadsCache); delete cachedUploads[cacheKey]; - await defaultStorage.setItem( + await resumableUploadsCache.setItem( UPLOADS_STORAGE_KEY, JSON.stringify(cachedUploads), ); diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts similarity index 60% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts index 8d002df37db..c9f52314f8a 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts @@ -1,40 +1,78 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Amplify, StorageAccessLevel } from '@aws-amplify/core'; +import { + Amplify, + KeyValueStorageInterface, + StorageAccessLevel, +} from '@aws-amplify/core'; import { StorageAction } from '@aws-amplify/core/internals/utils'; -import { UploadDataInput, UploadDataWithPathInput } from '../../../types'; +import { UploadDataInput } from '../../../../types'; +// TODO: Remove this interface when we move to public advanced APIs. +import { UploadDataInput as UploadDataWithPathInputWithAdvancedOptions } from '../../../../../../internals/types/inputs'; import { resolveS3ConfigAndInput, validateStorageOperationInput, -} from '../../../utils'; -import { ItemWithKey, ItemWithPath } from '../../../types/outputs'; +} from '../../../../utils'; +import { ItemWithKey, ItemWithPath } from '../../../../types/outputs'; import { DEFAULT_ACCESS_LEVEL, DEFAULT_QUEUE_SIZE, STORAGE_INPUT_KEY, -} from '../../../utils/constants'; +} from '../../../../utils/constants'; import { ResolvedS3Config, UploadDataWithKeyOptions, -} from '../../../types/options'; -import { StorageError } from '../../../../../errors/StorageError'; -import { CanceledError } from '../../../../../errors/CanceledError'; +} from '../../../../types/options'; +import { StorageError } from '../../../../../../errors/StorageError'; +import { CanceledError } from '../../../../../../errors/CanceledError'; import { Part, abortMultipartUpload, completeMultipartUpload, headObject, -} from '../../../utils/client'; -import { getStorageUserAgentValue } from '../../../utils/userAgent'; -import { logger } from '../../../../../utils'; +} from '../../../../utils/client/s3data'; +import { getStorageUserAgentValue } from '../../../../utils/userAgent'; +import { logger } from '../../../../../../utils'; +import { calculateContentCRC32 } from '../../../../utils/crc32'; +import { StorageOperationOptionsInput } from '../../../../../../types/inputs'; +import { IntegrityError } from '../../../../../../errors/IntegrityError'; import { uploadPartExecutor } from './uploadPartExecutor'; import { getUploadsCacheKey, removeCachedUpload } from './uploadCache'; import { getConcurrentUploadsProgressTracker } from './progressTracker'; import { loadOrCreateMultipartUpload } from './initialUpload'; import { getDataChunker } from './getDataChunker'; +import { calculatePartSize } from './calculatePartSize'; + +type WithResumableCacheConfig> = + Input & { + options?: Input['options'] & { + /** + * The cache instance to store the in-progress multipart uploads so they can be resumed + * after page refresh. By default the library caches the uploaded file name, + * last modified, final checksum, size, bucket, key, and corresponded in-progress + * multipart upload ID from S3. If the library detects the same input corresponds to a + * previously in-progress upload from within 1 hour ago, it will continue + * the upload from where it left. + * + * By default, this option is not set. The upload caching is disabled. + */ + resumableUploadsCache?: KeyValueStorageInterface; + }; + }; + +/** + * The input interface for UploadData API with the options needed for multi-part upload. + * It supports both legacy Gen 1 input with key and Gen2 input with path. It also support additional + * advanced options for StorageBrowser. + * + * @internal + */ +export type MultipartUploadDataInput = WithResumableCacheConfig< + UploadDataInput | UploadDataWithPathInputWithAdvancedOptions +>; /** * Create closure hiding the multipart upload implementation details and expose the upload job and control functions( @@ -43,8 +81,8 @@ import { getDataChunker } from './getDataChunker'; * @internal */ export const getMultipartUploadHandlers = ( - uploadDataInput: UploadDataInput | UploadDataWithPathInput, - size?: number, + uploadDataInput: MultipartUploadDataInput, + size: number, ) => { let resolveCallback: | ((value: ItemWithKey | ItemWithPath) => void) @@ -54,6 +92,7 @@ export const getMultipartUploadHandlers = ( | { uploadId: string; completedParts: Part[]; + finalCrc32?: string; } | undefined; let resolvedS3Config: ResolvedS3Config | undefined; @@ -64,16 +103,19 @@ export const getMultipartUploadHandlers = ( let resolvedIdentityId: string | undefined; let uploadCacheKey: string | undefined; let finalKey: string; + let expectedBucketOwner: string | undefined; // Special flag that differentiates HTTP requests abort error caused by pause() from ones caused by cancel(). // The former one should NOT cause the upload job to throw, but cancels any pending HTTP requests. // This should be replaced by a special abort reason. However,the support of this API is lagged behind. let isAbortSignalFromPause = false; + const { resumableUploadsCache } = uploadDataInput.options ?? {}; + const startUpload = async (): Promise => { const { options: uploadDataOptions, data } = uploadDataInput; const resolvedS3Options = await resolveS3ConfigAndInput( Amplify, - uploadDataOptions, + uploadDataInput, ); abortController = new AbortController(); @@ -81,6 +123,7 @@ export const getMultipartUploadHandlers = ( resolvedS3Config = resolvedS3Options.s3Config; resolvedBucket = resolvedS3Options.bucket; resolvedIdentityId = resolvedS3Options.identityId; + expectedBucketOwner = uploadDataOptions?.expectedBucketOwner; const { inputType, objectKey } = validateStorageOperationInput( uploadDataInput, @@ -92,6 +135,7 @@ export const getMultipartUploadHandlers = ( contentEncoding, contentType = 'application/octet-stream', metadata, + preventOverwrite, onProgress, } = uploadDataOptions ?? {}; @@ -107,24 +151,34 @@ export const getMultipartUploadHandlers = ( resolvedAccessLevel = resolveAccessLevel(accessLevel); } + const optionsHash = ( + await calculateContentCRC32(JSON.stringify(uploadDataOptions)) + ).checksum; + if (!inProgressUpload) { - const { uploadId, cachedParts } = await loadOrCreateMultipartUpload({ - s3Config: resolvedS3Config, - accessLevel: resolvedAccessLevel, - bucket: resolvedBucket, - keyPrefix: resolvedKeyPrefix, - key: objectKey, - contentType, - contentDisposition, - contentEncoding, - metadata, - data, - size, - abortSignal: abortController.signal, - }); + const { uploadId, cachedParts, finalCrc32 } = + await loadOrCreateMultipartUpload({ + s3Config: resolvedS3Config, + accessLevel: resolvedAccessLevel, + bucket: resolvedBucket, + keyPrefix: resolvedKeyPrefix, + key: objectKey, + contentType, + contentDisposition, + contentEncoding, + metadata, + data, + size, + abortSignal: abortController.signal, + checksumAlgorithm: uploadDataOptions?.checksumAlgorithm, + optionsHash, + resumableUploadsCache, + expectedBucketOwner, + }); inProgressUpload = { uploadId, completedParts: cachedParts, + finalCrc32, }; } @@ -136,6 +190,7 @@ export const getMultipartUploadHandlers = ( bucket: resolvedBucket!, size, key: objectKey, + optionsHash, }) : undefined; @@ -143,10 +198,16 @@ export const getMultipartUploadHandlers = ( const completedPartNumberSet = new Set( inProgressUpload.completedParts.map(({ PartNumber }) => PartNumber!), ); - const onPartUploadCompletion = (partNumber: number, eTag: string) => { + const onPartUploadCompletion = ( + partNumber: number, + eTag: string, + crc32: string | undefined, + ) => { inProgressUpload?.completedParts.push({ PartNumber: partNumber, ETag: eTag, + // TODO: crc32 can always be added once RN also has an implementation + ...(crc32 ? { ChecksumCRC32: crc32 } : {}), }); }; const concurrentUploadsProgressTracker = @@ -169,12 +230,16 @@ export const getMultipartUploadHandlers = ( onPartUploadCompletion, onProgress: concurrentUploadsProgressTracker.getOnProgressListener(), isObjectLockEnabled: resolvedS3Options.isObjectLockEnabled, + useCRC32Checksum: Boolean(inProgressUpload.finalCrc32), + expectedBucketOwner, }), ); } await Promise.all(concurrentUploadPartExecutors); + validateCompletedParts(inProgressUpload.completedParts, size); + const { ETag: eTag } = await completeMultipartUpload( { ...resolvedS3Config, @@ -185,11 +250,12 @@ export const getMultipartUploadHandlers = ( Bucket: resolvedBucket, Key: finalKey, UploadId: inProgressUpload.uploadId, + ChecksumCRC32: inProgressUpload.finalCrc32, + IfNoneMatch: preventOverwrite ? '*' : undefined, MultipartUpload: { - Parts: inProgressUpload.completedParts.sort( - (partA, partB) => partA.PartNumber! - partB.PartNumber!, - ), + Parts: sortUploadParts(inProgressUpload.completedParts), }, + ExpectedBucketOwner: expectedBucketOwner, }, ); @@ -199,6 +265,7 @@ export const getMultipartUploadHandlers = ( { Bucket: resolvedBucket, Key: finalKey, + ExpectedBucketOwner: expectedBucketOwner, }, ); if (uploadedObjectSize && uploadedObjectSize !== size) { @@ -209,8 +276,8 @@ export const getMultipartUploadHandlers = ( } } - if (uploadCacheKey) { - await removeCachedUpload(uploadCacheKey); + if (resumableUploadsCache && uploadCacheKey) { + await removeCachedUpload(resumableUploadsCache, uploadCacheKey); } const result = { @@ -256,14 +323,15 @@ export const getMultipartUploadHandlers = ( const cancelUpload = async () => { // 2. clear upload cache. - if (uploadCacheKey) { - await removeCachedUpload(uploadCacheKey); + if (uploadCacheKey && resumableUploadsCache) { + await removeCachedUpload(resumableUploadsCache, uploadCacheKey); } // 3. clear multipart upload on server side. await abortMultipartUpload(resolvedS3Config!, { Bucket: resolvedBucket, Key: finalKey, UploadId: inProgressUpload?.uploadId, + ExpectedBucketOwner: expectedBucketOwner, }); }; cancelUpload().catch(e => { @@ -289,3 +357,23 @@ const resolveAccessLevel = (accessLevel?: StorageAccessLevel) => accessLevel ?? Amplify.libraryOptions.Storage?.S3?.defaultAccessLevel ?? DEFAULT_ACCESS_LEVEL; + +const validateCompletedParts = (completedParts: Part[], size: number) => { + const partsExpected = Math.ceil(size / calculatePartSize(size)); + const validPartCount = completedParts.length === partsExpected; + + const sorted = sortUploadParts(completedParts); + const validPartNumbers = sorted.every( + (part, index) => part.PartNumber === index + 1, + ); + + if (!validPartCount || !validPartNumbers) { + throw new IntegrityError(); + } +}; + +const sortUploadParts = (parts: Part[]) => { + return [...parts].sort( + (partA, partB) => partA.PartNumber! - partB.PartNumber!, + ); +}; diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadPartExecutor.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts similarity index 61% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/uploadPartExecutor.ts rename to packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts index c93d791aad3..34a6a8d1ae0 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadPartExecutor.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts @@ -1,11 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { TransferProgressEvent } from '../../../../../types'; -import { ResolvedS3Config } from '../../../types/options'; -import { calculateContentMd5 } from '../../../utils'; -import { uploadPart } from '../../../utils/client'; -import { logger } from '../../../../../utils'; +import { TransferProgressEvent } from '../../../../../../types'; +import { ResolvedS3Config } from '../../../../types/options'; +import { uploadPart } from '../../../../utils/client/s3data'; +import { logger } from '../../../../../../utils'; +import { CRC32Checksum, calculateContentCRC32 } from '../../../../utils/crc32'; +import { calculateContentMd5 } from '../../../../utils'; import { PartToUpload } from './getDataChunker'; @@ -18,8 +19,14 @@ interface UploadPartExecutorOptions { finalKey: string; uploadId: string; isObjectLockEnabled?: boolean; - onPartUploadCompletion(partNumber: number, eTag: string): void; + useCRC32Checksum?: boolean; + onPartUploadCompletion( + partNumber: number, + eTag: string, + crc32: string | undefined, + ): void; onProgress?(event: TransferProgressEvent): void; + expectedBucketOwner?: string; } export const uploadPartExecutor = async ({ @@ -33,6 +40,8 @@ export const uploadPartExecutor = async ({ onPartUploadCompletion, onProgress, isObjectLockEnabled, + useCRC32Checksum, + expectedBucketOwner, }: UploadPartExecutorOptions) => { let transferredBytes = 0; for (const { data, partNumber, size } of dataChunkerGenerator) { @@ -49,6 +58,16 @@ export const uploadPartExecutor = async ({ }); } else { // handle cancel error + let checksumCRC32: CRC32Checksum | undefined; + if (useCRC32Checksum) { + checksumCRC32 = await calculateContentCRC32(data); + } + const contentMD5 = + // check if checksum exists. ex: should not exist in react native + !checksumCRC32 && isObjectLockEnabled + ? await calculateContentMd5(data) + : undefined; + const { ETag: eTag } = await uploadPart( { ...s3Config, @@ -66,14 +85,14 @@ export const uploadPartExecutor = async ({ UploadId: uploadId, Body: data, PartNumber: partNumber, - ContentMD5: isObjectLockEnabled - ? await calculateContentMd5(data) - : undefined, + ChecksumCRC32: checksumCRC32?.checksum, + ContentMD5: contentMD5, + ExpectedBucketOwner: expectedBucketOwner, }, ); transferredBytes += size; // eTag will always be set even the S3 model interface marks it as optional. - onPartUploadCompletion(partNumber, eTag!); + onPartUploadCompletion(partNumber, eTag!, checksumCRC32?.checksum); } } }; diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts new file mode 100644 index 00000000000..340cd5d610a --- /dev/null +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { StorageAction } from '@aws-amplify/core/internals/utils'; + +import { UploadDataInput } from '../../../types'; +// TODO: Remove this interface when we move to public advanced APIs. +import { UploadDataInput as UploadDataWithPathInputWithAdvancedOptions } from '../../../../../internals/types/inputs'; +import { + calculateContentMd5, + resolveS3ConfigAndInput, + validateBucketOwnerID, + validateStorageOperationInput, +} from '../../../utils'; +import { ItemWithKey, ItemWithPath } from '../../../types/outputs'; +import { putObject } from '../../../utils/client/s3data'; +import { getStorageUserAgentValue } from '../../../utils/userAgent'; +import { + CHECKSUM_ALGORITHM_CRC32, + STORAGE_INPUT_KEY, +} from '../../../utils/constants'; +import { calculateContentCRC32 } from '../../../utils/crc32'; +import { constructContentDisposition } from '../../../utils/constructContentDisposition'; + +/** + * The input interface for UploadData API with only the options needed for single part upload. + * It supports both legacy Gen 1 input with key and Gen2 input with path. It also support additional + * advanced options for StorageBrowser. + * + * @internal + */ +export type SinglePartUploadDataInput = + | UploadDataInput + | UploadDataWithPathInputWithAdvancedOptions; + +/** + * Get a function the returns a promise to call putObject API to S3. + * + * @internal + */ +export const putObjectJob = + ( + uploadDataInput: SinglePartUploadDataInput, + abortSignal: AbortSignal, + totalLength: number, + ) => + async (): Promise => { + const { options: uploadDataOptions, data } = uploadDataInput; + const { bucket, keyPrefix, s3Config, isObjectLockEnabled, identityId } = + await resolveS3ConfigAndInput(Amplify, uploadDataInput); + const { inputType, objectKey } = validateStorageOperationInput( + uploadDataInput, + identityId, + ); + validateBucketOwnerID(uploadDataOptions?.expectedBucketOwner); + + const finalKey = + inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; + const { + contentDisposition, + contentEncoding, + contentType = 'application/octet-stream', + preventOverwrite, + metadata, + checksumAlgorithm, + onProgress, + expectedBucketOwner, + } = uploadDataOptions ?? {}; + + const checksumCRC32 = + checksumAlgorithm === CHECKSUM_ALGORITHM_CRC32 + ? await calculateContentCRC32(data) + : undefined; + + const contentMD5 = + // check if checksum exists. ex: should not exist in react native + !checksumCRC32 && isObjectLockEnabled + ? await calculateContentMd5(data) + : undefined; + + const { ETag: eTag, VersionId: versionId } = await putObject( + { + ...s3Config, + abortSignal, + onUploadProgress: onProgress, + userAgentValue: getStorageUserAgentValue(StorageAction.UploadData), + }, + { + Bucket: bucket, + Key: finalKey, + Body: data, + ContentType: contentType, + ContentDisposition: constructContentDisposition(contentDisposition), + ContentEncoding: contentEncoding, + Metadata: metadata, + ContentMD5: contentMD5, + ChecksumCRC32: checksumCRC32?.checksum, + ExpectedBucketOwner: expectedBucketOwner, + IfNoneMatch: preventOverwrite ? '*' : undefined, + }, + ); + + const result = { + eTag, + versionId, + contentType, + metadata, + size: totalLength, + }; + + return inputType === STORAGE_INPUT_KEY + ? { key: objectKey, ...result } + : { path: objectKey, ...result }; + }; diff --git a/packages/storage/src/providers/s3/apis/uploadData/index.ts b/packages/storage/src/providers/s3/apis/uploadData.ts similarity index 71% rename from packages/storage/src/providers/s3/apis/uploadData/index.ts rename to packages/storage/src/providers/s3/apis/uploadData.ts index 39ccdac89a9..b6173d3777e 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/index.ts +++ b/packages/storage/src/providers/s3/apis/uploadData.ts @@ -1,20 +1,16 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { defaultStorage } from '@aws-amplify/core'; + import { UploadDataInput, UploadDataOutput, UploadDataWithPathInput, UploadDataWithPathOutput, -} from '../../types'; -import { createUploadTask } from '../../utils'; -import { assertValidationError } from '../../../../errors/utils/assertValidationError'; -import { StorageValidationErrorCode } from '../../../../errors/types/validation'; -import { DEFAULT_PART_SIZE, MAX_OBJECT_SIZE } from '../../utils/constants'; +} from '../types'; -import { byteLength } from './byteLength'; -import { putObjectJob } from './putObjectJob'; -import { getMultipartUploadHandlers } from './multipart'; +import { uploadData as uploadDataInternal } from './internal/uploadData'; /** * Upload data to the specified S3 object path. By default uses single PUT operation to upload if the payload is less than 5MB. @@ -127,38 +123,13 @@ export function uploadData( export function uploadData(input: UploadDataInput): UploadDataOutput; export function uploadData(input: UploadDataInput | UploadDataWithPathInput) { - const { data } = input; - - const dataByteLength = byteLength(data); - assertValidationError( - dataByteLength === undefined || dataByteLength <= MAX_OBJECT_SIZE, - StorageValidationErrorCode.ObjectIsTooLarge, - ); - - if (dataByteLength !== undefined && dataByteLength <= DEFAULT_PART_SIZE) { - // Single part upload - const abortController = new AbortController(); - - return createUploadTask({ - isMultipartUpload: false, - job: putObjectJob(input, abortController.signal, dataByteLength), - onCancel: (message?: string) => { - abortController.abort(message); - }, - }); - } else { - // Multipart upload - const { multipartUploadJob, onPause, onResume, onCancel } = - getMultipartUploadHandlers(input, dataByteLength); - - return createUploadTask({ - isMultipartUpload: true, - job: multipartUploadJob, - onCancel: (message?: string) => { - onCancel(message); - }, - onPause, - onResume, - }); - } + return uploadDataInternal({ + ...input, + options: { + ...input?.options, + // This option enables caching in-progress multipart uploads. + // It's ONLY needed for client-side API. + resumableUploadsCache: defaultStorage, + }, + }); } diff --git a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts deleted file mode 100644 index 262a046ac71..00000000000 --- a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Amplify } from '@aws-amplify/core'; -import { StorageAction } from '@aws-amplify/core/internals/utils'; - -import { UploadDataInput, UploadDataWithPathInput } from '../../types'; -import { - calculateContentMd5, - resolveS3ConfigAndInput, - validateStorageOperationInput, -} from '../../utils'; -import { ItemWithKey, ItemWithPath } from '../../types/outputs'; -import { putObject } from '../../utils/client'; -import { getStorageUserAgentValue } from '../../utils/userAgent'; -import { STORAGE_INPUT_KEY } from '../../utils/constants'; -import { constructContentDisposition } from '../../utils/constructContentDisposition'; - -/** - * Get a function the returns a promise to call putObject API to S3. - * - * @internal - */ -export const putObjectJob = - ( - uploadDataInput: UploadDataInput | UploadDataWithPathInput, - abortSignal: AbortSignal, - totalLength?: number, - ) => - async (): Promise => { - const { options: uploadDataOptions, data } = uploadDataInput; - const { bucket, keyPrefix, s3Config, isObjectLockEnabled, identityId } = - await resolveS3ConfigAndInput(Amplify, uploadDataOptions); - const { inputType, objectKey } = validateStorageOperationInput( - uploadDataInput, - identityId, - ); - - const finalKey = - inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; - const { - contentDisposition, - contentEncoding, - contentType = 'application/octet-stream', - metadata, - onProgress, - } = uploadDataOptions ?? {}; - - const { ETag: eTag, VersionId: versionId } = await putObject( - { - ...s3Config, - abortSignal, - onUploadProgress: onProgress, - userAgentValue: getStorageUserAgentValue(StorageAction.UploadData), - }, - { - Bucket: bucket, - Key: finalKey, - Body: data, - ContentType: contentType, - ContentDisposition: constructContentDisposition(contentDisposition), - ContentEncoding: contentEncoding, - Metadata: metadata, - ContentMD5: isObjectLockEnabled - ? await calculateContentMd5(data) - : undefined, - }, - ); - - const result = { - eTag, - versionId, - contentType, - metadata, - size: totalLength, - }; - - return inputType === STORAGE_INPUT_KEY - ? { key: objectKey, ...result } - : { path: objectKey, ...result }; - }; diff --git a/packages/storage/src/providers/s3/index.ts b/packages/storage/src/providers/s3/index.ts index 2ec8bb61527..41023940aac 100644 --- a/packages/storage/src/providers/s3/index.ts +++ b/packages/storage/src/providers/s3/index.ts @@ -48,3 +48,5 @@ export { GetUrlOutput, GetUrlWithPathOutput, } from './types/outputs'; + +export { DEFAULT_PART_SIZE } from './utils/constants'; diff --git a/packages/storage/src/providers/s3/types/options.ts b/packages/storage/src/providers/s3/types/options.ts index 9a608c6dd2b..39891185185 100644 --- a/packages/storage/src/providers/s3/types/options.ts +++ b/packages/storage/src/providers/s3/types/options.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { StorageAccessLevel } from '@aws-amplify/core'; -import { SigningOptions } from '@aws-amplify/core/internals/aws-client-utils'; +import { + CredentialsProviderOptions, + SigningOptions, +} from '@aws-amplify/core/internals/aws-client-utils'; +import { AWSCredentials } from '@aws-amplify/core/internals/utils'; import { TransferProgressEvent } from '../../../types'; import { @@ -11,9 +15,32 @@ import { StorageSubpathStrategy, } from '../../../types/options'; +/** + * @internal + */ +export type AWSTemporaryCredentials = Required< + Pick< + AWSCredentials, + 'accessKeyId' | 'secretAccessKey' | 'sessionToken' | 'expiration' + > +>; + +/** + * Async function returning AWS credentials for an API call. This function + * is invoked with S3 locations(bucket and path). + * If omitted, the global credentials configured in Amplify Auth + * would be used. + * + * @internal + */ +export type LocationCredentialsProvider = ( + options?: CredentialsProviderOptions, +) => Promise<{ credentials: AWSTemporaryCredentials }>; + export interface BucketInfo { bucketName: string; region: string; + paths?: Record>; } export type StorageBucket = string | BucketInfo; @@ -23,7 +50,13 @@ interface CommonOptions { * @default false */ useAccelerateEndpoint?: boolean; + bucket?: StorageBucket; + + /** + * The expected owner of the target bucket. + */ + expectedBucketOwner?: string; } /** @@ -165,6 +198,8 @@ export type DownloadDataOptions = CommonOptions & export type DownloadDataWithKeyOptions = ReadOptions & DownloadDataOptions; export type DownloadDataWithPathOptions = DownloadDataOptions; +export type UploadDataChecksumAlgorithm = 'crc-32'; + export type UploadDataOptions = CommonOptions & TransferOptions & { /** @@ -190,6 +225,17 @@ export type UploadDataOptions = CommonOptions & * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata */ metadata?: Record; + /** + * Enforces target key does not already exist in S3 before committing upload. + * @default false + */ + preventOverwrite?: boolean; + /** + * The algorithm used to compute a checksum for the object. Used to verify that the data received by S3 + * matches what was originally sent. Disabled by default. + * @default undefined + */ + checksumAlgorithm?: UploadDataChecksumAlgorithm; }; /** @deprecated Use {@link UploadDataWithPathOptions} instead. */ @@ -201,6 +247,9 @@ export type CopySourceWithKeyOptions = ReadOptions & { /** @deprecated This may be removed in the next major version. */ key: string; bucket?: StorageBucket; + notModifiedSince?: Date; + eTag?: string; + expectedBucketOwner?: string; }; /** @deprecated This may be removed in the next major version. */ @@ -208,13 +257,19 @@ export type CopyDestinationWithKeyOptions = WriteOptions & { /** @deprecated This may be removed in the next major version. */ key: string; bucket?: StorageBucket; + expectedBucketOwner?: string; }; export interface CopyWithPathSourceOptions { bucket?: StorageBucket; + notModifiedSince?: Date; + eTag?: string; + expectedBucketOwner?: string; } + export interface CopyWithPathDestinationOptions { bucket?: StorageBucket; + expectedBucketOwner?: string; } /** diff --git a/packages/storage/src/providers/s3/utils/client/s3control/base.ts b/packages/storage/src/providers/s3/utils/client/s3control/base.ts new file mode 100644 index 00000000000..721ec7e3b9a --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/s3control/base.ts @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyUrl, + getAmplifyUserAgent, +} from '@aws-amplify/core/internals/utils'; +import { + EndpointResolverOptions, + getDnsSuffix, + jitteredBackoff, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { createRetryDecider, createXmlErrorParser } from '../utils'; +import { assertValidationError } from '../../../../../errors/utils/assertValidationError'; +import { StorageValidationErrorCode } from '../../../../../errors/types/validation'; + +/** + * The service name used to sign requests if the API requires authentication. + */ +export const SERVICE_NAME = 's3'; + +/** + * Options for endpoint resolver. + * + * @internal + */ +export type S3EndpointResolverOptions = EndpointResolverOptions & { + /** + * Fully qualified custom endpoint for S3. If this is set, this endpoint will be used regardless of region. + * + * A fully qualified custom endpoint for S3. If set, this endpoint will override + * the default S3 control endpoint and be used regardless of the specified region configuration. + * + * Refer to AWS documentation for more details on available endpoints: + * https://docs.aws.amazon.com/general/latest/gr/s3.html#s3_region + * + * @example + * ```ts + * // Examples of S3 custom endpoints + * const endpoint1 = "s3-control.us-east-2.amazonaws.com"; + * const endpoint2 = "s3-control.dualstack.us-east-2.amazonaws.com"; + * const endpoint3 = "s3-control-fips.dualstack.us-east-2.amazonaws.com"; + * ``` + */ + customEndpoint?: string; +}; + +/** + * The endpoint resolver function that returns the endpoint URL for a given region, and input parameters. + */ +const endpointResolver = ( + options: S3EndpointResolverOptions, + apiInput: { AccountId: string }, +) => { + const { region, customEndpoint } = options; + const { AccountId: accountId } = apiInput; + let endpoint: URL; + + if (customEndpoint) { + assertValidationError( + !customEndpoint.includes('://'), + StorageValidationErrorCode.InvalidCustomEndpoint, + ); + endpoint = new AmplifyUrl(`https://${accountId}.${customEndpoint}`); + } else { + endpoint = new AmplifyUrl( + `https://${accountId}.s3-control.${region}.${getDnsSuffix(region)}`, + ); + } + + return { url: endpoint }; +}; + +/** + * Error parser for the XML payload of S3 control plane error response. The + * error's `Code` and `Message` locates at the nested `Error` element instead of + * the XML root element. + * + * @example + * ``` + * + * + * + * AccessDenied + * Access Denied + * + * 656c76696e6727732072657175657374 + * Uuag1LuByRx9e6j5Onimru9pO4ZVKnJ2Qz7/C1NPcfTWAtRPfTaOFg== + * + * ``` + * + * @internal + */ +export const parseXmlError = createXmlErrorParser(); + +/** + * @internal + */ +export const retryDecider = createRetryDecider(parseXmlError); + +/** + * @internal + */ +export const defaultConfig = { + service: SERVICE_NAME, + endpointResolver, + retryDecider, + computeDelay: jitteredBackoff, + userAgentValue: getAmplifyUserAgent(), + uriEscapePath: false, // Required by S3. See https://github.com/aws/aws-sdk-js-v3/blob/9ba012dfa3a3429aa2db0f90b3b0b3a7a31f9bc3/packages/signature-v4/src/SignatureV4.ts#L76-L83 +}; diff --git a/packages/storage/src/providers/s3/utils/client/s3control/getDataAccess.ts b/packages/storage/src/providers/s3/utils/client/s3control/getDataAccess.ts new file mode 100644 index 00000000000..84adb14e8aa --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/s3control/getDataAccess.ts @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Endpoint, + HttpRequest, + HttpResponse, + parseMetadata, +} from '@aws-amplify/core/internals/aws-client-utils'; +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; +import { + AmplifyUrl, + AmplifyUrlSearchParams, +} from '@aws-amplify/core/internals/utils'; + +import { + assignStringVariables, + buildStorageServiceError, + deserializeTimestamp, + map, + parseXmlBody, + s3TransferHandler, +} from '../utils'; + +import type { + GetDataAccessCommandInput, + GetDataAccessCommandOutput, +} from './types'; +import { defaultConfig, parseXmlError } from './base'; + +export type GetDataAccessInput = GetDataAccessCommandInput; + +export type GetDataAccessOutput = GetDataAccessCommandOutput; + +const getDataAccessSerializer = ( + input: GetDataAccessInput, + endpoint: Endpoint, +): HttpRequest => { + const headers = assignStringVariables({ + 'x-amz-account-id': input.AccountId, + }); + const query = assignStringVariables({ + durationSeconds: input.DurationSeconds, + permission: input.Permission, + privilege: input.Privilege, + target: input.Target, + targetType: input.TargetType, + }); + const url = new AmplifyUrl(endpoint.url.toString()); + url.search = new AmplifyUrlSearchParams(query).toString(); + + // Ref: https://docs.aws.amazon.com/AmazonS3/latest/API/API_control_GetDataAccess.html + url.pathname = '/v20180820/accessgrantsinstance/dataaccess'; + + return { + method: 'GET', + headers, + url, + }; +}; + +const getDataAccessDeserializer = async ( + response: HttpResponse, +): Promise => { + if (response.statusCode >= 300) { + // error is always set when statusCode >= 300 + const error = (await parseXmlError(response)) as Error; + throw buildStorageServiceError(error, response.statusCode); + } else { + const parsed = await parseXmlBody(response); + const contents = map(parsed, { + Credentials: ['Credentials', deserializeCredentials], + MatchedGrantTarget: 'MatchedGrantTarget', + }); + + return { + $metadata: parseMetadata(response), + ...contents, + }; + } +}; + +const deserializeCredentials = (output: any) => + map(output, { + AccessKeyId: 'AccessKeyId', + Expiration: ['Expiration', deserializeTimestamp], + SecretAccessKey: 'SecretAccessKey', + SessionToken: 'SessionToken', + }); + +export const getDataAccess = composeServiceApi( + s3TransferHandler, + getDataAccessSerializer, + getDataAccessDeserializer, + { ...defaultConfig, responseType: 'text' }, +); diff --git a/packages/storage/src/providers/s3/utils/client/s3control/index.ts b/packages/storage/src/providers/s3/utils/client/s3control/index.ts new file mode 100644 index 00000000000..b9ae5230334 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/s3control/index.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + getDataAccess, + GetDataAccessInput, + GetDataAccessOutput, +} from '../s3control/getDataAccess'; +export { + listCallerAccessGrants, + ListCallerAccessGrantsInput, + ListCallerAccessGrantsOutput, +} from '../s3control/listCallerAccessGrants'; diff --git a/packages/storage/src/providers/s3/utils/client/s3control/listCallerAccessGrants.ts b/packages/storage/src/providers/s3/utils/client/s3control/listCallerAccessGrants.ts new file mode 100644 index 00000000000..5c4b3b71d8c --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/s3control/listCallerAccessGrants.ts @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Endpoint, + HttpRequest, + HttpResponse, + parseMetadata, +} from '@aws-amplify/core/internals/aws-client-utils'; +import { + AmplifyUrl, + AmplifyUrlSearchParams, +} from '@aws-amplify/core/internals/utils'; +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + assignStringVariables, + buildStorageServiceError, + emptyArrayGuard, + map, + parseXmlBody, + s3TransferHandler, +} from '../utils'; +import { createStringEnumDeserializer } from '../utils/deserializeHelpers'; + +import type { + ListCallerAccessGrantsCommandInput, + ListCallerAccessGrantsCommandOutput, +} from './types'; +import { defaultConfig, parseXmlError } from './base'; + +export type ListCallerAccessGrantsInput = Pick< + ListCallerAccessGrantsCommandInput, + | 'AccountId' + | 'AllowedByApplication' + | 'GrantScope' + | 'NextToken' + | 'MaxResults' +>; + +export type ListCallerAccessGrantsOutput = ListCallerAccessGrantsCommandOutput; + +const listCallerAccessGrantsSerializer = ( + input: ListCallerAccessGrantsInput, + endpoint: Endpoint, +): HttpRequest => { + const headers = assignStringVariables({ + 'x-amz-account-id': input.AccountId, + }); + const query = assignStringVariables({ + grantscope: input.GrantScope, + maxResults: input.MaxResults, + nextToken: input.NextToken, + allowedByApplication: input.AllowedByApplication, + }); + const url = new AmplifyUrl(endpoint.url.toString()); + url.search = new AmplifyUrlSearchParams(query).toString(); + + // Ref: https://docs.aws.amazon.com/AmazonS3/latest/API/API_control_ListCallerAccessGrants.html + url.pathname = '/v20180820/accessgrantsinstance/caller/grants'; + + return { + method: 'GET', + headers, + url, + }; +}; + +const listCallerAccessGrantsDeserializer = async ( + response: HttpResponse, +): Promise => { + if (response.statusCode >= 300) { + // error is always set when statusCode >= 300 + const error = (await parseXmlError(response)) as Error; + throw buildStorageServiceError(error, response.statusCode); + } else { + const parsed = await parseXmlBody(response); + const contents = map(parsed, { + CallerAccessGrantsList: [ + 'CallerAccessGrantsList', + value => + emptyArrayGuard(value.AccessGrant, deserializeAccessGrantsList), + ], + NextToken: 'NextToken', + }); + + return { + $metadata: parseMetadata(response), + ...contents, + }; + } +}; + +const deserializeAccessGrantsList = (output: any[]) => + output.map(deserializeCallerAccessGrant); + +const deserializeCallerAccessGrant = (output: any) => + map(output, { + ApplicationArn: 'ApplicationArn', + GrantScope: 'GrantScope', + Permission: [ + 'Permission', + createStringEnumDeserializer( + ['READ', 'READWRITE', 'WRITE'] as const, + 'Permission', + ), + ], + }); + +export const listCallerAccessGrants = composeServiceApi( + s3TransferHandler, + listCallerAccessGrantsSerializer, + listCallerAccessGrantsDeserializer, + { ...defaultConfig, responseType: 'text' }, +); diff --git a/packages/storage/src/providers/s3/utils/client/s3control/types.ts b/packages/storage/src/providers/s3/utils/client/s3control/types.ts new file mode 100644 index 00000000000..612875980e4 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/s3control/types.ts @@ -0,0 +1,246 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Generated by scripts/dts-bundler/README.md + */ + +import { MetadataBearer as __MetadataBearer } from '@aws-sdk/types'; + +declare const Permission: { + readonly READ: 'READ'; + readonly READWRITE: 'READWRITE'; + readonly WRITE: 'WRITE'; +}; +declare const Privilege: { + readonly Default: 'Default'; + readonly Minimal: 'Minimal'; +}; +declare const S3PrefixType: { + readonly Object: 'Object'; +}; + +/** + *

      The Amazon Web Services Security Token Service temporary credential that S3 Access Grants vends to grantees and client applications.

      + * @public + */ +export interface Credentials { + /** + *

      The unique access key ID of the Amazon Web Services STS temporary credential that S3 Access Grants vends to grantees and client applications.

      + * @public + */ + AccessKeyId?: string; + /** + *

      The secret access key of the Amazon Web Services STS temporary credential that S3 Access Grants vends to grantees and client applications.

      + * @public + */ + SecretAccessKey?: string; + /** + *

      The Amazon Web Services STS temporary credential that S3 Access Grants vends to grantees and client applications.

      + * @public + */ + SessionToken?: string; + /** + *

      The expiration date and time of the temporary credential that S3 Access Grants vends to grantees and client applications.

      + * @public + */ + Expiration?: Date; +} +/** + * @public + * + * The input for {@link GetDataAccessCommand}. + */ +export interface GetDataAccessCommandInput extends GetDataAccessRequest {} +/** + * @public + * + * The output of {@link GetDataAccessCommand}. + */ +export interface GetDataAccessCommandOutput + extends GetDataAccessResult, + __MetadataBearer {} +/** + * @public + */ +export interface GetDataAccessRequest { + /** + *

      The Amazon Web Services account ID of the S3 Access Grants instance.

      + * @public + */ + AccountId?: string; + /** + *

      The S3 URI path of the data to which you are requesting temporary access credentials. If the requesting account has an access grant for this data, S3 Access Grants vends temporary access credentials in the response.

      + * @public + */ + Target: string | undefined; + /** + *

      The type of permission granted to your S3 data, which can be set to one of the following values:

      + *
        + *
      • + *

        + * READ – Grant read-only access to the S3 data.

        + *
      • + *
      • + *

        + * WRITE – Grant write-only access to the S3 data.

        + *
      • + *
      • + *

        + * READWRITE – Grant both read and write access to the S3 data.

        + *
      • + *
      + * @public + */ + Permission: Permission | undefined; + /** + *

      The session duration, in seconds, of the temporary access credential that S3 Access Grants vends to the grantee or client application. The default value is 1 hour, but the grantee can specify a range from 900 seconds (15 minutes) up to 43200 seconds (12 hours). If the grantee requests a value higher than this maximum, the operation fails.

      + * @public + */ + DurationSeconds?: number; + /** + *

      The scope of the temporary access credential that S3 Access Grants vends to the grantee or client application.

      + *
        + *
      • + *

        + * Default – The scope of the returned temporary access token is the scope of the grant that is closest to the target scope.

        + *
      • + *
      • + *

        + * Minimal – The scope of the returned temporary access token is the same as the requested target scope as long as the requested scope is the same as or a subset of the grant scope.

        + *
      • + *
      + * @public + */ + Privilege?: Privilege; + /** + *

      The type of Target. The only possible value is Object. Pass this value if the target data that you would like to access is a path to an object. Do not pass this value if the target data is a bucket or a bucket and a prefix.

      + * @public + */ + TargetType?: S3PrefixType; +} +/** + * @public + */ +export interface GetDataAccessResult { + /** + *

      The temporary credential token that S3 Access Grants vends.

      + * @public + */ + Credentials?: Credentials; + /** + *

      The S3 URI path of the data to which you are being granted temporary access credentials.

      + * @public + */ + MatchedGrantTarget?: string; +} +/** + * @public + * + * The input for {@link ListCallerAccessGrantsCommand}. + */ +export interface ListCallerAccessGrantsCommandInput + extends ListCallerAccessGrantsRequest {} +/** + * @public + * + * The output of {@link ListCallerAccessGrantsCommand}. + */ +export interface ListCallerAccessGrantsCommandOutput + extends ListCallerAccessGrantsResult, + __MetadataBearer {} +/** + *

      Part of ListCallerAccessGrantsResult. Each entry includes the + * permission level (READ, WRITE, or READWRITE) and the grant scope of the access grant. If the grant also includes an application ARN, the grantee can only access the S3 data through this application.

      + * @public + */ +export interface ListCallerAccessGrantsEntry { + /** + *

      The type of permission granted, which can be one of the following values:

      + *
        + *
      • + *

        + * READ - Grants read-only access to the S3 data.

        + *
      • + *
      • + *

        + * WRITE - Grants write-only access to the S3 data.

        + *
      • + *
      • + *

        + * READWRITE - Grants both read and write access to the S3 data.

        + *
      • + *
      + * @public + */ + Permission?: Permission; + /** + *

      The S3 path of the data to which you have been granted access.

      + * @public + */ + GrantScope?: string; + /** + *

      The Amazon Resource Name (ARN) of an Amazon Web Services IAM Identity Center application associated with your Identity Center instance. If the grant includes an application ARN, the grantee can only access the S3 data through this application.

      + * @public + */ + ApplicationArn?: string; +} +/** + * @public + */ +export interface ListCallerAccessGrantsRequest { + /** + *

      The Amazon Web Services account ID of the S3 Access Grants instance.

      + * @public + */ + AccountId?: string; + /** + *

      The S3 path of the data that you would like to access. Must start with s3://. You can optionally pass only the beginning characters of a path, and S3 Access Grants will search for all applicable grants for the path fragment.

      + * @public + */ + GrantScope?: string; + /** + *

      A pagination token to request the next page of results. Pass this value into a subsequent List Caller Access Grants request in order to retrieve the next page of results.

      + * @public + */ + NextToken?: string; + /** + *

      The maximum number of access grants that you would like returned in the List Caller Access Grants response. If the results include the pagination token NextToken, make another call using the NextToken to determine if there are more results.

      + * @public + */ + MaxResults?: number; + /** + *

      If this optional parameter is passed in the request, a filter is applied to the results. The results will include only the access grants for the caller's Identity Center application or for any other applications (ALL).

      + * @public + */ + AllowedByApplication?: boolean; +} +/** + * @public + */ +export interface ListCallerAccessGrantsResult { + /** + *

      A pagination token that you can use to request the next page of results. Pass this value into a subsequent List Caller Access Grants request in order to retrieve the next page of results.

      + * @public + */ + NextToken?: string; + /** + *

      A list of the caller's access grants that were created using S3 Access Grants and that grant the caller access to the S3 data of the Amazon Web Services account ID that was specified in the request.

      + * @public + */ + CallerAccessGrantsList?: ListCallerAccessGrantsEntry[]; +} +/** + * @public + */ +export type Permission = (typeof Permission)[keyof typeof Permission]; +/** + * @public + */ +export type Privilege = (typeof Privilege)[keyof typeof Privilege]; +/** + * @public + */ +export type S3PrefixType = (typeof S3PrefixType)[keyof typeof S3PrefixType]; + +export {}; diff --git a/packages/storage/src/providers/s3/utils/client/abortMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts similarity index 80% rename from packages/storage/src/providers/s3/utils/client/abortMultipartUpload.ts rename to packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts index bddaf570d0e..83221ab22e9 100644 --- a/packages/storage/src/providers/s3/utils/client/abortMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts @@ -14,19 +14,21 @@ import { } from '@aws-amplify/core/internals/utils'; import { MetadataBearer } from '@aws-sdk/types'; -import type { AbortMultipartUploadCommandInput } from './types'; -import { defaultConfig } from './base'; import { + assignStringVariables, buildStorageServiceError, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import type { AbortMultipartUploadCommandInput } from './types'; +import { defaultConfig, parseXmlError } from './base'; export type AbortMultipartUploadInput = Pick< AbortMultipartUploadCommandInput, - 'Bucket' | 'Key' | 'UploadId' + 'Bucket' | 'Key' | 'UploadId' | 'ExpectedBucketOwner' >; export type AbortMultipartUploadOutput = MetadataBearer; @@ -42,10 +44,20 @@ const abortMultipartUploadSerializer = ( url.search = new AmplifyUrlSearchParams({ uploadId: input.UploadId, }).toString(); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); + const headers = { + ...assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), + }; return { method: 'DELETE', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/base.ts b/packages/storage/src/providers/s3/utils/client/s3data/base.ts similarity index 57% rename from packages/storage/src/providers/s3/utils/client/base.ts rename to packages/storage/src/providers/s3/utils/client/s3data/base.ts index 96f0e5958ef..fdf6160d077 100644 --- a/packages/storage/src/providers/s3/utils/client/base.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/base.ts @@ -8,11 +8,13 @@ import { import { EndpointResolverOptions, getDnsSuffix, - getRetryDecider, jitteredBackoff, } from '@aws-amplify/core/internals/aws-client-utils'; -import { parseXmlError } from './utils'; +import { createRetryDecider, createXmlErrorParser } from '../utils'; +import { LOCAL_TESTING_S3_ENDPOINT } from '../../constants'; +import { assertValidationError } from '../../../../../errors/utils/assertValidationError'; +import { StorageValidationErrorCode } from '../../../../../errors/types/validation'; const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/; const IP_ADDRESS_PATTERN = /(\d+\.){3}\d+/; @@ -34,12 +36,22 @@ export type S3EndpointResolverOptions = EndpointResolverOptions & { */ useAccelerateEndpoint?: boolean; /** - * Fully qualified custom endpoint for S3. If this is set, this endpoint will be used regardless of region or - * useAccelerateEndpoint config. - * The path of this endpoint + * A fully qualified custom endpoint for S3. If set, this endpoint will override + * the default S3 endpoint and be used regardless of the specified region or + * `useAccelerateEndpoint` configuration. + * + * Refer to AWS documentation for more details on available endpoints: + * https://docs.aws.amazon.com/general/latest/gr/s3.html#s3_region + * + * @example + * ```ts + * // Examples of S3 custom endpoints + * const endpoint1 = "s3.us-east-2.amazonaws.com"; + * const endpoint2 = "s3.dualstack.us-east-2.amazonaws.com"; + * const endpoint3 = "s3-fips.dualstack.us-east-2.amazonaws.com"; + * ``` */ customEndpoint?: string; - /** * Whether to force path style URLs for S3 objects (e.g., https://s3.amazonaws.com// instead of * https://.s3.amazonaws.com/ @@ -60,22 +72,31 @@ const endpointResolver = ( let endpoint: URL; // 1. get base endpoint if (customEndpoint) { - endpoint = new AmplifyUrl(customEndpoint); - } else if (useAccelerateEndpoint) { - if (forcePathStyle) { - throw new Error( - 'Path style URLs are not supported with S3 Transfer Acceleration.', - ); + if (customEndpoint === LOCAL_TESTING_S3_ENDPOINT) { + endpoint = new AmplifyUrl(customEndpoint); } + assertValidationError( + !customEndpoint.includes('://'), + StorageValidationErrorCode.InvalidCustomEndpoint, + ); + endpoint = new AmplifyUrl(`https://${customEndpoint}`); + } else if (useAccelerateEndpoint) { + // this ErrorCode isn't expose yet since forcePathStyle param isn't publicly exposed + assertValidationError( + !forcePathStyle, + StorageValidationErrorCode.ForcePathStyleEndpointNotSupported, + ); endpoint = new AmplifyUrl(`https://s3-accelerate.${getDnsSuffix(region)}`); } else { endpoint = new AmplifyUrl(`https://s3.${region}.${getDnsSuffix(region)}`); } // 2. inject bucket name if (apiInput?.Bucket) { - if (!isDnsCompatibleBucketName(apiInput.Bucket)) { - throw new Error(`Invalid bucket name: "${apiInput.Bucket}".`); - } + assertValidationError( + isDnsCompatibleBucketName(apiInput.Bucket), + StorageValidationErrorCode.DnsIncompatibleBucketName, + ); + if (forcePathStyle || apiInput.Bucket.includes('.')) { endpoint.pathname = `/${apiInput.Bucket}`; } else { @@ -100,13 +121,37 @@ export const isDnsCompatibleBucketName = (bucketName: string): boolean => !IP_ADDRESS_PATTERN.test(bucketName) && !DOTS_PATTERN.test(bucketName); +/** + * Error parser for the XML payload of S3 data plane error response. The error's + * `Code` and `Message` locates directly at the XML root element. + * + * @example + * ``` + * + * + * NoSuchKey + * The resource you requested does not exist + * /mybucket/myfoto.jpg + * 4442587FB7D0A2F9 + * + * ``` + * + * @internal + */ +export const parseXmlError = createXmlErrorParser({ noErrorWrapping: true }); + +/** + * @internal + */ +export const retryDecider = createRetryDecider(parseXmlError); + /** * @internal */ export const defaultConfig = { service: SERVICE_NAME, endpointResolver, - retryDecider: getRetryDecider(parseXmlError), + retryDecider, computeDelay: jitteredBackoff, userAgentValue: getAmplifyUserAgent(), useAccelerateEndpoint: false, diff --git a/packages/storage/src/providers/s3/utils/client/completeMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts similarity index 75% rename from packages/storage/src/providers/s3/utils/client/completeMultipartUpload.ts rename to packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts index 36dd9f59a52..e7c4c516157 100644 --- a/packages/storage/src/providers/s3/utils/client/completeMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts @@ -5,6 +5,8 @@ import { Endpoint, HttpRequest, HttpResponse, + MiddlewareContext, + RetryDeciderOutput, parseMetadata, } from '@aws-amplify/core/internals/aws-client-utils'; import { @@ -13,29 +15,38 @@ import { } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { - CompleteMultipartUploadCommandInput, - CompleteMultipartUploadCommandOutput, - CompletedMultipartUpload, - CompletedPart, -} from './types'; -import { defaultConfig } from './base'; import { + assignStringVariables, buildStorageServiceError, map, parseXmlBody, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; +import { validateMultipartUploadXML } from '../../validateMultipartUploadXML'; + +import { defaultConfig, parseXmlError, retryDecider } from './base'; +import type { + CompleteMultipartUploadCommandInput, + CompleteMultipartUploadCommandOutput, + CompletedMultipartUpload, + CompletedPart, +} from './types'; const INVALID_PARAMETER_ERROR_MSG = 'Invalid parameter for ComplteMultipartUpload API'; export type CompleteMultipartUploadInput = Pick< CompleteMultipartUploadCommandInput, - 'Bucket' | 'Key' | 'UploadId' | 'MultipartUpload' + | 'Bucket' + | 'Key' + | 'UploadId' + | 'MultipartUpload' + | 'ChecksumCRC32' + | 'ExpectedBucketOwner' + | 'IfNoneMatch' >; export type CompleteMultipartUploadOutput = Pick< @@ -49,6 +60,11 @@ const completeMultipartUploadSerializer = async ( ): Promise => { const headers = { 'content-type': 'application/xml', + ...assignStringVariables({ + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + 'If-None-Match': input.IfNoneMatch, + }), }; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); @@ -58,14 +74,20 @@ const completeMultipartUploadSerializer = async ( uploadId: input.UploadId, }).toString(); validateS3RequiredParameter(!!input.MultipartUpload, 'MultipartUpload'); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); + + const xml = serializeCompletedMultipartUpload(input.MultipartUpload); + validateMultipartUploadXML(input.MultipartUpload, xml); return { method: 'POST', headers, url, - body: - '' + - serializeCompletedMultipartUpload(input.MultipartUpload), + body: '' + xml, }; }; @@ -86,7 +108,13 @@ const serializeCompletedPartList = (input: CompletedPart): string => { throw new Error(`${INVALID_PARAMETER_ERROR_MSG}: ${input}`); } - return `${input.ETag}${input.PartNumber}`; + const eTag = `${input.ETag}`; + const partNumber = `${input.PartNumber}`; + const checksumCRC32 = input.ChecksumCRC32 + ? `${input.ChecksumCRC32}` + : ''; + + return `${eTag}${partNumber}${checksumCRC32}`; }; /** @@ -135,25 +163,24 @@ const completeMultipartUploadDeserializer = async ( const retryWhenErrorWith200StatusCode = async ( response?: HttpResponse, error?: unknown, -): Promise => { + middlewareContext?: MiddlewareContext, +): Promise => { if (!response) { - return false; + return { retryable: false }; } if (response.statusCode === 200) { if (!response.body) { - return true; + return { retryable: true }; } const parsed = await parseXmlBody(response); if (parsed.Code !== undefined && parsed.Message !== undefined) { - return true; + return { retryable: true }; } - return false; + return { retryable: false }; } - const defaultRetryDecider = defaultConfig.retryDecider; - - return defaultRetryDecider(response, error); + return retryDecider(response, error, middlewareContext); }; export const completeMultipartUpload = composeServiceApi( diff --git a/packages/storage/src/providers/s3/utils/client/copyObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts similarity index 59% rename from packages/storage/src/providers/s3/utils/client/copyObject.ts rename to packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts index a08301d9f7e..f1dd2784d13 100644 --- a/packages/storage/src/providers/s3/utils/client/copyObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts @@ -10,18 +10,21 @@ import { import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { CopyObjectCommandInput, CopyObjectCommandOutput } from './types'; -import { defaultConfig } from './base'; import { assignStringVariables, + bothNilOrEqual, buildStorageServiceError, parseXmlBody, - parseXmlError, s3TransferHandler, serializeObjectConfigsToHeaders, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { IntegrityError } from '../../../../../errors/IntegrityError'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import type { CopyObjectCommandInput, CopyObjectCommandOutput } from './types'; +import { defaultConfig, parseXmlError } from './base'; export type CopyObjectInput = Pick< CopyObjectCommandInput, @@ -37,6 +40,10 @@ export type CopyObjectInput = Pick< | 'ACL' | 'Tagging' | 'Metadata' + | 'CopySourceIfUnmodifiedSince' + | 'CopySourceIfMatch' + | 'ExpectedSourceBucketOwner' + | 'ExpectedBucketOwner' >; export type CopyObjectOutput = CopyObjectCommandOutput; @@ -50,11 +57,22 @@ const copyObjectSerializer = async ( ...assignStringVariables({ 'x-amz-copy-source': input.CopySource, 'x-amz-metadata-directive': input.MetadataDirective, + 'x-amz-copy-source-if-match': input.CopySourceIfMatch, + 'x-amz-copy-source-if-unmodified-since': + input.CopySourceIfUnmodifiedSince?.toUTCString(), + 'x-amz-source-expected-bucket-owner': input.ExpectedSourceBucketOwner, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, }), }; + validateCopyObjectHeaders(input, headers); const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'PUT', @@ -63,6 +81,31 @@ const copyObjectSerializer = async ( }; }; +export const validateCopyObjectHeaders = ( + input: CopyObjectInput, + headers: Record, +) => { + const validations: boolean[] = [ + headers['x-amz-copy-source'] === input.CopySource, + bothNilOrEqual( + input.MetadataDirective, + headers['x-amz-metadata-directive'], + ), + bothNilOrEqual( + input.CopySourceIfMatch, + headers['x-amz-copy-source-if-match'], + ), + bothNilOrEqual( + input.CopySourceIfUnmodifiedSince?.toUTCString(), + headers['x-amz-copy-source-if-unmodified-since'], + ), + ]; + + if (validations.some(validation => !validation)) { + throw new IntegrityError(); + } +}; + const copyObjectDeserializer = async ( response: HttpResponse, ): Promise => { diff --git a/packages/storage/src/providers/s3/utils/client/createMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts similarity index 80% rename from packages/storage/src/providers/s3/utils/client/createMultipartUpload.ts rename to packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts index 5a2b79a9635..86a9e5cb89a 100644 --- a/packages/storage/src/providers/s3/utils/client/createMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts @@ -10,22 +10,24 @@ import { import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { - CreateMultipartUploadCommandInput, - CreateMultipartUploadCommandOutput, -} from './types'; -import type { PutObjectInput } from './putObject'; -import { defaultConfig } from './base'; import { + assignStringVariables, buildStorageServiceError, map, parseXmlBody, - parseXmlError, s3TransferHandler, serializeObjectConfigsToHeaders, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import type { + CreateMultipartUploadCommandInput, + CreateMultipartUploadCommandOutput, +} from './types'; +import type { PutObjectInput } from './putObject'; +import { defaultConfig, parseXmlError } from './base'; export type CreateMultipartUploadInput = Extract< CreateMultipartUploadCommandInput, @@ -41,11 +43,22 @@ const createMultipartUploadSerializer = async ( input: CreateMultipartUploadInput, endpoint: Endpoint, ): Promise => { - const headers = await serializeObjectConfigsToHeaders(input); + const headers = { + ...(await serializeObjectConfigsToHeaders(input)), + ...assignStringVariables({ + 'x-amz-checksum-algorithm': input.ChecksumAlgorithm, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), + }; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); url.search = 'uploads'; + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'POST', diff --git a/packages/storage/src/providers/s3/utils/client/deleteObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts similarity index 81% rename from packages/storage/src/providers/s3/utils/client/deleteObject.ts rename to packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts index 290a3e5ebf0..ebbba829d94 100644 --- a/packages/storage/src/providers/s3/utils/client/deleteObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts @@ -10,24 +10,26 @@ import { import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { - DeleteObjectCommandInput, - DeleteObjectCommandOutput, -} from './types'; -import { defaultConfig } from './base'; import { + assignStringVariables, buildStorageServiceError, deserializeBoolean, map, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import type { + DeleteObjectCommandInput, + DeleteObjectCommandOutput, +} from './types'; +import { defaultConfig, parseXmlError } from './base'; export type DeleteObjectInput = Pick< DeleteObjectCommandInput, - 'Bucket' | 'Key' + 'Bucket' | 'Key' | 'ExpectedBucketOwner' >; export type DeleteObjectOutput = DeleteObjectCommandOutput; @@ -39,10 +41,18 @@ const deleteObjectSerializer = ( const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); + const headers = assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }); return { method: 'DELETE', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/getObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts similarity index 93% rename from packages/storage/src/providers/s3/utils/client/getObject.ts rename to packages/storage/src/providers/s3/utils/client/s3data/getObject.ts index 2b4153541cd..fca84d1b570 100644 --- a/packages/storage/src/providers/s3/utils/client/getObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts @@ -11,28 +11,34 @@ import { parseMetadata, presignUrl, } from '@aws-amplify/core/internals/aws-client-utils'; -import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; +import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; -import { S3EndpointResolverOptions, defaultConfig } from './base'; -import type { - CompatibleHttpResponse, - GetObjectCommandInput, - GetObjectCommandOutput, -} from './types'; import { CONTENT_SHA256_HEADER, + assignStringVariables, buildStorageServiceError, deserializeBoolean, deserializeMetadata, deserializeNumber, deserializeTimestamp, map, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import { + S3EndpointResolverOptions, + defaultConfig, + parseXmlError, +} from './base'; +import type { + CompatibleHttpResponse, + GetObjectCommandInput, + GetObjectCommandOutput, +} from './types'; const USER_AGENT_HEADER = 'x-amz-user-agent'; @@ -43,6 +49,7 @@ export type GetObjectInput = Pick< | 'Range' | 'ResponseContentDisposition' | 'ResponseContentType' + | 'ExpectedBucketOwner' >; export type GetObjectOutput = GetObjectCommandOutput; @@ -54,11 +61,19 @@ const getObjectSerializer = async ( const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'GET', headers: { ...(input.Range && { Range: input.Range }), + ...assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), }, url, }; diff --git a/packages/storage/src/providers/s3/utils/client/headObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts similarity index 81% rename from packages/storage/src/providers/s3/utils/client/headObject.ts rename to packages/storage/src/providers/s3/utils/client/s3data/headObject.ts index 109263def26..c3fc64fb425 100644 --- a/packages/storage/src/providers/s3/utils/client/headObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts @@ -10,21 +10,26 @@ import { import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import { defaultConfig } from './base'; -import type { HeadObjectCommandInput, HeadObjectCommandOutput } from './types'; import { + assignStringVariables, buildStorageServiceError, deserializeMetadata, deserializeNumber, deserializeTimestamp, map, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import { defaultConfig, parseXmlError } from './base'; +import type { HeadObjectCommandInput, HeadObjectCommandOutput } from './types'; -export type HeadObjectInput = Pick; +export type HeadObjectInput = Pick< + HeadObjectCommandInput, + 'Bucket' | 'Key' | 'ExpectedBucketOwner' +>; export type HeadObjectOutput = Pick< HeadObjectCommandOutput, @@ -44,10 +49,18 @@ const headObjectSerializer = async ( const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); + const headers = assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }); return { method: 'HEAD', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/index.ts b/packages/storage/src/providers/s3/utils/client/s3data/index.ts similarity index 100% rename from packages/storage/src/providers/s3/utils/client/index.ts rename to packages/storage/src/providers/s3/utils/client/s3data/index.ts diff --git a/packages/storage/src/providers/s3/utils/client/listObjectsV2.ts b/packages/storage/src/providers/s3/utils/client/s3data/listObjectsV2.ts similarity index 83% rename from packages/storage/src/providers/s3/utils/client/listObjectsV2.ts rename to packages/storage/src/providers/s3/utils/client/s3data/listObjectsV2.ts index 232499931c5..6caa8a46a8e 100644 --- a/packages/storage/src/providers/s3/utils/client/listObjectsV2.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/listObjectsV2.ts @@ -13,11 +13,6 @@ import { } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { - ListObjectsV2CommandInput, - ListObjectsV2CommandOutput, -} from './types'; -import { defaultConfig } from './base'; import { assignStringVariables, buildStorageServiceError, @@ -27,9 +22,15 @@ import { emptyArrayGuard, map, parseXmlBody, - parseXmlError, s3TransferHandler, -} from './utils'; +} from '../utils'; +import { IntegrityError } from '../../../../../errors/IntegrityError'; + +import type { + ListObjectsV2CommandInput, + ListObjectsV2CommandOutput, +} from './types'; +import { defaultConfig, parseXmlError } from './base'; export type ListObjectsV2Input = ListObjectsV2CommandInput; @@ -93,10 +94,14 @@ const listObjectsV2Deserializer = async ( StartAfter: 'StartAfter', }); - return { + const output = { $metadata: parseMetadata(response), ...contents, }; + + validateCorroboratingElements(output); + + return output; } }; @@ -130,6 +135,27 @@ const deserializeChecksumAlgorithmList = (output: any[]) => const deserializeOwner = (output: any) => map(output, { DisplayName: 'DisplayName', ID: 'ID' }); +const validateCorroboratingElements = (response: ListObjectsV2Output) => { + const { + IsTruncated, + KeyCount, + Contents = [], + CommonPrefixes = [], + NextContinuationToken, + } = response; + + const validTruncation = + (IsTruncated && !!NextContinuationToken) || + (!IsTruncated && !NextContinuationToken); + + const validNumberOfKeysReturned = + KeyCount === Contents.length + CommonPrefixes.length; + + if (!validTruncation || !validNumberOfKeysReturned) { + throw new IntegrityError(); + } +}; + export const listObjectsV2 = composeServiceApi( s3TransferHandler, listObjectsV2Serializer, diff --git a/packages/storage/src/providers/s3/utils/client/listParts.ts b/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts similarity index 82% rename from packages/storage/src/providers/s3/utils/client/listParts.ts rename to packages/storage/src/providers/s3/utils/client/s3data/listParts.ts index 86899ad4e9d..0affbf7a5f5 100644 --- a/packages/storage/src/providers/s3/utils/client/listParts.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts @@ -13,23 +13,19 @@ import { } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import type { - CompletedPart, - ListPartsCommandInput, - ListPartsCommandOutput, -} from './types'; -import { defaultConfig } from './base'; import { buildStorageServiceError, - deserializeNumber, + deserializeCompletedPartList, emptyArrayGuard, map, parseXmlBody, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; + +import type { ListPartsCommandInput, ListPartsCommandOutput } from './types'; +import { defaultConfig, parseXmlError } from './base'; export type ListPartsInput = Pick< ListPartsCommandInput, @@ -84,15 +80,6 @@ const listPartsDeserializer = async ( } }; -const deserializeCompletedPartList = (input: any[]): CompletedPart[] => - input.map(item => - map(item, { - PartNumber: ['PartNumber', deserializeNumber], - ETag: 'ETag', - Size: ['Size', deserializeNumber], - }), - ); - export const listParts = composeServiceApi( s3TransferHandler, listPartsSerializer, diff --git a/packages/storage/src/providers/s3/utils/client/putObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts similarity index 81% rename from packages/storage/src/providers/s3/utils/client/putObject.ts rename to packages/storage/src/providers/s3/utils/client/s3data/putObject.ts index 86755f1c703..7b7f9c2a43e 100644 --- a/packages/storage/src/providers/s3/utils/client/putObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts @@ -10,18 +10,19 @@ import { import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import { defaultConfig } from './base'; -import type { PutObjectCommandInput, PutObjectCommandOutput } from './types'; import { assignStringVariables, buildStorageServiceError, map, - parseXmlError, s3TransferHandler, serializeObjectConfigsToHeaders, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import { defaultConfig, parseXmlError } from './base'; +import type { PutObjectCommandInput, PutObjectCommandOutput } from './types'; export type PutObjectInput = Pick< PutObjectCommandInput, @@ -37,6 +38,9 @@ export type PutObjectInput = Pick< | 'Expires' | 'Metadata' | 'Tagging' + | 'ChecksumCRC32' + | 'ExpectedBucketOwner' + | 'IfNoneMatch' >; export type PutObjectOutput = Pick< @@ -55,11 +59,21 @@ const putObjectSerializer = async ( ...input, ContentType: input.ContentType ?? 'application/octet-stream', })), - ...assignStringVariables({ 'content-md5': input.ContentMD5 }), + ...assignStringVariables({ + 'content-md5': input.ContentMD5, + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + 'If-None-Match': input.IfNoneMatch, + }), }; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'PUT', diff --git a/packages/storage/src/providers/s3/utils/client/types.ts b/packages/storage/src/providers/s3/utils/client/s3data/types.ts similarity index 98% rename from packages/storage/src/providers/s3/utils/client/types.ts rename to packages/storage/src/providers/s3/utils/client/s3data/types.ts index d4ccf20c1cd..4a9fad263f3 100644 --- a/packages/storage/src/providers/s3/utils/client/types.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/types.ts @@ -367,6 +367,13 @@ export interface CompleteMultipartUploadRequest { * Amazon S3 User Guide.

      */ SSECustomerKeyMD5?: string; + /** + *

      Uploads the object only if the object key name does not already exist in the bucket specified. Otherwise, Amazon S3 returns a 412 Precondition Failed error.

      + *

      If a conflicting operation occurs during the upload S3 returns a 409 ConditionalRequestConflict response. On a 409 failure you should re-initiate the multipart upload with CreateMultipartUpload and re-upload each part.

      + *

      Expects the '*' (asterisk) character.

      + *

      For more information about conditional requests, see RFC 7232, or Conditional requests in the Amazon S3 User Guide.

      + */ + IfNoneMatch?: string; } /** * @public @@ -2534,6 +2541,13 @@ export interface PutObjectRequest { *

      The account ID of the expected bucket owner. If the bucket is owned by a different account, the request fails with the HTTP status code 403 Forbidden (access denied).

      */ ExpectedBucketOwner?: string; + /** + *

      Uploads the object only if the object key name does not already exist in the bucket specified. Otherwise, Amazon S3 returns a 412 Precondition Failed error.

      + *

      If a conflicting operation occurs during the upload S3 returns a 409 ConditionalRequestConflict response. On a 409 failure you should retry the upload.

      + *

      Expects the '*' (asterisk) character.

      + *

      For more information about conditional requests, see RFC 7232, or Conditional requests in the Amazon S3 User Guide.

      + */ + IfNoneMatch?: string; } /** * This interface extends from `UploadPartRequest` interface. There are more parameters than `Body` defined in {@link UploadPartRequest} diff --git a/packages/storage/src/providers/s3/utils/client/uploadPart.ts b/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts similarity index 78% rename from packages/storage/src/providers/s3/utils/client/uploadPart.ts rename to packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts index 3bcacc6236f..629f352e42d 100644 --- a/packages/storage/src/providers/s3/utils/client/uploadPart.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts @@ -13,23 +13,31 @@ import { } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; -import { defaultConfig } from './base'; -import type { UploadPartCommandInput, UploadPartCommandOutput } from './types'; import { assignStringVariables, buildStorageServiceError, map, - parseXmlError, s3TransferHandler, serializePathnameObjectKey, validateS3RequiredParameter, -} from './utils'; +} from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; + +import { defaultConfig, parseXmlError } from './base'; +import type { UploadPartCommandInput, UploadPartCommandOutput } from './types'; // Content-length is ignored here because it's forbidden header // and will be set by browser or fetch polyfill. export type UploadPartInput = Pick< UploadPartCommandInput, - 'PartNumber' | 'Body' | 'UploadId' | 'Bucket' | 'Key' | 'ContentMD5' + | 'PartNumber' + | 'Body' + | 'UploadId' + | 'Bucket' + | 'Key' + | 'ContentMD5' + | 'ChecksumCRC32' + | 'ExpectedBucketOwner' >; export type UploadPartOutput = Pick< @@ -42,9 +50,13 @@ const uploadPartSerializer = async ( endpoint: Endpoint, ): Promise => { const headers = { - ...assignStringVariables({ 'content-md5': input.ContentMD5 }), + ...assignStringVariables({ + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'content-md5': input.ContentMD5, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), + 'content-type': 'application/octet-stream', }; - headers['content-type'] = 'application/octet-stream'; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); @@ -54,6 +66,11 @@ const uploadPartSerializer = async ( partNumber: input.PartNumber + '', uploadId: input.UploadId, }).toString(); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'PUT', diff --git a/packages/storage/src/providers/s3/utils/client/utils/createRetryDecider.ts b/packages/storage/src/providers/s3/utils/client/utils/createRetryDecider.ts new file mode 100644 index 00000000000..bc9ce1c161c --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/utils/createRetryDecider.ts @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + ErrorParser, + HttpResponse, + MiddlewareContext, + RetryDeciderOutput, + getRetryDecider, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { LocationCredentialsProvider } from '../../../types/options'; + +/** + * Function to decide if the S3 request should be retried. For S3 APIs, we support forceRefresh option + * for {@link LocationCredentialsProvider | LocationCredentialsProvider } option. It's set when S3 returns + * credentials expired error. In the retry decider, we detect this response and set flag to signify a retry + * attempt. The retry attempt would invoke the LocationCredentialsProvider with forceRefresh option set. + * + * @param response Optional response of the request. + * @param error Optional error thrown from previous attempts. + * @param middlewareContext Optional context object to store data between retries. + * @returns True if the request should be retried. + */ +export type RetryDecider = ( + response?: HttpResponse, + error?: unknown, + middlewareContext?: MiddlewareContext, +) => Promise; + +/** + * Factory of a {@link RetryDecider} function. + * + * @param errorParser function to parse HTTP response wth XML payload to JS + * Error instance. + * @returns A structure indicating if the response is retryable; And if it is a + * CredentialsExpiredError + */ +export const createRetryDecider = + (errorParser: ErrorParser): RetryDecider => + async ( + response?: HttpResponse, + error?: unknown, + middlewareContext?: MiddlewareContext, + ): Promise => { + const defaultRetryDecider = getRetryDecider(errorParser); + const defaultRetryDecision = await defaultRetryDecider(response, error); + if (!response) { + return { retryable: defaultRetryDecision.retryable }; + } + const parsedError = await errorParser(response); + const errorCode = parsedError?.name; + const errorMessage = parsedError?.message; + const isCredentialsExpired = isCredentialsExpiredError( + errorCode, + errorMessage, + ); + + return { + retryable: + defaultRetryDecision.retryable || + // If we know the previous retry attempt sets isCredentialsExpired in the + // middleware context, we don't want to retry anymore. + !!(isCredentialsExpired && !middlewareContext?.isCredentialsExpired), + isCredentialsExpiredError: isCredentialsExpired, + }; + }; + +// Ref: https://github.com/aws/aws-sdk-js/blob/54829e341181b41573c419bd870dd0e0f8f10632/lib/event_listeners.js#L522-L541 +const INVALID_TOKEN_ERROR_CODES = [ + 'RequestExpired', + 'ExpiredTokenException', + 'ExpiredToken', +]; + +/** + * Given an error code, returns true if it is related to invalid credentials. + * + * @param errorCode String representation of some error. + * @returns True if given error indicates the credentials used to authorize request + * are invalid. + */ +const isCredentialsExpiredError = ( + errorCode?: string, + errorMessage?: string, +) => { + const isExpiredTokenError = + !!errorCode && INVALID_TOKEN_ERROR_CODES.includes(errorCode); + // Ref: https://github.com/aws/aws-sdk-js/blob/54829e341181b41573c419bd870dd0e0f8f10632/lib/event_listeners.js#L536-L539 + const isExpiredSignatureError = + !!errorCode && + !!errorMessage && + errorCode.includes('Signature') && + errorMessage.includes('expired'); + + return isExpiredTokenError || isExpiredSignatureError; +}; diff --git a/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts b/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts index 0c06cbc60e7..0c2e3d2c7c0 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts @@ -5,6 +5,7 @@ import { Headers } from '@aws-amplify/core/internals/aws-client-utils'; import { ServiceError } from '@aws-amplify/core/internals/utils'; import { StorageError } from '../../../../../errors/StorageError'; +import { CompletedPart } from '../s3data'; type PropertyNameWithStringValue = string; type PropertyNameWithSubsequentDeserializer = [string, (arg: any) => T]; @@ -104,6 +105,47 @@ export const deserializeTimestamp = (value: string): Date | undefined => { return value ? new Date(value) : undefined; }; +/** + * Create a function deserializing a string to an enum value. If the string is not a valid enum value, it throws a + * StorageError. + * + * @example + * ```typescript + * const deserializeStringEnum = createStringEnumDeserializer(['a', 'b', 'c'] as const, 'FieldName'); + * const deserializedArray = ['a', 'b', 'c'].map(deserializeStringEnum); + * // deserializedArray = ['a', 'b', 'c'] + * + * const invalidValue = deserializeStringEnum('d'); + * // Throws InvalidFieldName: Invalid FieldName: d + * ``` + * + * @internal + */ +export const createStringEnumDeserializer = ( + enumValues: T, + fieldName: string, +) => { + const deserializeStringEnum = ( + value: any, + ): T extends (infer E)[] ? E : never => { + const parsedEnumValue = value + ? (enumValues.find(enumValue => enumValue === value) as any) + : undefined; + if (!parsedEnumValue) { + throw new StorageError({ + name: `Invalid${fieldName}`, + message: `Invalid ${fieldName}: ${value}`, + recoverySuggestion: + 'This is likely to be a bug. Please reach out to library authors.', + }); + } + + return parsedEnumValue; + }; + + return deserializeStringEnum; +}; + /** * Function that makes sure the deserializer receives non-empty array. * @@ -161,3 +203,17 @@ export const buildStorageServiceError = ( return storageError; }; + +/** + * Internal-only method used for deserializing the parts of a multipart upload. + * + * @internal + */ +export const deserializeCompletedPartList = (input: any[]): CompletedPart[] => + input.map(item => + map(item, { + PartNumber: ['PartNumber', deserializeNumber], + ETag: 'ETag', + ChecksumCRC32: 'ChecksumCRC32', + }), + ); diff --git a/packages/storage/src/providers/s3/utils/client/utils/index.ts b/packages/storage/src/providers/s3/utils/client/utils/index.ts index abfe9328d45..1dbf1b54d9d 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/index.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/index.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { parseXmlBody, parseXmlError } from './parsePayload'; +export { parseXmlBody, createXmlErrorParser } from './parsePayload'; export { SEND_DOWNLOAD_PROGRESS_EVENT, SEND_UPLOAD_PROGRESS_EVENT, @@ -13,6 +13,7 @@ export { export { buildStorageServiceError, deserializeBoolean, + deserializeCompletedPartList, deserializeMetadata, deserializeNumber, deserializeTimestamp, @@ -25,3 +26,5 @@ export { serializePathnameObjectKey, validateS3RequiredParameter, } from './serializeHelpers'; +export { createRetryDecider } from './createRetryDecider'; +export { bothNilOrEqual } from './integrityHelpers'; diff --git a/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts b/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts new file mode 100644 index 00000000000..783be7c810d --- /dev/null +++ b/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const isNil = (value?: T) => { + return value === undefined || value === null; +}; + +export const bothNilOrEqual = (original?: string, output?: string): boolean => { + return (isNil(original) && isNil(output)) || original === output; +}; + +/** + * This function is used to determine if a value is an object. + * It excludes arrays and null values. + * + * @param value + * @returns + */ +export const isObject = (value?: T) => { + return value != null && typeof value === 'object' && !Array.isArray(value); +}; + +/** + * This function is used to compare two objects and determine if they are equal. + * It handles nested objects and arrays as well. + * Array order is not taken into account. + * + * @param object + * @param other + * @returns + */ +export const isEqual = (object: T, other: T): boolean => { + if (Array.isArray(object) && !Array.isArray(other)) { + return false; + } + if (!Array.isArray(object) && Array.isArray(other)) { + return false; + } + if (Array.isArray(object) && Array.isArray(other)) { + return ( + object.length === other.length && + object.every((val, ix) => isEqual(val, other[ix])) + ); + } + if (!isObject(object) || !isObject(other)) { + return object === other; + } + + const objectKeys = Object.keys(object as any); + const otherKeys = Object.keys(other as any); + + if (objectKeys.length !== otherKeys.length) { + return false; + } + + return objectKeys.every(key => { + return ( + otherKeys.includes(key) && + isEqual(object[key as keyof T] as any, other[key as keyof T] as any) + ); + }); +}; diff --git a/packages/storage/src/providers/s3/utils/client/utils/parsePayload.ts b/packages/storage/src/providers/s3/utils/client/utils/parsePayload.ts index 9da44dcbdd0..f0284d573d2 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/parsePayload.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/parsePayload.ts @@ -9,25 +9,43 @@ import { import { parser } from '../runtime'; -export const parseXmlError: ErrorParser = async (response?: HttpResponse) => { - if (!response || response.statusCode < 300) { - return; - } - const { statusCode } = response; - const body = await parseXmlBody(response); - const code = body?.Code - ? (body.Code as string) - : statusCode === 404 - ? 'NotFound' - : statusCode.toString(); - const message = body?.message ?? body?.Message ?? code; - const error = new Error(message); +/** + * Factory creating a parser that parses the JS Error object from the XML + * response payload. + * + * @param input Input object + * @param input.noErrorWrapping Whether the error code and message are located + * directly in the root XML element, or in a nested `` element. + * See: https://smithy.io/2.0/aws/protocols/aws-restxml-protocol.html#restxml-errors + * + * Default to false. + * + * @internal + */ +export const createXmlErrorParser = + ({ + noErrorWrapping = false, + }: { noErrorWrapping?: boolean } = {}): ErrorParser => + async (response?: HttpResponse) => { + if (!response || response.statusCode < 300) { + return; + } + const { statusCode } = response; + const body = await parseXmlBody(response); + const errorLocation = noErrorWrapping ? body : body.Error; + const code = errorLocation?.Code + ? (errorLocation.Code as string) + : statusCode === 404 + ? 'NotFound' + : statusCode.toString(); + const message = errorLocation?.message ?? errorLocation?.Message ?? code; + const error = new Error(message); - return Object.assign(error, { - name: code, - $metadata: parseMetadata(response), - }); -}; + return Object.assign(error, { + name: code, + $metadata: parseMetadata(response), + }); + }; export const parseXmlBody = async (response: HttpResponse): Promise => { if (!response.body) { diff --git a/packages/storage/src/providers/s3/utils/constants.ts b/packages/storage/src/providers/s3/utils/constants.ts index e96c83c8f3c..72a58b778de 100644 --- a/packages/storage/src/providers/s3/utils/constants.ts +++ b/packages/storage/src/providers/s3/utils/constants.ts @@ -13,6 +13,9 @@ const MiB = 1024 * 1024; const GiB = 1024 * MiB; const TiB = 1024 * GiB; +/** + * Default part size in MB that is used to determine if an upload task is single part or multi part. + */ export const DEFAULT_PART_SIZE = 5 * MiB; export const MAX_OBJECT_SIZE = 5 * TiB; export const MAX_PARTS_COUNT = 10000; @@ -25,3 +28,5 @@ export const STORAGE_INPUT_KEY = 'key'; export const STORAGE_INPUT_PATH = 'path'; export const DEFAULT_DELIMITER = '/'; + +export const CHECKSUM_ALGORITHM_CRC32 = 'crc-32'; diff --git a/packages/storage/src/providers/s3/utils/crc32.ts b/packages/storage/src/providers/s3/utils/crc32.ts new file mode 100644 index 00000000000..b11e97085ba --- /dev/null +++ b/packages/storage/src/providers/s3/utils/crc32.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import crc32 from 'crc-32'; + +import { hexToArrayBuffer, hexToBase64 } from './hexUtils'; +import { readFile } from './readFile'; + +const CHUNK_SIZE = 1024 * 1024; // 1MB chunks + +export interface CRC32Checksum { + checksumArrayBuffer: ArrayBuffer; + checksum: string; + seed: number; +} + +export const calculateContentCRC32 = async ( + content: Blob | string | ArrayBuffer | ArrayBufferView, + seed = 0, +): Promise => { + let internalSeed = seed; + + if (content instanceof ArrayBuffer || ArrayBuffer.isView(content)) { + let uint8Array: Uint8Array; + + if (content instanceof ArrayBuffer) { + uint8Array = new Uint8Array(content); + } else { + uint8Array = new Uint8Array( + content.buffer, + content.byteOffset, + content.byteLength, + ); + } + + let offset = 0; + while (offset < uint8Array.length) { + const end = Math.min(offset + CHUNK_SIZE, uint8Array.length); + const chunk = uint8Array.slice(offset, end); + internalSeed = crc32.buf(chunk, internalSeed) >>> 0; + offset = end; + } + } else { + let blob: Blob; + + if (content instanceof Blob) { + blob = content; + } else { + blob = new Blob([content]); + } + + let offset = 0; + while (offset < blob.size) { + const end = Math.min(offset + CHUNK_SIZE, blob.size); + const chunk = blob.slice(offset, end); + const arrayBuffer = await readFile(chunk); + const uint8Array = new Uint8Array(arrayBuffer); + + internalSeed = crc32.buf(uint8Array, internalSeed) >>> 0; + + offset = end; + } + } + + const hex = internalSeed.toString(16).padStart(8, '0'); + + return { + checksumArrayBuffer: hexToArrayBuffer(hex), + checksum: hexToBase64(hex), + seed: internalSeed, + }; +}; diff --git a/packages/storage/src/providers/s3/utils/getCombinedCrc32.native.ts b/packages/storage/src/providers/s3/utils/getCombinedCrc32.native.ts new file mode 100644 index 00000000000..f15b4fec3a9 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/getCombinedCrc32.native.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageUploadDataPayload } from '../../../types'; +import { getDataChunker } from '../apis/internal/uploadData/multipart/getDataChunker'; + +import { calculateContentCRC32 } from './crc32'; + +/** + * Calculates a combined CRC32 checksum for the given data. + * + * This function chunks the input data, calculates CRC32 for each chunk, + * and then combines these checksums into a single value. + * + * @async + * @param {StorageUploadDataPayload} data - The data to calculate the checksum for. + * @param {number | undefined} size - The size of each chunk. If undefined, a default chunk size will be used. + * @returns {Promise} A promise that resolves to a string containing the combined CRC32 checksum + * and the number of chunks, separated by a hyphen. + */ +export const getCombinedCrc32 = async ( + data: StorageUploadDataPayload, + size: number | undefined, +) => { + const crc32List: Uint8Array[] = []; + const dataChunker = getDataChunker(data, size); + + let totalLength = 0; + for (const { data: checkData } of dataChunker) { + const checksum = new Uint8Array( + (await calculateContentCRC32(checkData)).checksumArrayBuffer, + ); + totalLength += checksum.length; + crc32List.push(checksum); + } + + // Combine all Uint8Arrays into a single Uint8Array + const combinedArray = new Uint8Array(totalLength); + let offset = 0; + for (const crc32Hash of crc32List) { + combinedArray.set(crc32Hash, offset); + offset += crc32Hash.length; + } + + return `${(await calculateContentCRC32(combinedArray.buffer)).checksum}-${crc32List.length}`; +}; diff --git a/packages/storage/src/providers/s3/utils/getCombinedCrc32.ts b/packages/storage/src/providers/s3/utils/getCombinedCrc32.ts new file mode 100644 index 00000000000..91082038523 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/getCombinedCrc32.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageUploadDataPayload } from '../../../types'; +import { getDataChunker } from '../apis/internal/uploadData/multipart/getDataChunker'; + +import { calculateContentCRC32 } from './crc32'; + +/** + * Calculates a combined CRC32 checksum for the given data. + * + * This function chunks the input data, calculates CRC32 for each chunk, + * and then combines these checksums into a single value. + * + * @async + * @param {StorageUploadDataPayload} data - The data to calculate the checksum for. + * @param {number | undefined} size - The size of each chunk. If undefined, a default chunk size will be used. + * @returns {Promise} A promise that resolves to a string containing the combined CRC32 checksum + * and the number of chunks, separated by a hyphen. + */ +export const getCombinedCrc32 = async ( + data: StorageUploadDataPayload, + size: number | undefined, +) => { + const crc32List: ArrayBuffer[] = []; + const dataChunker = getDataChunker(data, size); + for (const { data: checkData } of dataChunker) { + const { checksumArrayBuffer } = await calculateContentCRC32(checkData); + + crc32List.push(checksumArrayBuffer); + } + + return `${(await calculateContentCRC32(new Blob(crc32List))).checksum}-${crc32List.length}`; +}; diff --git a/packages/storage/src/providers/s3/utils/hexUtils.ts b/packages/storage/src/providers/s3/utils/hexUtils.ts new file mode 100644 index 00000000000..febb0d42e62 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/hexUtils.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { toBase64 } from './client/runtime'; + +export const hexToUint8Array = (hexString: string) => + new Uint8Array((hexString.match(/\w{2}/g)! ?? []).map(h => parseInt(h, 16))); + +export const hexToArrayBuffer = (hexString: string) => + hexToUint8Array(hexString).buffer; + +export const hexToBase64 = (hexString: string) => + toBase64(hexToUint8Array(hexString)); diff --git a/packages/storage/src/providers/s3/utils/index.ts b/packages/storage/src/providers/s3/utils/index.ts index cd6b9753019..a709e025988 100644 --- a/packages/storage/src/providers/s3/utils/index.ts +++ b/packages/storage/src/providers/s3/utils/index.ts @@ -4,6 +4,8 @@ export { calculateContentMd5 } from './md5'; export { resolveS3ConfigAndInput } from './resolveS3ConfigAndInput'; export { createDownloadTask, createUploadTask } from './transferTask'; +export { validateBucketOwnerID } from './validateBucketOwnerID'; export { validateStorageOperationInput } from './validateStorageOperationInput'; export { validateStorageOperationInputWithPrefix } from './validateStorageOperationInputWithPrefix'; export { isInputWithPath } from './isInputWithPath'; +export { urlDecode } from './urlDecoder'; diff --git a/packages/storage/src/providers/s3/utils/md5.ts b/packages/storage/src/providers/s3/utils/md5.ts index 98e04fdaf99..05cb09a4a5b 100644 --- a/packages/storage/src/providers/s3/utils/md5.ts +++ b/packages/storage/src/providers/s3/utils/md5.ts @@ -4,6 +4,7 @@ import { Md5 } from '@smithy/md5-js'; import { toBase64 } from './client/utils'; +import { readFile } from './readFile'; export const calculateContentMd5 = async ( content: Blob | string | ArrayBuffer | ArrayBufferView, @@ -15,18 +16,3 @@ export const calculateContentMd5 = async ( return toBase64(digest); }; - -const readFile = (file: Blob): Promise => - new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - resolve(reader.result as ArrayBuffer); - }; - reader.onabort = () => { - reject(new Error('Read aborted')); - }; - reader.onerror = () => { - reject(reader.error); - }; - reader.readAsArrayBuffer(file); - }); diff --git a/packages/storage/src/providers/s3/utils/md5.native.ts b/packages/storage/src/providers/s3/utils/readFile.native.ts similarity index 68% rename from packages/storage/src/providers/s3/utils/md5.native.ts rename to packages/storage/src/providers/s3/utils/readFile.native.ts index a0c5a2365d8..29ccbfa5966 100644 --- a/packages/storage/src/providers/s3/utils/md5.native.ts +++ b/packages/storage/src/providers/s3/utils/readFile.native.ts @@ -3,25 +3,10 @@ import { Buffer } from 'buffer'; -import { Md5 } from '@smithy/md5-js'; - -import { toBase64 } from './client/utils'; - -// The FileReader in React Native 0.71 did not support `readAsArrayBuffer`. This native implementation accomodates this +// The FileReader in React Native 0.71 did not support `readAsArrayBuffer`. This native implementation accommodates this // by attempting to use `readAsArrayBuffer` and changing the file reading strategy if it throws an error. // TODO: This file should be removable when we drop support for React Native 0.71 -export const calculateContentMd5 = async ( - content: Blob | string | ArrayBuffer | ArrayBufferView, -): Promise => { - const hasher = new Md5(); - const buffer = content instanceof Blob ? await readFile(content) : content; - hasher.update(buffer); - const digest = await hasher.digest(); - - return toBase64(digest); -}; - -const readFile = (file: Blob): Promise => +export const readFile = (file: Blob): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { diff --git a/packages/storage/src/providers/s3/utils/readFile.ts b/packages/storage/src/providers/s3/utils/readFile.ts new file mode 100644 index 00000000000..5d3782569d2 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/readFile.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const readFile = (file: Blob): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + resolve(reader.result as ArrayBuffer); + }; + reader.onabort = () => { + reject(new Error('Read aborted')); + }; + reader.onerror = () => { + reject(reader.error); + }; + reader.readAsArrayBuffer(file); + }); diff --git a/packages/storage/src/providers/s3/utils/resolveIdentityId.ts b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts new file mode 100644 index 00000000000..c4831ae88c4 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; + +export const resolveIdentityId = (identityId?: string): string => { + assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); + + return identityId; +}; diff --git a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts index 1e731ec2a12..7cb4c55316e 100644 --- a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts +++ b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts @@ -2,11 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import { AmplifyClassV6, StorageAccessLevel } from '@aws-amplify/core'; +import { CredentialsProviderOptions } from '@aws-amplify/core/internals/aws-client-utils'; import { assertValidationError } from '../../../errors/utils/assertValidationError'; import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { resolvePrefix as defaultPrefixResolver } from '../../../utils/resolvePrefix'; -import { BucketInfo, ResolvedS3Config, StorageBucket } from '../types/options'; +import { + StorageOperationInputWithKey, + StorageOperationInputWithPath, + StorageOperationInputWithPrefix, +} from '../../../types/inputs'; +import { StorageError } from '../../../errors/StorageError'; +import { CopyInput, CopyWithPathInput } from '../types'; +import { INVALID_STORAGE_INPUT } from '../../../errors/constants'; +import { + BucketInfo, + LocationCredentialsProvider, + ResolvedS3Config, + StorageBucket, +} from '../types/options'; import { DEFAULT_ACCESS_LEVEL, LOCAL_TESTING_S3_ENDPOINT } from './constants'; @@ -14,6 +28,8 @@ interface S3ApiOptions { accessLevel?: StorageAccessLevel; targetIdentityId?: string; useAccelerateEndpoint?: boolean; + locationCredentialsProvider?: LocationCredentialsProvider; + customEndpoint?: string; bucket?: StorageBucket; } @@ -24,6 +40,16 @@ interface ResolvedS3ConfigAndInput { isObjectLockEnabled?: boolean; identityId?: string; } +export type DeprecatedStorageInput = + | StorageOperationInputWithKey + | StorageOperationInputWithPrefix + | CopyInput; + +export type CallbackPathStorageInput = + | StorageOperationInputWithPath + | CopyWithPathInput; + +type StorageInput = DeprecatedStorageInput | CallbackPathStorageInput; /** * resolve the common input options for S3 API handlers from Amplify configuration and library options. @@ -38,23 +64,35 @@ interface ResolvedS3ConfigAndInput { */ export const resolveS3ConfigAndInput = async ( amplify: AmplifyClassV6, - apiOptions?: S3ApiOptions, + apiInput?: StorageInput & { options?: S3ApiOptions }, ): Promise => { + const { options: apiOptions } = apiInput ?? {}; /** * IdentityId is always cached in memory so we can safely make calls here. It * should be stable even for unauthenticated users, regardless of credentials. */ const { identityId } = await amplify.Auth.fetchAuthSession(); - assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); /** * A credentials provider function instead of a static credentials object is * used because the long-running tasks like multipart upload may span over the * credentials expiry. Auth.fetchAuthSession() automatically refreshes the * credentials if they are expired. + * + * The optional forceRefresh option is set when the S3 service returns expired + * tokens error in the previous API call attempt. */ - const credentialsProvider = async () => { - const { credentials } = await amplify.Auth.fetchAuthSession(); + const credentialsProvider = async (options?: CredentialsProviderOptions) => { + if (isLocationCredentialsProvider(apiOptions)) { + assertStorageInput(apiInput); + } + + // TODO: forceRefresh option of fetchAuthSession would refresh both tokens and + // AWS credentials. So we do not support forceRefreshing from the Auth until + // we support refreshing only the credentials. + const { credentials } = isLocationCredentialsProvider(apiOptions) + ? await apiOptions.locationCredentialsProvider(options) + : await amplify.Auth.fetchAuthSession(); assertValidationError( !!credentials, StorageValidationErrorCode.NoCredentials, @@ -82,21 +120,23 @@ export const resolveS3ConfigAndInput = async ( isObjectLockEnabled, } = amplify.libraryOptions?.Storage?.S3 ?? {}; - const keyPrefix = await prefixResolver({ - accessLevel: - apiOptions?.accessLevel ?? defaultAccessLevel ?? DEFAULT_ACCESS_LEVEL, - // use conditional assign to make tsc happy because StorageOptions is a union type that may not have targetIdentityId - targetIdentityId: - apiOptions?.accessLevel === 'protected' - ? (apiOptions?.targetIdentityId ?? identityId) - : identityId, - }); + const accessLevel = + apiOptions?.accessLevel ?? defaultAccessLevel ?? DEFAULT_ACCESS_LEVEL; + const targetIdentityId = + accessLevel === 'protected' + ? (apiOptions?.targetIdentityId ?? identityId) + : identityId; + + const keyPrefix = await prefixResolver({ accessLevel, targetIdentityId }); return { s3Config: { credentials: credentialsProvider, region, useAccelerateEndpoint: apiOptions?.useAccelerateEndpoint, + ...(apiOptions?.customEndpoint + ? { customEndpoint: apiOptions.customEndpoint } + : {}), ...(dangerouslyConnectToHttpEndpointForTesting ? { customEndpoint: LOCAL_TESTING_S3_ENDPOINT, @@ -111,6 +151,65 @@ export const resolveS3ConfigAndInput = async ( }; }; +const isLocationCredentialsProvider = ( + options?: S3ApiOptions, +): options is S3ApiOptions & { + locationCredentialsProvider: LocationCredentialsProvider; +} => { + return !!options?.locationCredentialsProvider; +}; + +const isInputWithCallbackPath = (input?: CallbackPathStorageInput) => { + return ( + ((input as StorageOperationInputWithPath)?.path && + typeof (input as StorageOperationInputWithPath).path === 'function') || + ((input as CopyWithPathInput)?.destination?.path && + typeof (input as CopyWithPathInput).destination?.path === 'function') || + ((input as CopyWithPathInput)?.source?.path && + typeof (input as CopyWithPathInput).source?.path === 'function') + ); +}; + +const isDeprecatedInput = ( + input?: StorageInput, +): input is DeprecatedStorageInput => { + return ( + isInputWithKey(input) || + isInputWithPrefix(input) || + isInputWithCopySourceOrDestination(input) + ); +}; +const assertStorageInput = (input?: StorageInput) => { + if (isDeprecatedInput(input) || isInputWithCallbackPath(input)) { + throw new StorageError({ + name: INVALID_STORAGE_INPUT, + message: 'The input needs to have a path as a string value.', + recoverySuggestion: + 'Please provide a valid path as a string value for the input.', + }); + } +}; + +const isInputWithKey = ( + input?: StorageInput, +): input is StorageOperationInputWithKey => { + return !!(typeof (input as StorageOperationInputWithKey).key === 'string'); +}; +const isInputWithPrefix = ( + input?: StorageInput, +): input is StorageOperationInputWithPrefix => { + return !!( + typeof (input as StorageOperationInputWithPrefix).prefix === 'string' + ); +}; +const isInputWithCopySourceOrDestination = ( + input?: StorageInput, +): input is CopyInput => { + return !!( + typeof (input as CopyInput).source?.key === 'string' || + typeof (input as CopyInput).destination?.key === 'string' + ); +}; const resolveBucketConfig = ( apiOptions: S3ApiOptions, buckets: Record | undefined, diff --git a/packages/storage/src/providers/s3/utils/urlDecoder.ts b/packages/storage/src/providers/s3/utils/urlDecoder.ts new file mode 100644 index 00000000000..e812c8a23f4 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/urlDecoder.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Decodes a URL-encoded string by replacing '+' characters with spaces and applying `decodeURIComponent`. + * Reference: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url + * @param {string} value - The URL-encoded string to decode. + * @returns {string} The decoded string. + */ +export const urlDecode = (value: string): string => { + return decodeURIComponent(value.replace(/\+/g, ' ')); +}; diff --git a/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts b/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts new file mode 100644 index 00000000000..d43e91b5e17 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; + +const VALID_AWS_ACCOUNT_ID_PATTERN = /^\d{12}/; + +export const validateBucketOwnerID = (accountID?: string) => { + if (accountID === undefined) { + return; + } + + assertValidationError( + VALID_AWS_ACCOUNT_ID_PATTERN.test(accountID), + StorageValidationErrorCode.InvalidAWSAccountID, + ); +}; diff --git a/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts b/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts new file mode 100644 index 00000000000..0295ab511fc --- /dev/null +++ b/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { IntegrityError } from '../../../errors/IntegrityError'; + +import { parser } from './client/runtime'; +import { CompletedMultipartUpload } from './client/s3data/types'; +import { + deserializeCompletedPartList, + emptyArrayGuard, + map, +} from './client/utils'; +import { isEqual } from './client/utils/integrityHelpers'; + +export function validateMultipartUploadXML( + input: CompletedMultipartUpload, + xml: string, +) { + if (!input.Parts) { + throw new IntegrityError(); + } + const parsedXML = parser.parse(xml); + const mappedCompletedMultipartUpload: CompletedMultipartUpload = map( + parsedXML, + { + Parts: [ + 'Part', + value => emptyArrayGuard(value, deserializeCompletedPartList), + ], + }, + ); + + if (!isEqual(input, mappedCompletedMultipartUpload)) { + throw new IntegrityError(); + } +} diff --git a/packages/storage/src/providers/s3/utils/validateObjectUrl.ts b/packages/storage/src/providers/s3/utils/validateObjectUrl.ts new file mode 100644 index 00000000000..a50eb50daab --- /dev/null +++ b/packages/storage/src/providers/s3/utils/validateObjectUrl.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { extendedEncodeURIComponent } from '@aws-amplify/core/internals/aws-client-utils'; + +import { IntegrityError } from '../../../errors/IntegrityError'; + +export function validateObjectUrl({ + bucketName, + key, + objectURL, +}: { + bucketName?: string; + key?: string; + objectURL?: URL; +}): void { + if (!bucketName || !key || !objectURL) { + throw new IntegrityError(); + } + const bucketWithDots = bucketName.includes('.'); + const encodedBucketName = extendedEncodeURIComponent(bucketName); + const encodedKey = key.split('/').map(extendedEncodeURIComponent).join('/'); + const isPathStyleUrl = + objectURL.pathname === `/${encodedBucketName}/${encodedKey}`; + const isSubdomainUrl = + objectURL.hostname.startsWith(`${encodedBucketName}.`) && + objectURL.pathname === `/${encodedKey}`; + + if (!(isPathStyleUrl || (!bucketWithDots && isSubdomainUrl))) { + throw new IntegrityError(); + } +} diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts index 585701c81e9..fa423b45913 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts @@ -7,6 +7,7 @@ import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { isInputWithPath } from './isInputWithPath'; import { STORAGE_INPUT_KEY, STORAGE_INPUT_PATH } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; export const validateStorageOperationInput = ( input: Input, @@ -22,7 +23,10 @@ export const validateStorageOperationInput = ( if (isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); assertValidationError( !objectKey.startsWith('/'), diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts index da1068af010..1c2efce19f7 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts @@ -9,6 +9,7 @@ import { assertValidationError } from '../../../errors/utils/assertValidationErr import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { STORAGE_INPUT_PATH, STORAGE_INPUT_PREFIX } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; // Local assertion function with StorageOperationInputWithPrefixPath as Input const _isInputWithPath = ( @@ -28,7 +29,10 @@ export const validateStorageOperationInputWithPrefix = ( ); if (_isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); // Assert on no leading slash in the path parameter assertValidationError( diff --git a/packages/storage/src/types/inputs.ts b/packages/storage/src/types/inputs.ts index 06c348b4b8f..7b8f8b10570 100644 --- a/packages/storage/src/types/inputs.ts +++ b/packages/storage/src/types/inputs.ts @@ -3,7 +3,10 @@ import { StrictUnion } from '@aws-amplify/core/internals/utils'; -import { StorageBucket } from '../providers/s3/types/options'; +import { + CopyWithPathDestinationOptions, + CopyWithPathSourceOptions, +} from '../providers/s3/types/options'; import { StorageListAllOptions, @@ -93,8 +96,8 @@ export interface StorageCopyInputWithKey< } export interface StorageCopyInputWithPath { - source: StorageOperationInputWithPath & { bucket?: StorageBucket }; - destination: StorageOperationInputWithPath & { bucket?: StorageBucket }; + source: StorageOperationInputWithPath & CopyWithPathSourceOptions; + destination: StorageOperationInputWithPath & CopyWithPathDestinationOptions; } /** diff --git a/scripts/dts-bundler/README.md b/scripts/dts-bundler/README.md index ce6b8459f27..7bd7938b2fa 100644 --- a/scripts/dts-bundler/README.md +++ b/scripts/dts-bundler/README.md @@ -1,10 +1,32 @@ -This project is used to rollup the TS types from the AWS SDK into the custom AWS clients. You can regenerate them -by running the `build` script in this project, and commit the generated file changes. +## What is this package? -To update the generated types files, you need to: +Amplify JS uses custom AWS API clients in limited scope. These API handlers' types are compatible with those of +AWS SDK, and trimmed to the parameters used by the Amplify library. -1. Update existing `*.d.ts` files in this folder or add new ones. -1. If new `*.d.ts` file is added, update the `dts-bundler.config.js` with additional entries. +This package is used to rollup the TS types from the AWS SDK into the custom AWS clients. You can regenerate them +by running the `build` script in this project, then review & commit the generated file changes. + +## How to update the custom AWS clients types? + +Since custom AWS clients are used in limited scope, in most cases you don't need to add any new services. Instead, you +may need to update the SDK versions or exporting additional types. Here's the steps: + +1. Make sure the `package.json` dev dependencies entry contains the AWS SDK service client you are working with and +more importantly the version is upgraded to that supports the feature you are working with. +1. Open the `*.d.ts` file for the AWS client you need to upgrade, and make sure the interfaces you need are exported. +1. Open the `dts-bundler.config.js` file and make sure the entry to the `*.d.ts` file you are working with exists and +the `outFile` path is expected. + * You need to update the `libraries.inlinedLibraries` to include the AWS SDK service client package to bundle + the TS interfaces there. 1. Run the generating script `yarn && yarn build`. The generated files will be shown in the console. -1. Inspect generated files and make sure headers are not changed. -1. Commit the changes + * If you only want to work with a single AWS service instead of changing all the definitions for all the services, + you can comment out other service entries from the `dts-bundler.config.js`. +1. Inspect the bundled TypeScript definition file in the `outFile` path. To better compare the diffs, you need to +re-format the generated code. + * You need to make sure any license headers and previous notes are not changed. + * The bundled TypeScript definition file may import more types transitive dependencies of AWS SDK package. In this + case you may need to tweak the `libraries.inlinedLibraries` config until all the necessary dependency types are + bundled. + * You need to make sure the imported packages of the bundle file(e.g. `@aws-sdk/types`) are also added to the + Amplify library's **runtime dependency**. + * You **must** make sure the documented manual changes are re-applied to the newly generated bundle file. diff --git a/scripts/dts-bundler/dts-bundler.config.js b/scripts/dts-bundler/dts-bundler.config.js index e4ac1a24d61..bc6fd3b44e5 100644 --- a/scripts/dts-bundler/dts-bundler.config.js +++ b/scripts/dts-bundler/dts-bundler.config.js @@ -76,12 +76,20 @@ const config = { }, { filePath: './s3.d.ts', - outFile: join(storagePackageSrcClientsPath, 'client', 'types.ts'), + outFile: join(storagePackageSrcClientsPath, 'client', 's3data', 'types.ts'), libraries: { inlinedLibraries: ['@aws-sdk/client-s3'], }, output: outputConfig, }, + { + filePath: './s3-control.d.ts', + outFile: join(storagePackageSrcClientsPath, 'client', 's3control', 'types.ts'), + libraries: { + inlinedLibraries: ['@aws-sdk/client-s3-control'], + }, + output: outputConfig, + }, { filePath: './cognito-identity-provider.d.ts', outFile: join( diff --git a/scripts/dts-bundler/package.json b/scripts/dts-bundler/package.json index 85aa0a9ea72..69e499bcd22 100644 --- a/scripts/dts-bundler/package.json +++ b/scripts/dts-bundler/package.json @@ -1,10 +1,12 @@ { "name": "api-extract-aws-clients", + "private": true, "devDependencies": { "@aws-sdk/client-pinpoint": "3.335.1", "@aws-sdk/client-cognito-identity": "3.335.0", "@aws-sdk/client-cognito-identity-provider": "3.386.0", - "@aws-sdk/client-s3": "3.335.0", + "@aws-sdk/client-s3": "3.673.0", + "@aws-sdk/client-s3-control": "3.670.0", "dts-bundle-generator": "^8.0.1" }, "scripts": { diff --git a/scripts/dts-bundler/s3-control.d.ts b/scripts/dts-bundler/s3-control.d.ts new file mode 100644 index 00000000000..1c5443611a7 --- /dev/null +++ b/scripts/dts-bundler/s3-control.d.ts @@ -0,0 +1,13 @@ +import { + GetDataAccessCommandInput, + GetDataAccessCommandOutput, + ListCallerAccessGrantsCommandInput, + ListCallerAccessGrantsCommandOutput, +} from '@aws-sdk/client-s3-control'; + +export { + ListCallerAccessGrantsCommandInput, + ListCallerAccessGrantsCommandOutput, + GetDataAccessCommandInput, + GetDataAccessCommandOutput, +}; diff --git a/scripts/tsc-compliance-test/CHANGELOG.md b/scripts/tsc-compliance-test/CHANGELOG.md index c4e7343b5e4..f08d67d9a96 100644 --- a/scripts/tsc-compliance-test/CHANGELOG.md +++ b/scripts/tsc-compliance-test/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.1.65](https://github.com/aws-amplify/amplify-js/compare/tsc-compliance-test@0.1.64...tsc-compliance-test@0.1.65) (2024-11-25) + +**Note:** Version bump only for package tsc-compliance-test + +## [0.1.64](https://github.com/aws-amplify/amplify-js/compare/tsc-compliance-test@0.1.63...tsc-compliance-test@0.1.64) (2024-11-20) + +**Note:** Version bump only for package tsc-compliance-test + ## [0.1.63](https://github.com/aws-amplify/amplify-js/compare/tsc-compliance-test@0.1.62...tsc-compliance-test@0.1.63) (2024-11-13) **Note:** Version bump only for package tsc-compliance-test diff --git a/scripts/tsc-compliance-test/package.json b/scripts/tsc-compliance-test/package.json index 3d2f88e1a3a..aad99cfc6b9 100644 --- a/scripts/tsc-compliance-test/package.json +++ b/scripts/tsc-compliance-test/package.json @@ -1,11 +1,11 @@ { "name": "tsc-compliance-test", - "version": "0.1.63", + "version": "0.1.65", "license": "MIT", "private": true, "devDependencies": { "@types/node": "16.18.82", - "aws-amplify": "6.8.2", + "aws-amplify": "6.10.0", "typescript": "4.2.x" }, "scripts": { diff --git a/tsconfig.json b/tsconfig.json index 7a38e92756a..53556e642d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ "module": "es2020", "types": ["node", "jest"] }, - "exclude": ["node_modules", "dist", ".eslintrc.js", "scripts"] + "exclude": ["node_modules", "dist", ".eslintrc.js"] } diff --git a/yarn.lock b/yarn.lock index c2c865e8aad..d24a3ecba52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2297,9 +2297,9 @@ integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== "@eslint/plugin-kit@^0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz#8712dccae365d24e9eeecb7b346f85e750ba343d" - integrity sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig== + version "0.2.3" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz#812980a6a41ecf3a8341719f92a6d1e784a2e0e8" + integrity sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA== dependencies: levn "^0.4.1" @@ -5148,6 +5148,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.82.tgz#58d734b4acaa5be339864bbec9cd8024dd0b43d5" integrity sha512-pcDZtkx9z8XYV+ius2P3Ot2VVrcYOfXffBQUBuiszrlUzKSmoDYqo+mV+IoL8iIiIjjtOMvNSmH1hwJ+Q+f96Q== +"@types/node@20.14.12": + version "20.14.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.12.tgz#129d7c3a822cb49fc7ff661235f19cfefd422b49" + integrity sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ== + dependencies: + undici-types "~5.26.4" + "@types/node@^18.0.0": version "18.19.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.55.tgz#29c3f8e1485a92ec96636957ddec55aabc6e856e" @@ -6961,6 +6968,11 @@ cosmiconfig@^9.0.0: js-yaml "^4.1.0" parse-json "^5.2.0" +crc-32@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -6981,21 +6993,10 @@ cross-fetch@^3.0.4: dependencies: node-fetch "^2.6.12" -cross-spawn@^6.0.0: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +cross-spawn@7.0.5, cross-spawn@^6.0.0, cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: + version "7.0.5" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" + integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -11984,11 +11985,6 @@ neo-async@^2.5.0, neo-async@^2.6.2: "@next/swc-win32-ia32-msvc" "14.2.15" "@next/swc-win32-x64-msvc" "14.2.15" -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - nocache@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/nocache/-/nocache-3.0.4.tgz#5b37a56ec6e09fc7d401dceaed2eab40c8bfdf79" @@ -12782,7 +12778,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== -path-key@^2.0.0, path-key@^2.0.1: +path-key@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" integrity sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw== @@ -13887,7 +13883,7 @@ semantic-ui-react@^0.88.2: react-popper "^1.3.4" shallowequal "^1.1.0" -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0: +"semver@2 || 3 || 4 || 5", semver@^5.6.0: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== @@ -14001,13 +13997,6 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg== - dependencies: - shebang-regex "^1.0.0" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -14015,11 +14004,6 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ== - shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" @@ -14377,7 +14361,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== @@ -14455,7 +14448,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== @@ -14469,6 +14462,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" @@ -15558,13 +15558,6 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -15601,7 +15594,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== @@ -15628,6 +15621,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"