Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/aws-amplify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@
"name": "[Storage] copy (S3)",
"path": "./dist/esm/storage/index.mjs",
"import": "{ copy }",
"limit": "15.42 kB"
"limit": "15.75 kB"
},
{
"name": "[Storage] downloadData (S3)",
Expand Down
72 changes: 72 additions & 0 deletions packages/storage/__tests__/providers/s3/apis/copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,42 @@ describe('copy API', () => {
},
);
});

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('With path', () => {
Expand Down Expand Up @@ -310,6 +346,42 @@ describe('copy API', () => {
},
);
});

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const copyObjectHappyCase: ApiFunctionalTestCase<typeof copyObject> = [
CacheControl: 'cacheControl',
ContentType: 'contentType',
ACL: 'acl',
CopySourceIfMatch: 'eTag',
CopySourceIfUnmodifiedSince: new Date(0),
},
expect.objectContaining({
url: expect.objectContaining({
Expand All @@ -34,6 +36,8 @@ const copyObjectHappyCase: ApiFunctionalTestCase<typeof copyObject> = [
'cache-control': 'cacheControl',
'content-type': 'contentType',
'x-amz-acl': 'acl',
'x-amz-copy-source-if-match': 'eTag',
'x-amz-copy-source-if-unmodified-since': '1970-01-01T00:00:00.000Z',
}),
}),
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
bothNilOrEqual,
isNil,
} 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);
},
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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<string, string>;
body: string;
}): HttpResponse => {
const responseBody = {
json: async (): Promise<any> => {
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': '1970-01-01T00:00:00.000Z',
},
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': '1970-01-01T00:00:00.000Z',
},
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');
}
});
}
});
});
});
10 changes: 10 additions & 0 deletions packages/storage/src/providers/s3/apis/internal/copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ const copyWithPath = async (
destination: finalCopyDestination,
bucket: destBucket,
s3Config,
notModifiedSince: input.source.notModifiedSince,
eTag: input.source.eTag,
});

return { path: finalCopyDestination };
Expand Down Expand Up @@ -162,6 +164,8 @@ export const copyWithKey = async (
destination: finalCopyDestination,
bucket: destBucket,
s3Config,
notModifiedSince: input.source.notModifiedSince,
eTag: input.source.eTag,
});

return {
Expand All @@ -174,11 +178,15 @@ const serviceCopy = async ({
destination,
bucket,
s3Config,
notModifiedSince,
eTag,
}: {
source: string;
destination: string;
bucket: string;
s3Config: ResolvedS3Config;
notModifiedSince?: Date;
eTag?: string;
}) => {
await copyObject(
{
Expand All @@ -190,6 +198,8 @@ const serviceCopy = async ({
CopySource: source,
Key: destination,
MetadataDirective: 'COPY', // Copies over metadata like contentType as well
CopySourceIfMatch: eTag,
CopySourceIfUnmodifiedSince: notModifiedSince,
},
);
};
Loading