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-browser): validate schema of data received from read APIs #6213

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { getUrl, GetUrlInput } from '../../../storage-internal';

import { downloadHandler, DownloadHandlerInput } from '../download';
Expand Down Expand Up @@ -70,4 +71,15 @@ describe('downloadHandler', () => {
message: errorMessage,
});
});

it('should fail if getUrl does not return a url', async () => {
mockGetUrl.mockResolvedValue({} as any);
Copy link
Member

Choose a reason for hiding this comment

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

Prefer @ts-expect-error with a concise comment explaining the need for the invalid response


const result = await downloadHandler(baseInput).result;
expect(result).toEqual({
message:
'Required keys missing for StorageGetUrlOutput: url.\nObject: {}',
status: 'FAILED',
});
});
});
Copy link
Member

Choose a reason for hiding this comment

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

Test directories live as siblings to the module they are testing, for example:

validators/
├─ __tests__/
│  ├─ assertResponseKeys.spec.ts
├─ assertAccountId.ts
├─ assertResponseKeys.ts

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { checkRequiredKeys } from '../../integrity';

interface TestObject {
key1?: string | null;
key2?: string | null;
key3?: string | null;
extraKey?: string | null;
Comment on lines +4 to +7
Copy link
Member

Choose a reason for hiding this comment

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

What if the values provided are of type number?

}

describe('checkRequiredKeys', () => {
it('should not throw when all required keys are present', () => {
const object: TestObject = {
Copy link
Member

Choose a reason for hiding this comment

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

Prefer declarative naming of variables implying their use case (e.g. input)

key1: 'value1',
key2: 'value2',
};

expect(
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toBeUndefined();
});

it('should not throw when required keys array is empty', () => {
const object: TestObject = { key1: 'value1', key2: 'value2' };

expect(checkRequiredKeys(object, 'test-object', [])).toBeUndefined();
});

it('should not throw when the object has extra keys', () => {
const object: TestObject = {
key1: 'value1',
key2: 'value2',
extraKey: 'extra',
};

expect(
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toBeUndefined();
});

it('should throw when a required key is missing', () => {
const object: TestObject = { key1: 'value1', key3: 'value3' };

expect(() =>
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toThrow();
});

it('should throw when object is empty and required keys are specified', () => {
const object: TestObject = {};

expect(() =>
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toThrow();
});

it('should throw on null values correctly', () => {
const object: TestObject = { key1: null, key2: 'value2' };

expect(() =>
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toThrow();
});

it('should throw on undefined values correctly', () => {
const object: TestObject = { key1: undefined, key2: 'value2' };

expect(() =>
checkRequiredKeys(object, 'test-object', ['key1', 'key2'])
).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { list } from '../../../storage-internal';

import {
Expand Down Expand Up @@ -150,6 +151,33 @@ describe('listLocationItemsHandler', () => {
})
);
});

it('should throw if list does not provide items', async () => {
mockList.mockResolvedValueOnce({} as any);
Copy link
Member

Choose a reason for hiding this comment

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

Prefer @ts-expect-error with a concise comment explaining the need for the invalid response

await expect(listLocationItemsHandler(baseInput)).rejects.toThrow(
'Required keys missing for ListOutput: items.'
);
});

['path', 'lastModified', 'size'].forEach((requiredKey) => {
Copy link
Member

Choose a reason for hiding this comment

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

Prefer jest.each

it(`should throw if any item is missing ${requiredKey}`, async () => {
mockList.mockResolvedValueOnce({
items: [
{ path: `${prefix}-1`, lastModified: new Date(), size: 0 },
{
path: `${prefix}-2`,
lastModified: new Date(),
size: 0,
[requiredKey]: undefined,
},
],
});

await expect(listLocationItemsHandler(baseInput)).rejects.toThrow(
`Required keys missing for ListOutputItem #1: ${requiredKey}.`
Copy link
Member

Choose a reason for hiding this comment

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

If a single key is missing why pluralize?

);
});
});
});

describe('parseResult', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import {
listCallerAccessGrants,
ListLocationsOutput,
Expand Down Expand Up @@ -127,4 +128,37 @@ describe('listLocationsHandler', () => {
'Storage Browser: Must provide accountId to `listCallerAccessGrants`.'
);
});

it('should throw if list does not provide locations', async () => {
input.config.accountId = accountId;
Copy link
Member

Choose a reason for hiding this comment

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

See that mutating the test input is an existing pattern but these new tests need to create a new input for each call to avoid non-deterministic behavior between individual tests, example:

await expect(listLocationsHandler({ ...input, config: { ...input.config, accountId }})).rejects.toThrow(
   'Required keys missing for ListLocationsOutput: locations.'
);

mockListCallerAccessGrants.mockResolvedValueOnce({} as any);
await expect(listLocationsHandler(input)).rejects.toThrow(
'Required keys missing for ListLocationsOutput: locations.'
);
});

['scope', 'type', 'permission'].forEach((requiredKey) => {
input.config.accountId = accountId;
it(`should throw if any location is missing ${requiredKey}`, async () => {
mockListCallerAccessGrants.mockResolvedValueOnce({
locations: [
{
scope: 's3://bucket/prefix1/*',
permission: 'READWRITE',
type: 'PREFIX',
},
{
scope: 's3://bucket/prefix1/*',
permission: 'READWRITE',
type: 'PREFIX',
[requiredKey]: undefined,
},
],
});

await expect(listLocationsHandler(input)).rejects.toThrow(
`Required keys missing for AccessGrantLocation #1: ${requiredKey}.`
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getUrl } from '../../storage-internal';
import { checkRequiredKeys } from './integrity';
import {
TaskData,
TaskHandler,
Expand Down Expand Up @@ -54,7 +55,9 @@ export const downloadHandler: DownloadHandler = ({
expectedBucketOwner: accountId,
},
})
.then(({ url }) => {
.then((result) => {
checkRequiredKeys(result, 'StorageGetUrlOutput', ['url']);
Copy link
Member

Choose a reason for hiding this comment

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

Does the UI handle the error thrown here gracefully?

const { url } = result;
downloadFromUrl(key, url.toString());
return { status: 'COMPLETE' as const, value: { url } };
})
Expand Down
Copy link
Member

Choose a reason for hiding this comment

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

Should be an assertion utility (e.g. assertResponseKeys and live with other assertion utils in https://github.com/aws-amplify/amplify-ui/tree/main/packages/react-storage/src/components/StorageBrowser/validators

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const checkRequiredKeys = <T extends object>(
objectToCheck: T,
objectName: string,
Comment on lines +2 to +3
Copy link
Member

Choose a reason for hiding this comment

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

Please updates these names to align with function parameter naming within the code base, for example:

- objectToCheck: T,
+ input: T,
- objectName: string,
+ displayName: string,

requiredKeys: Extract<keyof T, string>[]
): void => {
const missingValues = requiredKeys.filter(
(key) => objectToCheck[key] === undefined || objectToCheck[key] === null
);

if (missingValues.length > 0) {
throw Error(
`Required keys missing for ${objectName}: ${[...missingValues].join(
', '
)}.\nObject: ${JSON.stringify(objectToCheck)}`
);
Comment on lines +11 to +15
Copy link
Member

Choose a reason for hiding this comment

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

How is this intended to be surfaced to an end user and what actions are they expected to take in a missing key scenario?

}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './checkRequiredKeys';
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ListPaginateInput,
ListOutput,
} from '../../storage-internal';
import { checkRequiredKeys } from './integrity';
import {
ListHandler,
ListHandlerInput,
Expand Down Expand Up @@ -90,6 +91,17 @@ export const parseResult = (
prefix
);

const validateResult = (output: ListOutput) => {
checkRequiredKeys(output, `ListOutput`, ['items']);
output.items.forEach((item, i) =>
checkRequiredKeys(item, `ListOutputItem #${i}`, [
Copy link
Member

Choose a reason for hiding this comment

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

What purpose does providing the index value in the second positional parameter serve?

'path',
'lastModified',
'size',
Comment on lines +98 to +100
Copy link
Member

Choose a reason for hiding this comment

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

Why are these the only required keys and what is the determining factor?

])
);
};

export const listLocationItemsHandler: ListLocationItemsHandler = async (
input
) => {
Expand Down Expand Up @@ -143,6 +155,8 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async (
};

const output = await list(listInput);
validateResult(output);

nextNextToken = output.nextToken;

const items = parseResult(output, prefix);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
listCallerAccessGrants,
LocationCredentialsProvider,
ListLocationsOutput as ListCallerAccessGrantsOutput,
} from '../../storage-internal';
import { assertAccountId } from '../../validators';
import { checkRequiredKeys } from './integrity';

import {
ListHandlerOptions,
Expand Down Expand Up @@ -53,6 +55,17 @@ export interface ListLocationsHandlerOutput {
export interface ListLocationsHandler
extends ListHandler<ListLocationsHandlerInput, ListLocationsHandlerOutput> {}

const validateResult = (output: ListCallerAccessGrantsOutput) => {
checkRequiredKeys(output, 'ListLocationsOutput', ['locations']);
output.locations.forEach((location, i) =>
checkRequiredKeys(location, `AccessGrantLocation #${i}`, [
'scope',
'type',
'permission',
])
);
};

export const listLocationsHandler: ListLocationsHandler = async (input) => {
const { config, options } = input;
const { accountId, credentials, customEndpoint, region } = config;
Expand All @@ -74,6 +87,7 @@ export const listLocationsHandler: ListLocationsHandler = async (input) => {
pageSize: remainingPageSize,
region,
});
validateResult(output);

const parsedOutput = getFilteredLocations(output.locations, exclude);

Expand Down