From 0283602ce400153960dbcd89146cbc52580b873f Mon Sep 17 00:00:00 2001 From: Al Harris Date: Tue, 10 Jan 2023 10:42:57 -0800 Subject: [PATCH] fix: disable searchable nodeToNode encryption unless it is already deployed to mitigate impact from enabling or disabling. --- .../transformer-factory.ts | 4 +- .../amplify-e2e-core/src/categories/api.ts | 31 ++- .../searchable-new-deployment.test.ts | 181 ++++++++++++++++++ ...evious-deployment-had-node-to-node.test.ts | 179 +++++++++++++++++ ...revious-deployment-no-node-to-node.test.ts | 177 +++++++++++++++++ ...yment-override-remove-node-to-node.test.ts | 178 +++++++++++++++++ ...-deployment-overrides-node-to-node.test.ts | 177 +++++++++++++++++ .../API.md | 2 +- .../package.json | 3 + .../__tests__/nodeToNodeEncryption.test.ts | 71 +++++++ .../src/cdk/create-searchable-domain.ts | 4 +- .../src/graphql-searchable-transformer.ts | 8 +- .../src/nodeToNodeEncryption.ts | 64 +++++++ .../amplify-graphql-transformer-core/API.md | 2 + .../src/config/transformer-config.ts | 5 + 15 files changed, 1076 insertions(+), 10 deletions(-) create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-new-deployment.test.ts create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-had-node-to-node.test.ts create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-no-node-to-node.test.ts create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-override-remove-node-to-node.test.ts create mode 100644 packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-overrides-node-to-node.test.ts create mode 100644 packages/amplify-graphql-searchable-transformer/src/__tests__/nodeToNodeEncryption.test.ts create mode 100644 packages/amplify-graphql-searchable-transformer/src/nodeToNodeEncryption.ts diff --git a/packages/amplify-category-api/src/graphql-transformer/transformer-factory.ts b/packages/amplify-category-api/src/graphql-transformer/transformer-factory.ts index 029849b5b9..d23706da69 100644 --- a/packages/amplify-category-api/src/graphql-transformer/transformer-factory.ts +++ b/packages/amplify-category-api/src/graphql-transformer/transformer-factory.ts @@ -102,7 +102,9 @@ const getTransformerFactoryV2 = ( ]; if (options?.addSearchableTransformer) { - transformerList.push(new SearchableModelTransformerV2()); + const resourceDirParts = resourceDir.split(path.sep); + const apiName = resourceDirParts[resourceDirParts.length - 1]; + transformerList.push(new SearchableModelTransformerV2(apiName)); } const customTransformersConfig = await loadProject(resourceDir); diff --git a/packages/amplify-e2e-core/src/categories/api.ts b/packages/amplify-e2e-core/src/categories/api.ts index 0aa7cd517c..7c45bf32df 100644 --- a/packages/amplify-e2e-core/src/categories/api.ts +++ b/packages/amplify-e2e-core/src/categories/api.ts @@ -982,8 +982,31 @@ export async function validateRestApiMeta(projRoot: string, meta?: any) { expect(seenAtLeastOneFunc).toBe(true); } -export function setStackMapping(projRoot: string, projectName: string, stackMapping: Record) { - const transformConfig = getTransformConfig(projRoot, projectName); - transformConfig.StackMapping = stackMapping; - setTransformConfig(projRoot, projectName, transformConfig); +export function setStackMapping(projRoot: string, apiName: string, stackMapping: Record) { + setTransformConfigValue(projRoot, apiName, 'StackMapping', stackMapping); } + +/** + * Set a specific key in the `transform.conf.json` file. + * @param projRoot root directory for the project + * @param apiName the name of the api to modify + * @param key the key in the transform.conf.json value + * @param value the value to set in the file + */ +export const setTransformConfigValue = (projRoot: string, apiName: string, key: string, value: any): void => { + const transformConfig = getTransformConfig(projRoot, apiName); + transformConfig[key] = value; + setTransformConfig(projRoot, apiName, transformConfig); +}; + +/** + * Remove a specified key from the `transform.conf.json` file. + * @param projRoot root directory for the project + * @param apiName the name of the api to modify + * @param key the key in the transform.conf.json value + */ +export const removeTransformConfigValue = (projRoot: string, apiName: string, key: string): void => { + const transformConfig = getTransformConfig(projRoot, apiName); + delete transformConfig[key]; + setTransformConfig(projRoot, apiName, transformConfig); +}; diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-new-deployment.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-new-deployment.test.ts new file mode 100644 index 0000000000..f9fa58edf4 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-new-deployment.test.ts @@ -0,0 +1,181 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + createRandomName, + addAuthWithDefault, + setTransformConfigValue, +} from 'amplify-category-api-e2e-core'; +import { addApiWithoutSchema, updateApiSchema, getProjectMeta } from 'amplify-category-api-e2e-core'; +import { createNewProjectDir, deleteProjectDir } from 'amplify-category-api-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +(global as any).fetch = require('node-fetch'); + +describe('searchable deployments succeed with various NodeToNodeEncryption flag states', () => { + let projRoot: string; + let projectName: string; + let appSyncClient = undefined; + + beforeEach(async () => { + projectName = createRandomName(); + projRoot = await createNewProjectDir(createRandomName()); + await initJSProjectWithProfile(projRoot, { + name: projectName, + }); + await addAuthWithDefault(projRoot, {}); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('succeeds deployment with NodeToNodeEncryption set to true', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', true); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + const searchableStack = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainProps = searchableStack.Resources.OpenSearchDomain.Properties; + + expect(searchDomainProps).toHaveProperty('NodeToNodeEncryptionOptions'); + expect(searchDomainProps.NodeToNodeEncryptionOptions.Enabled).toEqual(true); + }); + + it('succeeds deployment with NodeToNodeEncryption set to false', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', false); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + const searchableStack = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainProps = searchableStack.Resources.OpenSearchDomain.Properties; + + expect(searchDomainProps).not.toHaveProperty('NodeToNodeEncryptionOptions'); + }); + + const getAppSyncClientFromProj = (projRoot: string) => { + const meta = getProjectMeta(projRoot); + const region = meta['providers']['awscloudformation']['Region'] as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + return new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey, + }, + }); + }; + + const fragments = [`fragment FullTodo on Todo { id name description count }`]; + + const runMutation = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.mutate({ + mutation: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const runQuery = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.query({ + query: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const createEntry = async (name: string, description: string, count: number) => { + return await runMutation(getCreateTodosMutation(name, description, count)); + }; + + const searchTodos = async () => { + return await runQuery(getTodos()); + }; + + function getCreateTodosMutation(name: string, description: string, count: number): string { + return `mutation { + createTodo(input: { + name: "${name}" + description: "${description}" + count: ${count} + }) { ...FullTodo } + }`; + } + + function getTodos() { + return `query { + searchTodos { + items { + ...FullTodo + } + } + }`; + } + + const runAndValidateQuery = async (name: string, description: string, count: number) => { + const response = await createEntry(name, description, count); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + expect(response.data.createTodo).toBeDefined(); + + await waitForOSPropagate(); + const searchResponse = await searchTodos(); + + const expectedRows = 1; + expect(searchResponse).toBeDefined(); + expect(searchResponse.errors).toBeUndefined(); + expect(searchResponse.data).toBeDefined(); + expect(searchResponse.data.searchTodos).toBeDefined(); + expect(searchResponse.data.searchTodos.items).toHaveLength(expectedRows); + }; + + const waitForOSPropagate = async (initialWaitSeconds = 5, maxRetryCount = 5) => { + const expectedCount = 1; + let waitInMilliseconds = initialWaitSeconds * 1000; + let currentRetryCount = 0; + let searchResponse; + + do { + await new Promise(r => setTimeout(r, waitInMilliseconds)); + searchResponse = await searchTodos(); + currentRetryCount += 1; + waitInMilliseconds = waitInMilliseconds * 2; + } while (searchResponse.data.searchTodos?.items?.length < expectedCount && currentRetryCount <= maxRetryCount); + }; +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-had-node-to-node.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-had-node-to-node.test.ts new file mode 100644 index 0000000000..af3a36b36f --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-had-node-to-node.test.ts @@ -0,0 +1,179 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + createRandomName, + addAuthWithDefault, + setTransformConfigValue, + removeTransformConfigValue, +} from 'amplify-category-api-e2e-core'; +import { addApiWithoutSchema, updateApiSchema, getProjectMeta } from 'amplify-category-api-e2e-core'; +import { createNewProjectDir, deleteProjectDir } from 'amplify-category-api-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +(global as any).fetch = require('node-fetch'); + +describe('searchable deployment when previous deployed state had node to node encryption enabled', () => { + let projRoot: string; + let projectName: string; + let appSyncClient = undefined; + + beforeEach(async () => { + projectName = createRandomName(); + projRoot = await createNewProjectDir(createRandomName()); + await initJSProjectWithProfile(projRoot, { + name: projectName, + }); + await addAuthWithDefault(projRoot, {}); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('if previous deployment had NodeToNodeEncryption, carry it forward, there should be no data loss', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + + // Initial Deploy with flag explicitly set + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', true); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 1); // Expect a single record + + const searchableStackFirstDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsFirstDeploy = searchableStackFirstDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsFirstDeploy).toHaveProperty('NodeToNodeEncryptionOptions'); + expect(searchDomainPropsFirstDeploy.NodeToNodeEncryptionOptions.Enabled).toEqual(true); + + // Subsequent deploy without flag set + removeTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption'); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 2); // Expect two records + + const searchableStackSecondDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsSecondDeploy = searchableStackSecondDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsSecondDeploy).toHaveProperty('NodeToNodeEncryptionOptions'); + expect(searchDomainPropsSecondDeploy.NodeToNodeEncryptionOptions.Enabled).toEqual(true); + }); + + const getAppSyncClientFromProj = (projRoot: string) => { + const meta = getProjectMeta(projRoot); + const region = meta['providers']['awscloudformation']['Region'] as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + return new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey, + }, + }); + }; + + const fragments = [`fragment FullTodo on Todo { id name description count }`]; + + const runMutation = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.mutate({ + mutation: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const runQuery = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.query({ + query: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const createEntry = async (name: string, description: string, count: number) => { + return await runMutation(getCreateTodosMutation(name, description, count)); + }; + + const searchTodos = async () => { + return await runQuery(getTodos()); + }; + + function getCreateTodosMutation(name: string, description: string, count: number): string { + return `mutation { + createTodo(input: { + name: "${name}" + description: "${description}" + count: ${count} + }) { ...FullTodo } + }`; + } + + function getTodos() { + return `query { + searchTodos { + items { + ...FullTodo + } + } + }`; + } + + const runAndValidateQuery = async (name: string, description: string, count: number, expectedRowCount: number) => { + const response = await createEntry(name, description, count); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + expect(response.data.createTodo).toBeDefined(); + + await waitForOSPropagate(); + const searchResponse = await searchTodos(); + + expect(searchResponse).toBeDefined(); + expect(searchResponse.errors).toBeUndefined(); + expect(searchResponse.data).toBeDefined(); + expect(searchResponse.data.searchTodos).toBeDefined(); + expect(searchResponse.data.searchTodos.items).toHaveLength(expectedRowCount); + }; + + const waitForOSPropagate = async (initialWaitSeconds = 5, maxRetryCount = 5) => { + const expectedCount = 1; + let waitInMilliseconds = initialWaitSeconds * 1000; + let currentRetryCount = 0; + let searchResponse; + + do { + await new Promise(r => setTimeout(r, waitInMilliseconds)); + searchResponse = await searchTodos(); + currentRetryCount += 1; + waitInMilliseconds = waitInMilliseconds * 2; + } while (searchResponse.data.searchTodos?.items?.length < expectedCount && currentRetryCount <= maxRetryCount); + }; +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-no-node-to-node.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-no-node-to-node.test.ts new file mode 100644 index 0000000000..0f709a54d2 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-previous-deployment-no-node-to-node.test.ts @@ -0,0 +1,177 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + createRandomName, + addAuthWithDefault, + setTransformConfigValue, + removeTransformConfigValue, +} from 'amplify-category-api-e2e-core'; +import { addApiWithoutSchema, updateApiSchema, getProjectMeta } from 'amplify-category-api-e2e-core'; +import { createNewProjectDir, deleteProjectDir } from 'amplify-category-api-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +(global as any).fetch = require('node-fetch'); + +describe('searchable deployment when previous deployed state had node to node encryption disabled', () => { + let projRoot: string; + let projectName: string; + let appSyncClient = undefined; + + beforeEach(async () => { + projectName = createRandomName(); + projRoot = await createNewProjectDir(createRandomName()); + await initJSProjectWithProfile(projRoot, { + name: projectName, + }); + await addAuthWithDefault(projRoot, {}); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('if previous deployment had no NodeToNodeEncryption, carry state forward, there should be no data loss', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + + // Initial Deploy with flag explicitly set + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', false); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 1); // Expect a single record + + const searchableStackFirstDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsFirstDeploy = searchableStackFirstDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsFirstDeploy).not.toHaveProperty('NodeToNodeEncryptionOptions'); + + // Subsequent deploy without flag set + removeTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption'); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 2); // Expect two records + + const searchableStackSecondDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsSecondDeploy = searchableStackSecondDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsSecondDeploy).not.toHaveProperty('NodeToNodeEncryptionOptions'); + }); + + const getAppSyncClientFromProj = (projRoot: string) => { + const meta = getProjectMeta(projRoot); + const region = meta['providers']['awscloudformation']['Region'] as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + return new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey, + }, + }); + }; + + const fragments = [`fragment FullTodo on Todo { id name description count }`]; + + const runMutation = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.mutate({ + mutation: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const runQuery = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.query({ + query: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const createEntry = async (name: string, description: string, count: number) => { + return await runMutation(getCreateTodosMutation(name, description, count)); + }; + + const searchTodos = async () => { + return await runQuery(getTodos()); + }; + + function getCreateTodosMutation(name: string, description: string, count: number): string { + return `mutation { + createTodo(input: { + name: "${name}" + description: "${description}" + count: ${count} + }) { ...FullTodo } + }`; + } + + function getTodos() { + return `query { + searchTodos { + items { + ...FullTodo + } + } + }`; + } + + const runAndValidateQuery = async (name: string, description: string, count: number, expectedRowCount: number) => { + const response = await createEntry(name, description, count); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + expect(response.data.createTodo).toBeDefined(); + + await waitForOSPropagate(); + const searchResponse = await searchTodos(); + + expect(searchResponse).toBeDefined(); + expect(searchResponse.errors).toBeUndefined(); + expect(searchResponse.data).toBeDefined(); + expect(searchResponse.data.searchTodos).toBeDefined(); + expect(searchResponse.data.searchTodos.items).toHaveLength(expectedRowCount); + }; + + const waitForOSPropagate = async (initialWaitSeconds = 5, maxRetryCount = 5) => { + const expectedCount = 1; + let waitInMilliseconds = initialWaitSeconds * 1000; + let currentRetryCount = 0; + let searchResponse; + + do { + await new Promise(r => setTimeout(r, waitInMilliseconds)); + searchResponse = await searchTodos(); + currentRetryCount += 1; + waitInMilliseconds = waitInMilliseconds * 2; + } while (searchResponse.data.searchTodos?.items?.length < expectedCount && currentRetryCount <= maxRetryCount); + }; +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-override-remove-node-to-node.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-override-remove-node-to-node.test.ts new file mode 100644 index 0000000000..372ffb1543 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-override-remove-node-to-node.test.ts @@ -0,0 +1,178 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + createRandomName, + addAuthWithDefault, + setTransformConfigValue, + removeTransformConfigValue, +} from 'amplify-category-api-e2e-core'; +import { addApiWithoutSchema, updateApiSchema, getProjectMeta } from 'amplify-category-api-e2e-core'; +import { createNewProjectDir, deleteProjectDir } from 'amplify-category-api-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +(global as any).fetch = require('node-fetch'); + +describe('searchable deployment user flag overrides previous deployment state - will recreate table if set toggled from true to false', () => { + let projRoot: string; + let projectName: string; + let appSyncClient = undefined; + + beforeEach(async () => { + projectName = createRandomName(); + projRoot = await createNewProjectDir(createRandomName()); + await initJSProjectWithProfile(projRoot, { + name: projectName, + }); + await addAuthWithDefault(projRoot, {}); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('if previous deployment had NodeToNodeEncryption, and flag is set to false on subsequent push, deploy, search domain will be recreated', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + + // Initial Deploy with no flag set + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', true); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 1); // Expect a single record + + const searchableStackFirstDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsFirstDeploy = searchableStackFirstDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsFirstDeploy).not.toHaveProperty('NodeToNodeEncryptionOptions'); + + // Subsequent deploy with flag set + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', false); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 1); // Expect one record, previous searchable instance will be dropped + + const searchableStackSecondDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsSecondDeploy = searchableStackSecondDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsSecondDeploy).toHaveProperty('NodeToNodeEncryptionOptions'); + expect(searchDomainPropsSecondDeploy.NodeToNodeEncryptionOptions.Enabled).toEqual(true); + }); + + const getAppSyncClientFromProj = (projRoot: string) => { + const meta = getProjectMeta(projRoot); + const region = meta['providers']['awscloudformation']['Region'] as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + return new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey, + }, + }); + }; + + const fragments = [`fragment FullTodo on Todo { id name description count }`]; + + const runMutation = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.mutate({ + mutation: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const runQuery = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.query({ + query: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const createEntry = async (name: string, description: string, count: number) => { + return await runMutation(getCreateTodosMutation(name, description, count)); + }; + + const searchTodos = async () => { + return await runQuery(getTodos()); + }; + + function getCreateTodosMutation(name: string, description: string, count: number): string { + return `mutation { + createTodo(input: { + name: "${name}" + description: "${description}" + count: ${count} + }) { ...FullTodo } + }`; + } + + function getTodos() { + return `query { + searchTodos { + items { + ...FullTodo + } + } + }`; + } + + const runAndValidateQuery = async (name: string, description: string, count: number, expectedRowCount: number) => { + const response = await createEntry(name, description, count); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + expect(response.data.createTodo).toBeDefined(); + + await waitForOSPropagate(); + const searchResponse = await searchTodos(); + + expect(searchResponse).toBeDefined(); + expect(searchResponse.errors).toBeUndefined(); + expect(searchResponse.data).toBeDefined(); + expect(searchResponse.data.searchTodos).toBeDefined(); + expect(searchResponse.data.searchTodos.items).toHaveLength(expectedRowCount); + }; + + const waitForOSPropagate = async (initialWaitSeconds = 5, maxRetryCount = 5) => { + const expectedCount = 1; + let waitInMilliseconds = initialWaitSeconds * 1000; + let currentRetryCount = 0; + let searchResponse; + + do { + await new Promise(r => setTimeout(r, waitInMilliseconds)); + searchResponse = await searchTodos(); + currentRetryCount += 1; + waitInMilliseconds = waitInMilliseconds * 2; + } while (searchResponse.data.searchTodos?.items?.length < expectedCount && currentRetryCount <= maxRetryCount); + }; +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-overrides-node-to-node.test.ts b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-overrides-node-to-node.test.ts new file mode 100644 index 0000000000..f40b408422 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/graphql-v2/searchable-node-to-node-encryption/searchable-subsequent-deployment-overrides-node-to-node.test.ts @@ -0,0 +1,177 @@ +import { + initJSProjectWithProfile, + deleteProject, + amplifyPush, + createRandomName, + addAuthWithDefault, + setTransformConfigValue, + removeTransformConfigValue, +} from 'amplify-category-api-e2e-core'; +import { addApiWithoutSchema, updateApiSchema, getProjectMeta } from 'amplify-category-api-e2e-core'; +import { createNewProjectDir, deleteProjectDir } from 'amplify-category-api-e2e-core'; +import gql from 'graphql-tag'; +import AWSAppSyncClient, { AUTH_TYPE } from 'aws-appsync'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +(global as any).fetch = require('node-fetch'); + +describe('searchable deployment user flag overrides previous deployment state', () => { + let projRoot: string; + let projectName: string; + let appSyncClient = undefined; + + beforeEach(async () => { + projectName = createRandomName(); + projRoot = await createNewProjectDir(createRandomName()); + await initJSProjectWithProfile(projRoot, { + name: projectName, + }); + await addAuthWithDefault(projRoot, {}); + }); + + afterEach(async () => { + await deleteProject(projRoot); + deleteProjectDir(projRoot); + }); + + it('if previous deployment had no NodeToNodeEncryption, and flag is enabled on subsequent push, deploy, there should be no data loss', async () => { + const v2Schema = 'transformer_migration/searchable-v2.graphql'; + + await addApiWithoutSchema(projRoot, { apiName: projectName }); + updateApiSchema(projRoot, projectName, v2Schema); + + const searchableStackPath = path.join(projRoot, 'amplify', 'backend', 'api', projectName, 'build', 'stacks', 'SearchableStack.json'); + + // Initial Deploy with no flag set + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 1); // Expect a single record + + const searchableStackFirstDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsFirstDeploy = searchableStackFirstDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsFirstDeploy).not.toHaveProperty('NodeToNodeEncryptionOptions'); + + // Subsequent deploy with flag set + setTransformConfigValue(projRoot, projectName, 'NodeToNodeEncryption', true); + await amplifyPush(projRoot); + + appSyncClient = getAppSyncClientFromProj(projRoot); + await runAndValidateQuery('test1', 'test1', 10, 2); // Expect two records + + const searchableStackSecondDeploy = JSON.parse(fs.readFileSync(searchableStackPath).toString()); + const searchDomainPropsSecondDeploy = searchableStackSecondDeploy.Resources.OpenSearchDomain.Properties; + + expect(searchDomainPropsSecondDeploy).toHaveProperty('NodeToNodeEncryptionOptions'); + expect(searchDomainPropsSecondDeploy.NodeToNodeEncryptionOptions.Enabled).toEqual(true); + }); + + const getAppSyncClientFromProj = (projRoot: string) => { + const meta = getProjectMeta(projRoot); + const region = meta['providers']['awscloudformation']['Region'] as string; + const { output } = meta.api[projectName]; + const url = output.GraphQLAPIEndpointOutput as string; + const apiKey = output.GraphQLAPIKeyOutput as string; + + return new AWSAppSyncClient({ + url, + region, + disableOffline: true, + auth: { + type: AUTH_TYPE.API_KEY, + apiKey, + }, + }); + }; + + const fragments = [`fragment FullTodo on Todo { id name description count }`]; + + const runMutation = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.mutate({ + mutation: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const runQuery = async (query: string) => { + try { + const q = [query, ...fragments].join('\n'); + const response = await appSyncClient.query({ + query: gql(q), + fetchPolicy: 'no-cache', + }); + return response; + } catch (e) { + console.error(e); + return null; + } + }; + + const createEntry = async (name: string, description: string, count: number) => { + return await runMutation(getCreateTodosMutation(name, description, count)); + }; + + const searchTodos = async () => { + return await runQuery(getTodos()); + }; + + function getCreateTodosMutation(name: string, description: string, count: number): string { + return `mutation { + createTodo(input: { + name: "${name}" + description: "${description}" + count: ${count} + }) { ...FullTodo } + }`; + } + + function getTodos() { + return `query { + searchTodos { + items { + ...FullTodo + } + } + }`; + } + + const runAndValidateQuery = async (name: string, description: string, count: number, expectedRowCount: number) => { + const response = await createEntry(name, description, count); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + expect(response.data.createTodo).toBeDefined(); + + await waitForOSPropagate(); + const searchResponse = await searchTodos(); + + expect(searchResponse).toBeDefined(); + expect(searchResponse.errors).toBeUndefined(); + expect(searchResponse.data).toBeDefined(); + expect(searchResponse.data.searchTodos).toBeDefined(); + expect(searchResponse.data.searchTodos.items).toHaveLength(expectedRowCount); + }; + + const waitForOSPropagate = async (initialWaitSeconds = 5, maxRetryCount = 5) => { + const expectedCount = 1; + let waitInMilliseconds = initialWaitSeconds * 1000; + let currentRetryCount = 0; + let searchResponse; + + do { + await new Promise(r => setTimeout(r, waitInMilliseconds)); + searchResponse = await searchTodos(); + currentRetryCount += 1; + waitInMilliseconds = waitInMilliseconds * 2; + } while (searchResponse.data.searchTodos?.items?.length < expectedCount && currentRetryCount <= maxRetryCount); + }; +}); diff --git a/packages/amplify-graphql-searchable-transformer/API.md b/packages/amplify-graphql-searchable-transformer/API.md index 285fc2f921..f967d60e2f 100644 --- a/packages/amplify-graphql-searchable-transformer/API.md +++ b/packages/amplify-graphql-searchable-transformer/API.md @@ -14,7 +14,7 @@ import { TransformerTransformSchemaStepContextProvider } from '@aws-amplify/grap // @public (undocumented) export class SearchableModelTransformer extends TransformerPluginBase { - constructor(); + constructor(apiName?: string | undefined); // (undocumented) generateResolvers: (context: TransformerContextProvider) => void; // (undocumented) diff --git a/packages/amplify-graphql-searchable-transformer/package.json b/packages/amplify-graphql-searchable-transformer/package.json index 0107a2d04a..4183f3907d 100644 --- a/packages/amplify-graphql-searchable-transformer/package.json +++ b/packages/amplify-graphql-searchable-transformer/package.json @@ -46,6 +46,9 @@ "@aws-cdk/assert": "~1.172.0", "@types/node": "^12.12.6" }, + "peerDependencies": { + "amplify-cli-core": "^3.0.0" + }, "jest": { "transform": { "^.+\\.tsx?$": "ts-jest" diff --git a/packages/amplify-graphql-searchable-transformer/src/__tests__/nodeToNodeEncryption.test.ts b/packages/amplify-graphql-searchable-transformer/src/__tests__/nodeToNodeEncryption.test.ts new file mode 100644 index 0000000000..eef6f2084f --- /dev/null +++ b/packages/amplify-graphql-searchable-transformer/src/__tests__/nodeToNodeEncryption.test.ts @@ -0,0 +1,71 @@ +import { hasNodeToNodeEncryptionOptions } from '../nodeToNodeEncryption'; + +describe('hasNodeToNodeEncryptionOptions', () => { + test('returns true if the search domain has NodeToNodeEncryptionOptions with value true', () => { + const definition = { + Resources: { + OpenSearchDomain: { + Properties: { + NodeToNodeEncryptionOptions: { + Enabled: true, + }, + }, + }, + }, + }; + + expect(hasNodeToNodeEncryptionOptions(definition)).toEqual(true); + }); + + test('returns false if the search domain has NodeToNodeEncryptionOptions with value false', () => { + const definition = { + Resources: { + OpenSearchDomain: { + Properties: { + NodeToNodeEncryptionOptions: { + Enabled: false, + }, + }, + }, + }, + }; + + expect(hasNodeToNodeEncryptionOptions(definition)).toEqual(false); + }); + + test('returns false if the search domain does not have NodeToNodeEncryptionOptions', () => { + const definition = { + Resources: { + OpenSearchDomain: { + Properties: {}, + }, + }, + }; + + expect(hasNodeToNodeEncryptionOptions(definition)).toEqual(false); + }); + + test('returns false if the search domain has NodeToNodeEncryptionOptions with unknown value', () => { + const definition = { + Resources: { + OpenSearchDomain: { + Properties: { + NodeToNodeEncryptionOptions: { + Enabled: '$Token-{145}', + }, + }, + }, + }, + }; + + expect(hasNodeToNodeEncryptionOptions(definition)).toEqual(false); + }); + + test('returns false if the no search domain is found', () => { + const definition = { + Resources: {}, + }; + + expect(hasNodeToNodeEncryptionOptions(definition)).toEqual(false); + }); +}); diff --git a/packages/amplify-graphql-searchable-transformer/src/cdk/create-searchable-domain.ts b/packages/amplify-graphql-searchable-transformer/src/cdk/create-searchable-domain.ts index bf54a9461d..7945bd6b3b 100644 --- a/packages/amplify-graphql-searchable-transformer/src/cdk/create-searchable-domain.ts +++ b/packages/amplify-graphql-searchable-transformer/src/cdk/create-searchable-domain.ts @@ -10,7 +10,7 @@ import { } from '@aws-cdk/core'; import { ResourceConstants } from 'graphql-transformer-common'; -export const createSearchableDomain = (stack: Construct, parameterMap: Map, apiId: string): Domain => { +export const createSearchableDomain = (stack: Construct, parameterMap: Map, apiId: string, nodeToNodeEncryption: boolean): Domain => { const { OpenSearchEBSVolumeGB, OpenSearchInstanceType, OpenSearchInstanceCount } = ResourceConstants.PARAMETERS; const { OpenSearchDomainLogicalID } = ResourceConstants.RESOURCES; const { HasEnvironmentParameter } = ResourceConstants.CONDITIONS; @@ -23,7 +23,7 @@ export const createSearchableDomain = (stack: Construct, parameterMap: Map { + try { + const nodeToNodeEncryptionParameter = getNodeToNodeEncryptionConfigValue(apiName); + const doesExistingBackendHaveNodeToNodeEncryption = getCurrentCloudBackendStackFiles(apiName).some(definition => hasNodeToNodeEncryptionOptions(definition)); + + if (nodeToNodeEncryptionParameter !== undefined) { + return nodeToNodeEncryptionParameter; + } + + return doesExistingBackendHaveNodeToNodeEncryption; + } catch (e) { + // Fail open, and don't set the flag for the purposes of this workaround phase. + return false; + } +}; + +const getCurrentCloudBackendStackFiles = (apiName: string): any[] => { + const backendPath = path.join(pathManager.getCurrentCloudBackendDirPath(), 'api', apiName, 'build', 'stacks'); + try { + return fs.readdirSync(backendPath).map(stackFile => JSONUtilities.readJson(path.join(backendPath, stackFile))); + } catch (e) { + return []; + } +}; + +/** + * Given a Stack file, determine whether or not NodeToNodeEncryption is defined in a search domain. + * @param stackDefinition the stack to inspect + * @returns whether or not NodeToNodeEncryption was found, else false + */ +export const hasNodeToNodeEncryptionOptions = (stackDefinition: any): boolean => { + try { + const domain = stackDefinition['Resources'][ResourceConstants.RESOURCES.OpenSearchDomainLogicalID]; + const nodeToNodeEncryptionOption = domain['Properties']['NodeToNodeEncryptionOptions']['Enabled']; + return nodeToNodeEncryptionOption === true; + } catch (e) {} + return false; +}; + +const getNodeToNodeEncryptionConfigValue = (apiName: string): boolean | undefined => { + const projectRoot = pathManager.findProjectRoot(); + const configPath = projectRoot ? path.join(projectRoot, 'amplify', 'backend', 'api', apiName, TRANSFORM_CONFIG_FILE_NAME) : undefined; + if (configPath && fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as TransformConfig; + return config.NodeToNodeEncryption; + } + return undefined; +}; diff --git a/packages/amplify-graphql-transformer-core/API.md b/packages/amplify-graphql-transformer-core/API.md index 665703fb8a..89c5780e77 100644 --- a/packages/amplify-graphql-transformer-core/API.md +++ b/packages/amplify-graphql-transformer-core/API.md @@ -508,6 +508,8 @@ export interface Template { // @public (undocumented) export interface TransformConfig { + // (undocumented) + NodeToNodeEncryption?: boolean; // (undocumented) StackMapping?: { [resourceId: string]: string; diff --git a/packages/amplify-graphql-transformer-core/src/config/transformer-config.ts b/packages/amplify-graphql-transformer-core/src/config/transformer-config.ts index d869e4b3ec..0d32f0381e 100644 --- a/packages/amplify-graphql-transformer-core/src/config/transformer-config.ts +++ b/packages/amplify-graphql-transformer-core/src/config/transformer-config.ts @@ -65,4 +65,9 @@ export interface TransformConfig { // Custom transformer plugins transformers?: string[]; + + /** + * Override NodeToNodeEncryption parameter on Search Domain + */ + NodeToNodeEncryption?: boolean; }