Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(storage): add support for conditional headers to copy, and validate serialization #13772

Merged
merged 9 commits into from
Sep 12, 2024
2 changes: 1 addition & 1 deletion packages/aws-amplify/package.json
Original file line number Diff line number Diff line change
@@ -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)",
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
@@ -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', () => {
@@ -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,
}),
);
});
});
});

Original file line number Diff line number Diff line change
@@ -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({
@@ -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',
}),
}),
{
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', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could use inline doc for explaining the util

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
@@ -105,6 +105,8 @@ const copyWithPath = async (
destination: finalCopyDestination,
bucket: destBucket,
s3Config,
notModifiedSince: input.source.notModifiedSince,
eTag: input.source.eTag,
});

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

return {
@@ -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(
{
@@ -190,6 +198,8 @@ const serviceCopy = async ({
CopySource: source,
Key: destination,
MetadataDirective: 'COPY', // Copies over metadata like contentType as well
CopySourceIfMatch: eTag,
CopySourceIfUnmodifiedSince: notModifiedSince,
},
);
};
4 changes: 2 additions & 2 deletions packages/storage/src/providers/s3/types/index.ts
Original file line number Diff line number Diff line change
@@ -17,8 +17,8 @@ export {
DownloadDataOptionsWithKey,
CopyDestinationOptionsWithKey,
CopySourceOptionsWithKey,
CopyWithPathSourceOptions,
CopyWithPathDestinationOptions,
CopySourceOptionsWithPath,
CopyDestinationOptionsWithPath,
} from './options';
export {
UploadDataOutput,
Loading
Loading