-
Notifications
You must be signed in to change notification settings - Fork 301
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
base: main
Are you sure you want to change the base?
Changes from all commits
aa2c1ad
5804cb3
5d89b56
9b3467b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if the values provided are of type |
||
} | ||
|
||
describe('checkRequiredKeys', () => { | ||
it('should not throw when all required keys are present', () => { | ||
const object: TestObject = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer declarative naming of variables implying their use case (e.g. |
||
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 { | ||
|
@@ -150,6 +151,33 @@ describe('listLocationItemsHandler', () => { | |
}) | ||
); | ||
}); | ||
|
||
it('should throw if list does not provide items', async () => { | ||
mockList.mockResolvedValueOnce({} as any); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer |
||
await expect(listLocationItemsHandler(baseInput)).rejects.toThrow( | ||
'Required keys missing for ListOutput: items.' | ||
); | ||
}); | ||
|
||
['path', 'lastModified', 'size'].forEach((requiredKey) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer |
||
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}.` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a single key is missing why pluralize? |
||
); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('parseResult', () => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
/* eslint-disable @typescript-eslint/no-unsafe-argument */ | ||
import { | ||
listCallerAccessGrants, | ||
ListLocationsOutput, | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
@@ -54,7 +55,9 @@ export const downloadHandler: DownloadHandler = ({ | |
expectedBucketOwner: accountId, | ||
}, | ||
}) | ||
.then(({ url }) => { | ||
.then((result) => { | ||
checkRequiredKeys(result, 'StorageGetUrlOutput', ['url']); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } }; | ||
}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be an assertion utility (e.g. |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
|
@@ -4,6 +4,7 @@ import { | |
ListPaginateInput, | ||
ListOutput, | ||
} from '../../storage-internal'; | ||
import { checkRequiredKeys } from './integrity'; | ||
import { | ||
ListHandler, | ||
ListHandlerInput, | ||
|
@@ -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}`, [ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
) => { | ||
|
@@ -143,6 +155,8 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async ( | |
}; | ||
|
||
const output = await list(listInput); | ||
validateResult(output); | ||
|
||
nextNextToken = output.nextToken; | ||
|
||
const items = parseResult(output, prefix); | ||
|
There was a problem hiding this comment.
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