diff --git a/.eslintrc.js b/.eslintrc.js index 955eb6c96e2..01ea33f2bd3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -35,25 +35,13 @@ module.exports = { 'setupTests.ts', 'jest.setup.*', 'jest.config.*', - // 'packages/adapter-nextjs/__tests__', - // 'packages/analytics/__tests__', 'packages/api/__tests__', 'packages/api-graphql/__tests__', - // 'packages/api-rest/__tests__', - // 'packages/auth/__tests__', - // 'packages/aws-amplify/__tests__', - // 'packages/core/__tests__', 'packages/datastore/__tests__', 'packages/datastore-storage-adapter/__tests__', - // 'packages/geo/__tests__', 'packages/interactions/__tests__', - // 'packages/notifications/__tests__', 'packages/predictions/__tests__', 'packages/pubsub/__tests__', - 'packages/react-native/__tests__', - 'packages/rtn-push-notification/__tests__', - 'packages/rtn-web-browser/__tests__', - // 'packages/storage/__tests__', ], rules: { camelcase: [ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 20ef03327ad..901a2eb8cc1 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,8 +24,15 @@ the requirements below. - [ ] PR description included - [ ] `yarn test` passes -- [ ] Tests are [changed or added](https://github.com/aws-amplify/amplify-js/blob/main/CONTRIBUTING.md#steps-towards-contributions) +- [ ] Unit Tests are [changed or added](https://github.com/aws-amplify/amplify-js/blob/main/CONTRIBUTING.md#steps-towards-contributions) - [ ] Relevant documentation is changed or added (and PR referenced) + + + +#### Checklist for repo maintainers + + +- [ ] Verify E2E tests for existing workflows are working as expected or add E2E tests for newly added workflows - [ ] New source file paths included in this PR have been added to CODEOWNERS, if appropriate - + By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index cdbef83b25d..d29ae41ba42 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -822,7 +822,14 @@ tests: sample_name: [storage-gen2] spec: storage-gen2 browser: *minimal_browser_list - + - test_name: storage-guest-access + desc: 'Next Storage guest access' + framework: next + category: storage + sample_name: [guest-access] + spec: storage-client-server + browser: *minimal_browser_list + # INAPPMESSAGING - test_name: integ_in_app_messaging desc: 'React InApp Messaging' diff --git a/.github/workflows/callable-npm-publish-release.yml b/.github/workflows/callable-npm-publish-release.yml index 281eeb5a63a..6d799e7b5d3 100644 --- a/.github/workflows/callable-npm-publish-release.yml +++ b/.github/workflows/callable-npm-publish-release.yml @@ -59,7 +59,7 @@ jobs: run: | yarn run docs git add ./docs/api/ - git commit -m "chore(release): update API docs [skip release]" + git commit -m "chore(release): Update API docs [skip release]" - name: Push post release changes to the release branch working-directory: ./amplify-js diff --git a/package.json b/package.json index 7c52ae1b2ef..fbe7ec06f07 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "publish:release": "yarn generate-metadata && lerna publish --conventional-commits --message 'chore(release): Publish [skip release]' --yes", "publish:v5-stable": "lerna publish --conventional-commits --yes --dist-tag=stable-5 --message 'chore(release): Publish [ci skip]' --no-verify-access", "publish:verdaccio": "lerna publish --canary --force-publish --no-push --dist-tag=unstable --preid=unstable --yes", - "generate-metadata": "git rev-parse --short HEAD > packages/core/metadata && git commit -am 'chore: set core metadata [skip release]'", + "generate-metadata": "git rev-parse --short HEAD > packages/core/metadata && git commit -am 'chore(release): Set core metadata [skip release]'", "ts-coverage": "lerna run ts-coverage", "prepare": "husky && ./scripts/set-preid-versions.sh" }, diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 31564a92ec3..5266151387b 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -485,7 +485,7 @@ "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "14.94 kB" + "limit": "15.04 kB" }, { "name": "[Storage] remove (S3)", diff --git a/packages/datastore/__tests__/subscription.test.ts b/packages/datastore/__tests__/subscription.test.ts index aa1e649b878..e6de41bcc0a 100644 --- a/packages/datastore/__tests__/subscription.test.ts +++ b/packages/datastore/__tests__/subscription.test.ts @@ -126,6 +126,34 @@ describe('sync engine subscription module', () => { ), ).toEqual(authInfo); }); + test('owner authorization with no token(expired)', () => { + const authRules = [ + { + provider: 'userPools', + ownerField: 'owner', + allow: 'owner', + identityClaim: 'cognito:username', + operations: ['create', 'update', 'delete'], + }, + ]; + const model = generateModelWithAuth(authRules); + + const authInfo = { + authMode: 'userPool', + isOwner: false, + }; + + expect( + // @ts-ignore + SubscriptionProcessor.prototype.getAuthorizationInfo( + model, + USER_CREDENTIALS.auth, + 'userPool', + undefined, + 'userPool', + ), + ).toEqual(authInfo); + }); test('owner authorization with public subscription', () => { const authRules = [ { diff --git a/packages/datastore/src/sync/processors/subscription.ts b/packages/datastore/src/sync/processors/subscription.ts index ac3760255d0..c508c8d5885 100644 --- a/packages/datastore/src/sync/processors/subscription.ts +++ b/packages/datastore/src/sync/processors/subscription.ts @@ -205,7 +205,7 @@ class SubscriptionProcessor { : []; oidcOwnerAuthRules.forEach(ownerAuthRule => { - const ownerValue = oidcTokenPayload[ownerAuthRule.identityClaim]; + const ownerValue = oidcTokenPayload?.[ownerAuthRule.identityClaim]; const singleOwner = model.fields[ownerAuthRule.ownerField]?.isArray !== true; const isOwnerArgRequired = diff --git a/packages/rtn-push-notification/__tests__/apis/getPermissionStatus.test.ts b/packages/rtn-push-notification/__tests__/apis/getPermissionStatus.test.ts index d58cc630a7d..5046c5c9021 100644 --- a/packages/rtn-push-notification/__tests__/apis/getPermissionStatus.test.ts +++ b/packages/rtn-push-notification/__tests__/apis/getPermissionStatus.test.ts @@ -23,7 +23,7 @@ describe('getPermissionStatus', () => { beforeAll(() => { mockGetPermissionStatusNative.mockResolvedValue(status); mockNormalizeNativePermissionStatus.mockImplementation( - status => `normalized-${status}`, + statusParam => `normalized-${statusParam}`, ); }); diff --git a/packages/rtn-push-notification/__tests__/apis/registerHeadlessTask.test.ts b/packages/rtn-push-notification/__tests__/apis/registerHeadlessTask.test.ts index 8a4c5492544..11315971f32 100644 --- a/packages/rtn-push-notification/__tests__/apis/registerHeadlessTask.test.ts +++ b/packages/rtn-push-notification/__tests__/apis/registerHeadlessTask.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { AppRegistry } from 'react-native'; + import { getConstants } from '../../src/apis/getConstants'; import { registerHeadlessTask } from '../../src/apis/registerHeadlessTask'; import { normalizeNativeMessage } from '../../src/utils'; diff --git a/packages/rtn-push-notification/__tests__/testUtils/data.ts b/packages/rtn-push-notification/__tests__/testUtils/data.ts index a0913b85b25..767f0e9e6e6 100644 --- a/packages/rtn-push-notification/__tests__/testUtils/data.ts +++ b/packages/rtn-push-notification/__tests__/testUtils/data.ts @@ -33,7 +33,7 @@ export const fcmMessageOptions = { export const fcmMessagePayload = { title: 'fcm-title', body: 'fcm-body', - imageUrl: imageUrl, + imageUrl, action: {}, rawData: pushNotificationAdhocData, }; diff --git a/packages/rtn-push-notification/__tests__/utils/normalizeNativeMessage.test.ts b/packages/rtn-push-notification/__tests__/utils/normalizeNativeMessage.test.ts index 01e05c327e0..655fda3c90c 100644 --- a/packages/rtn-push-notification/__tests__/utils/normalizeNativeMessage.test.ts +++ b/packages/rtn-push-notification/__tests__/utils/normalizeNativeMessage.test.ts @@ -6,8 +6,8 @@ import { apnsMessage, apnsMessagePayload, fcmMessage, - fcmMessagePayload, fcmMessageOptions, + fcmMessagePayload, imageUrl, pushNotificationAdhocData, pushNotificationDeeplinkUrl, @@ -23,7 +23,7 @@ describe('normalizeNativeMessage', () => { expect(normalizeNativeMessage(apnsMessage)).toStrictEqual({ title, body, - imageUrl: imageUrl, + imageUrl, data: { ...pushNotificationAdhocData, 'media-url': imageUrl, @@ -72,12 +72,17 @@ describe('normalizeNativeMessage', () => { describe('fcm messages', () => { it('normalizes typical messages', () => { - const { body, rawData, imageUrl, title } = fcmMessagePayload; + const { + body, + rawData, + imageUrl: imageUrlFromPayload, + title, + } = fcmMessagePayload; expect(normalizeNativeMessage(fcmMessage)).toStrictEqual({ body, data: rawData, - imageUrl, + imageUrl: imageUrlFromPayload, title, fcmOptions: { ...fcmMessageOptions, diff --git a/packages/storage/__tests__/providers/s3/apis/list.test.ts b/packages/storage/__tests__/providers/s3/apis/list.test.ts index 9629129d7a2..82bde4a53e2 100644 --- a/packages/storage/__tests__/providers/s3/apis/list.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/list.test.ts @@ -512,4 +512,167 @@ describe('list API', () => { } }); }); + + 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.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, + }, + ); + }); + }); }); diff --git a/packages/storage/src/providers/s3/apis/internal/list.ts b/packages/storage/src/providers/s3/apis/internal/list.ts index f180dfe5247..1035559bedc 100644 --- a/packages/storage/src/providers/s3/apis/internal/list.ts +++ b/packages/storage/src/providers/s3/apis/internal/list.ts @@ -28,7 +28,9 @@ import { } from '../../utils/client'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; -import { STORAGE_INPUT_PREFIX } from '../../utils/constants'; +import { DEFAULT_DELIMITER, STORAGE_INPUT_PREFIX } from '../../utils/constants'; +import { CommonPrefix } from '../../utils/client/types'; +import { StorageSubpathStrategy } from '../../../../types'; const MAX_PAGE_SIZE = 1000; @@ -79,6 +81,7 @@ export const list = async ( Prefix: isInputWithPrefix ? `${generatedPrefix}${objectKey}` : objectKey, MaxKeys: options?.listAll ? undefined : options?.pageSize, ContinuationToken: options?.listAll ? undefined : options?.nextToken, + Delimiter: getDelimiter(options.subpathStrategy), }; logger.debug(`listing items from "${listParams.Prefix}"`); @@ -86,6 +89,7 @@ export const list = async ( s3Config, listParams, }; + if (options.listAll) { if (isInputWithPrefix) { return _listAllWithPrefix({ @@ -176,23 +180,29 @@ const _listAllWithPath = async ({ listParams, }: ListInputArgs): Promise => { const listResult: ListOutputItemWithPath[] = []; + const excludedSubpaths: string[] = []; let continuationToken = listParams.ContinuationToken; do { - const { items: pageResults, nextToken: pageNextToken } = - await _listWithPath({ - s3Config, - listParams: { - ...listParams, - ContinuationToken: continuationToken, - MaxKeys: MAX_PAGE_SIZE, - }, - }); + const { + items: pageResults, + excludedSubpaths: pageExcludedSubpaths, + nextToken: pageNextToken, + } = await _listWithPath({ + s3Config, + listParams: { + ...listParams, + ContinuationToken: continuationToken, + MaxKeys: MAX_PAGE_SIZE, + }, + }); listResult.push(...pageResults); + excludedSubpaths.push(...(pageExcludedSubpaths ?? [])); continuationToken = pageNextToken; } while (continuationToken); return { items: listResult, + excludedSubpaths, }; }; @@ -206,7 +216,11 @@ const _listWithPath = async ({ listParamsClone.MaxKeys = MAX_PAGE_SIZE; } - const response: ListObjectsV2Output = await listObjectsV2( + const { + Contents: contents, + NextContinuationToken: nextContinuationToken, + CommonPrefixes: commonPrefixes, + }: ListObjectsV2Output = await listObjectsV2( { ...s3Config, userAgentValue: getStorageUserAgentValue(StorageAction.List), @@ -214,19 +228,44 @@ const _listWithPath = async ({ listParamsClone, ); - if (!response?.Contents) { + const excludedSubpaths = + commonPrefixes && mapCommonPrefixesToExcludedSubpaths(commonPrefixes); + + if (!contents) { return { items: [], + excludedSubpaths, }; } return { - items: response.Contents.map(item => ({ + items: contents.map(item => ({ path: item.Key!, eTag: item.ETag, lastModified: item.LastModified, size: item.Size, })), - nextToken: response.NextContinuationToken, + nextToken: nextContinuationToken, + excludedSubpaths, }; }; + +const mapCommonPrefixesToExcludedSubpaths = ( + commonPrefixes: CommonPrefix[], +): string[] => { + return commonPrefixes.reduce((mappedSubpaths, { Prefix }) => { + if (Prefix) { + mappedSubpaths.push(Prefix); + } + + return mappedSubpaths; + }, [] as string[]); +}; + +const getDelimiter = ( + subpathStrategy?: StorageSubpathStrategy, +): string | undefined => { + if (subpathStrategy?.strategy === 'exclude') { + return subpathStrategy?.delimiter ?? DEFAULT_DELIMITER; + } +}; diff --git a/packages/storage/src/providers/s3/utils/constants.ts b/packages/storage/src/providers/s3/utils/constants.ts index 482343e5494..e96c83c8f3c 100644 --- a/packages/storage/src/providers/s3/utils/constants.ts +++ b/packages/storage/src/providers/s3/utils/constants.ts @@ -23,3 +23,5 @@ export const UPLOADS_STORAGE_KEY = '__uploadInProgress'; export const STORAGE_INPUT_PREFIX = 'prefix'; export const STORAGE_INPUT_KEY = 'key'; export const STORAGE_INPUT_PATH = 'path'; + +export const DEFAULT_DELIMITER = '/'; diff --git a/packages/storage/src/types/index.ts b/packages/storage/src/types/index.ts index 317fa20104c..592e28821eb 100644 --- a/packages/storage/src/types/index.ts +++ b/packages/storage/src/types/index.ts @@ -29,6 +29,7 @@ export { StorageRemoveOptions, StorageListAllOptions, StorageListPaginateOptions, + StorageSubpathStrategy, } from './options'; export { StorageItem, diff --git a/packages/storage/src/types/options.ts b/packages/storage/src/types/options.ts index b9c74590ba6..93e35acc9f7 100644 --- a/packages/storage/src/types/options.ts +++ b/packages/storage/src/types/options.ts @@ -10,12 +10,65 @@ export interface StorageOptions { export type StorageListAllOptions = StorageOptions & { listAll: true; + subpathStrategy?: StorageSubpathStrategy; }; export type StorageListPaginateOptions = StorageOptions & { listAll?: false; pageSize?: number; nextToken?: string; + subpathStrategy?: StorageSubpathStrategy; }; export type StorageRemoveOptions = StorageOptions; + +export type StorageSubpathStrategy = + | { + /** + * Default behavior. Includes all subpaths for a given page in the result. + */ + strategy: 'include'; + } + | { + /** + * When passed, the output of the list API will provide a list of `excludedSubpaths` + * that are delimited by the `/` (by default) character. + * + * + * @example + * ```ts + * const { excludedSubpaths } = await list({ + * path: 'photos/', + * options: { + * subpathStrategy: { + * strategy: 'exclude', + * } + * } + * }); + * + * console.log(excludedSubpaths); + * + * ``` + */ + strategy: 'exclude'; + /** + * Deliminate with with a custom delimiter character. + * + * @example + * ```ts + * const { excludedSubpaths } = await list({ + * path: 'photos/', + * options: { + * subpathStrategy: { + * strategy: 'exclude', + * delimiter: '-' + * } + * } + * }); + * + * console.log(excludedSubpaths); + * + * ``` + */ + delimiter?: string; + }; diff --git a/packages/storage/src/types/outputs.ts b/packages/storage/src/types/outputs.ts index e38482729b8..c728e28a9c6 100644 --- a/packages/storage/src/types/outputs.ts +++ b/packages/storage/src/types/outputs.ts @@ -70,4 +70,8 @@ export interface StorageListOutput { * List of items returned by the list API. */ items: Item[]; + /** + * List of excluded subpaths when `exclude` is passed as part of the `subpathStrategy` of the input options. + */ + excludedSubpaths?: string[]; }