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..f049dde518e 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 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..f584d07cb48 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..8af180de010 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_.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/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.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..199c2ed0ca8 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.29 | Amplify JS API Documentation +

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

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..bf10c9e8820 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.61 | Amplify JS API Documentation +

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

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..aaa6a5a3e7d 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.59 | Amplify JS API Documentation +

Module @aws-amplify/geo - v3.0.59

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..f295df288f4 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.0 | Amplify JS API Documentation +

Module @aws-amplify/interactions - v6.1.0

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..811ea9bfa20 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.34 | Amplify JS API Documentation +

Module @aws-amplify/predictions - v6.1.34

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..6bb5d150dfb 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.34 | Amplify JS API Documentation +

Module @aws-amplify/pubsub - v6.1.34

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.html b/docs/api/modules/aws_amplify.html index d9f234194a5..afb8d3e907d 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.9.0 | Amplify JS API Documentation +

Module aws-amplify - v6.9.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.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..8190422f70d 100644 --- a/packages/adapter-nextjs/CHANGELOG.md +++ b/packages/adapter-nextjs/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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 e03f22a86d5..4c656871173 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.29", "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.9.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..1c50573c42b 100644 --- a/packages/analytics/CHANGELOG.md +++ b/packages/analytics/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..ff07f4e85e7 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.59", "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.6.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..9f1b057f298 100644 --- a/packages/api-graphql/CHANGELOG.md +++ b/packages/api-graphql/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..06d57ec4bea 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.2", "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.59", + "@aws-amplify/core": "6.6.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..0ad0aa28c98 100644 --- a/packages/api-rest/CHANGELOG.md +++ b/packages/api-rest/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..39d81a3a6dd 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.59", "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.6.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..4128a479242 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..aaed53bcf68 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.4", "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.2", + "@aws-amplify/api-rest": "4.0.59", "tslib": "^2.5.0" } } diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index cae4c9caec1..d9a23c07a7a 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -3,6 +3,12 @@ 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/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/package.json b/packages/auth/package.json index 42771efb06b..1e6949f56fb 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.7.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.6.0", "@aws-amplify/react-native": "1.1.6", "@jest/test-sequencer": "^29.7.0", "typescript": "5.0.2" diff --git a/packages/aws-amplify/CHANGELOG.md b/packages/aws-amplify/CHANGELOG.md index 93361f07dab..d12b2ad3351 100644 --- a/packages/aws-amplify/CHANGELOG.md +++ b/packages/aws-amplify/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [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..16218f13871 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -239,6 +239,7 @@ describe('aws-amplify Exports', () => { 'getUrl', 'isCancelError', 'StorageError', + 'DEFAULT_PART_SIZE', ].sort(), ); }); @@ -253,6 +254,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..a8567555ee8 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.9.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.59", + "@aws-amplify/api": "6.1.4", + "@aws-amplify/auth": "6.7.0", + "@aws-amplify/core": "6.6.0", + "@aws-amplify/datastore": "5.0.61", + "@aws-amplify/notifications": "2.0.59", + "@aws-amplify/storage": "6.7.0", "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.59 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.09 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.21 kB" }, { "name": "[API] REST API handlers", @@ -353,43 +353,43 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.57 kB" + "limit": "12.66 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.51 kB" + "limit": "12.60 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.61 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.07 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", @@ -401,13 +401,13 @@ "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.86 kB" + "limit": "12.94 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.87 kB" + "limit": "12.96 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.19 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,19 +431,19 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.86 kB" + "limit": "12.93 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.01 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", @@ -455,49 +455,49 @@ "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": "[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.69 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..2a7817d9854 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [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/metadata b/packages/core/metadata index 852adf07360..b581520d29e 100644 --- a/packages/core/metadata +++ b/packages/core/metadata @@ -1 +1 @@ -f6727c6df +81fbd9e36 diff --git a/packages/core/package.json b/packages/core/package.json index 7cfe8932f22..043d3226596 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.6.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..488c37efe8b 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -133,6 +133,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..987ac966a59 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; 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/datastore-storage-adapter/CHANGELOG.md b/packages/datastore-storage-adapter/CHANGELOG.md index f40c5d42bb2..94c661cb995 100644 --- a/packages/datastore-storage-adapter/CHANGELOG.md +++ b/packages/datastore-storage-adapter/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..b2bf0091852 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.61", "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.6.0", + "@aws-amplify/datastore": "5.0.61", "@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..e4c3231f078 100644 --- a/packages/datastore/CHANGELOG.md +++ b/packages/datastore/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..358546bd4d3 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.61", "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.4", "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.6.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..f87dc0a3956 100644 --- a/packages/geo/CHANGELOG.md +++ b/packages/geo/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..f1bfc7b1b55 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.59", "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.6.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/interactions/CHANGELOG.md b/packages/interactions/CHANGELOG.md index ba6198c37dd..5d00d65e29c 100644 --- a/packages/interactions/CHANGELOG.md +++ b/packages/interactions/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [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..590fde3cf56 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.0", "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.6.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..607511e5c85 100644 --- a/packages/notifications/CHANGELOG.md +++ b/packages/notifications/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..296609e7462 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.59", "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.6.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..384d20ad079 100644 --- a/packages/predictions/CHANGELOG.md +++ b/packages/predictions/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..b5bba76c47a 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.34", "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.0", "@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.6.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/pubsub/CHANGELOG.md b/packages/pubsub/CHANGELOG.md index f197f2f5fd1..715a81f9110 100644 --- a/packages/pubsub/CHANGELOG.md +++ b/packages/pubsub/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..6a54970037d 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.34", "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.7.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.6.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index f5039e23a09..87e5b439a80 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -3,6 +3,12 @@ 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/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..2c75ed0da13 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.0", "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.6.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/providers/s3/apis/uploadData/multipart/index.ts b/packages/storage/src/internals/apis/listPaths/index.ts similarity index 63% rename from packages/storage/src/providers/s3/apis/uploadData/multipart/index.ts rename to packages/storage/src/internals/apis/listPaths/index.ts index 1f7db0aabb3..cf04534bf0f 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/index.ts +++ b/packages/storage/src/internals/apis/listPaths/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 { 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..e6d727c5fba --- /dev/null +++ b/scripts/dts-bundler/s3-control.d.ts @@ -0,0 +1,13 @@ +import { + ListCallerAccessGrantsCommandInput, + ListCallerAccessGrantsCommandOutput, + GetDataAccessCommandInput, + GetDataAccessCommandOutput, +} 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..76fd560bc24 100644 --- a/scripts/tsc-compliance-test/CHANGELOG.md +++ b/scripts/tsc-compliance-test/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [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..02670ed4c1a 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.64", "license": "MIT", "private": true, "devDependencies": { "@types/node": "16.18.82", - "aws-amplify": "6.8.2", + "aws-amplify": "6.9.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 e80146f785f..d24a3ecba52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -15574,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"