From 8fd14581c68b1bf08e2cf3a2aa68496b4dec199d Mon Sep 17 00:00:00 2001 From: "ben.hansell1" Date: Wed, 18 Dec 2024 16:14:22 +0000 Subject: [PATCH 01/47] CCM-7465: use JWT sub as owner field in ddb --- lambdas/authorizer/src/index.ts | 13 +++++-------- .../src/__tests__/templates/api/create.test.ts | 16 ++++++++-------- .../src/__tests__/templates/api/get.test.ts | 14 +++++++------- .../src/__tests__/templates/api/list.test.ts | 12 ++++++------ .../src/__tests__/templates/api/update.test.ts | 18 +++++++++--------- .../backend-api/src/templates/api/create.ts | 6 +++--- lambdas/backend-api/src/templates/api/get.ts | 6 +++--- lambdas/backend-api/src/templates/api/list.ts | 6 +++--- .../backend-api/src/templates/api/update.ts | 6 +++--- 9 files changed, 47 insertions(+), 50 deletions(-) diff --git a/lambdas/authorizer/src/index.ts b/lambdas/authorizer/src/index.ts index ae54163a..2f46b77c 100644 --- a/lambdas/authorizer/src/index.ts +++ b/lambdas/authorizer/src/index.ts @@ -22,7 +22,7 @@ const $AccessToken = z.object({ const generatePolicy = ( Resource: string, Effect: 'Allow' | 'Deny', - context?: { username: string; email: string } + context?: { user: string } ) => ({ principalId: 'api-caller', policyDocument: { @@ -110,18 +110,15 @@ export const handler: APIGatewayRequestAuthorizerHandler = async ({ return generatePolicy(methodArn, 'Deny'); } - const emailAddress = UserAttributes.find( - ({ Name }) => Name === 'email' - )?.Value; + const sub = UserAttributes.find(({ Name }) => Name === 'sub')?.Value; - if (!emailAddress) { - logger.warn('Missing user email address'); + if (!sub) { + logger.warn('Missing user subject'); return generatePolicy(methodArn, 'Deny'); } return generatePolicy(methodArn, 'Allow', { - username: Username, - email: emailAddress, + user: sub, }); } catch (error) { logger.error(error); diff --git a/lambdas/backend-api/src/__tests__/templates/api/create.test.ts b/lambdas/backend-api/src/__tests__/templates/api/create.test.ts index c32a2f2a..823ce068 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/create.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/create.test.ts @@ -16,9 +16,9 @@ const createMock = jest.spyOn(TemplateClient.prototype, 'createTemplate'); describe('Template API - Create', () => { beforeEach(jest.resetAllMocks); - test('should return 400 - Invalid request when, no email in requestContext', async () => { + test('should return 400 - Invalid request when, no user in requestContext', async () => { const event = mock({ - requestContext: { authorizer: { email: undefined } }, + requestContext: { authorizer: { user: undefined } }, body: JSON.stringify({ id: 1 }), }); @@ -48,7 +48,7 @@ describe('Template API - Create', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: undefined, }); @@ -65,7 +65,7 @@ describe('Template API - Create', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(createMock).toHaveBeenCalledWith({}); }); @@ -79,7 +79,7 @@ describe('Template API - Create', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: JSON.stringify({ id: 1 }), }); @@ -93,7 +93,7 @@ describe('Template API - Create', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(createMock).toHaveBeenCalledWith({ id: 1 }); }); @@ -117,7 +117,7 @@ describe('Template API - Create', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: JSON.stringify(create), }); @@ -128,7 +128,7 @@ describe('Template API - Create', () => { body: JSON.stringify({ statusCode: 201, template: response }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(createMock).toHaveBeenCalledWith(create); }); diff --git a/lambdas/backend-api/src/__tests__/templates/api/get.test.ts b/lambdas/backend-api/src/__tests__/templates/api/get.test.ts index 7f7ac0e8..da56fd4d 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/get.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/get.test.ts @@ -15,9 +15,9 @@ const getTemplateMock = jest.spyOn(TemplateClient.prototype, 'getTemplate'); describe('Template API - Get', () => { beforeEach(jest.resetAllMocks); - test('should return 400 - Invalid request when, no email in requestContext', async () => { + test('should return 400 - Invalid request when, no user in requestContext', async () => { const event = mock({ - requestContext: { authorizer: { email: undefined } }, + requestContext: { authorizer: { user: undefined } }, pathParameters: { templateId: '1' }, }); @@ -36,7 +36,7 @@ describe('Template API - Get', () => { test('should return 400 - Invalid request when, no templateId', async () => { const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, pathParameters: { templateId: undefined }, }); @@ -62,7 +62,7 @@ describe('Template API - Get', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, pathParameters: { templateId: '1' }, }); @@ -76,7 +76,7 @@ describe('Template API - Get', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(getTemplateMock).toHaveBeenCalledWith('1'); }); @@ -96,7 +96,7 @@ describe('Template API - Get', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, pathParameters: { templateId: '1' }, }); @@ -107,7 +107,7 @@ describe('Template API - Get', () => { body: JSON.stringify({ statusCode: 200, template }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(getTemplateMock).toHaveBeenCalledWith('1'); }); }); diff --git a/lambdas/backend-api/src/__tests__/templates/api/list.test.ts b/lambdas/backend-api/src/__tests__/templates/api/list.test.ts index 456a9f4b..641d50e2 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/list.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/list.test.ts @@ -15,9 +15,9 @@ const listTemplatesMock = jest.spyOn(TemplateClient.prototype, 'listTemplates'); describe('Template API - List', () => { beforeEach(jest.resetAllMocks); - test('should return 400 - Invalid request when, no email in requestContext', async () => { + test('should return 400 - Invalid request when, no user in requestContext', async () => { const event = mock({ - requestContext: { authorizer: { email: undefined } }, + requestContext: { authorizer: { user: undefined } }, }); const result = await handler(event, mock(), jest.fn()); @@ -42,7 +42,7 @@ describe('Template API - List', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, pathParameters: { templateId: '1' }, }); @@ -56,7 +56,7 @@ describe('Template API - List', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(listTemplatesMock).toHaveBeenCalled(); }); @@ -77,7 +77,7 @@ describe('Template API - List', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, }); const result = await handler(event, mock(), jest.fn()); @@ -87,7 +87,7 @@ describe('Template API - List', () => { body: JSON.stringify({ statusCode: 200, templates: [template] }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(listTemplatesMock).toHaveBeenCalled(); }); diff --git a/lambdas/backend-api/src/__tests__/templates/api/update.test.ts b/lambdas/backend-api/src/__tests__/templates/api/update.test.ts index d2ea5898..2a046f3b 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/update.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/update.test.ts @@ -19,9 +19,9 @@ const updateTemplateMock = jest.spyOn( describe('Template API - Update', () => { beforeEach(jest.resetAllMocks); - test('should return 400 - Invalid request when, no email in requestContext', async () => { + test('should return 400 - Invalid request when, no user in requestContext', async () => { const event = mock({ - requestContext: { authorizer: { email: undefined } }, + requestContext: { authorizer: { user: undefined } }, body: JSON.stringify({ name: 'test' }), pathParameters: { templateId: '1-2-3' }, }); @@ -52,7 +52,7 @@ describe('Template API - Update', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, pathParameters: { templateId: '1-2-3' }, body: undefined, }); @@ -70,14 +70,14 @@ describe('Template API - Update', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(updateTemplateMock).toHaveBeenCalledWith('1-2-3', {}); }); test('should return 400 - Invalid request when, no templateId', async () => { const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: JSON.stringify({ name: 'test' }), pathParameters: { templateId: undefined }, }); @@ -104,7 +104,7 @@ describe('Template API - Update', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: JSON.stringify({ name: 'name' }), pathParameters: { templateId: '1-2-3' }, }); @@ -119,7 +119,7 @@ describe('Template API - Update', () => { }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(updateTemplateMock).toHaveBeenCalledWith('1-2-3', { name: 'name' }); }); @@ -144,7 +144,7 @@ describe('Template API - Update', () => { }); const event = mock({ - requestContext: { authorizer: { email: 'email' } }, + requestContext: { authorizer: { user: 'sub' } }, body: JSON.stringify(update), pathParameters: { templateId: '1-2-3' }, }); @@ -156,7 +156,7 @@ describe('Template API - Update', () => { body: JSON.stringify({ statusCode: 200, template: response }), }); - expect(TemplateClient).toHaveBeenCalledWith('email'); + expect(TemplateClient).toHaveBeenCalledWith('sub'); expect(updateTemplateMock).toHaveBeenCalledWith('1-2-3', update); }); diff --git a/lambdas/backend-api/src/templates/api/create.ts b/lambdas/backend-api/src/templates/api/create.ts index bb47fbc3..178eebc2 100644 --- a/lambdas/backend-api/src/templates/api/create.ts +++ b/lambdas/backend-api/src/templates/api/create.ts @@ -3,15 +3,15 @@ import { TemplateClient } from '@backend-api/templates/app/template-client'; import { apiFailure, apiSuccess } from './responses'; export const handler: APIGatewayProxyHandler = async (event) => { - const email = event.requestContext.authorizer?.email; + const user = event.requestContext.authorizer?.user; const dto = JSON.parse(event.body || '{}'); - if (!email) { + if (!user) { return apiFailure(400, 'Invalid request'); } - const client = new TemplateClient(email); + const client = new TemplateClient(user); const { data, error } = await client.createTemplate(dto); diff --git a/lambdas/backend-api/src/templates/api/get.ts b/lambdas/backend-api/src/templates/api/get.ts index 4c67e568..6b76f857 100644 --- a/lambdas/backend-api/src/templates/api/get.ts +++ b/lambdas/backend-api/src/templates/api/get.ts @@ -3,15 +3,15 @@ import { TemplateClient } from '@backend-api/templates/app/template-client'; import { apiFailure, apiSuccess } from './responses'; export const handler: APIGatewayProxyHandler = async (event) => { - const email = event.requestContext.authorizer?.email; + const user = event.requestContext.authorizer?.user; const templateId = event.pathParameters?.templateId; - if (!email || !templateId) { + if (!user || !templateId) { return apiFailure(400, 'Invalid request'); } - const client = new TemplateClient(email); + const client = new TemplateClient(user); const { data, error } = await client.getTemplate(templateId); diff --git a/lambdas/backend-api/src/templates/api/list.ts b/lambdas/backend-api/src/templates/api/list.ts index 0a5f2298..18b8bec0 100644 --- a/lambdas/backend-api/src/templates/api/list.ts +++ b/lambdas/backend-api/src/templates/api/list.ts @@ -3,13 +3,13 @@ import { TemplateClient } from '@backend-api/templates/app/template-client'; import { apiFailure, apiSuccess } from './responses'; export const handler: APIGatewayProxyHandler = async (event) => { - const email = event.requestContext.authorizer?.email; + const user = event.requestContext.authorizer?.user; - if (!email) { + if (!user) { return apiFailure(400, 'Invalid request'); } - const client = new TemplateClient(email); + const client = new TemplateClient(user); const { data, error } = await client.listTemplates(); diff --git a/lambdas/backend-api/src/templates/api/update.ts b/lambdas/backend-api/src/templates/api/update.ts index 58134167..90959d76 100644 --- a/lambdas/backend-api/src/templates/api/update.ts +++ b/lambdas/backend-api/src/templates/api/update.ts @@ -3,17 +3,17 @@ import { TemplateClient } from '@backend-api/templates/app/template-client'; import { apiFailure, apiSuccess } from './responses'; export const handler: APIGatewayProxyHandler = async (event) => { - const email = event.requestContext.authorizer?.email; + const user = event.requestContext.authorizer?.user; const templateId = event.pathParameters?.templateId; const dto = JSON.parse(event.body || '{}'); - if (!email || !templateId) { + if (!user || !templateId) { return apiFailure(400, 'Invalid request'); } - const client = new TemplateClient(email); + const client = new TemplateClient(user); const { data, error } = await client.updateTemplate(templateId, dto); From 1e04f87242225f267d7fa94840530b5df86215d0 Mon Sep 17 00:00:00 2001 From: "ben.hansell1" Date: Thu, 19 Dec 2024 16:23:21 +0000 Subject: [PATCH 02/47] CCM-7465: use subject for owner --- .../authorizer/src/__tests__/index.test.ts | 19 +++++++++---------- .../__tests__/utils/remove-undefined.test.ts | 0 .../src/utils/remove-undefined.ts | 0 3 files changed, 9 insertions(+), 10 deletions(-) rename {frontend => lambdas/backend-api}/src/__tests__/utils/remove-undefined.test.ts (100%) rename {frontend => lambdas/backend-api}/src/utils/remove-undefined.ts (100%) diff --git a/lambdas/authorizer/src/__tests__/index.test.ts b/lambdas/authorizer/src/__tests__/index.test.ts index 88d578c3..bd3f6dc4 100644 --- a/lambdas/authorizer/src/__tests__/index.test.ts +++ b/lambdas/authorizer/src/__tests__/index.test.ts @@ -32,7 +32,7 @@ jest.mock('@aws-sdk/client-cognito-identity-provider', () => { ) { return { Username: undefined, - UserAttributes: [{ Name: 'email', Value: 'email' }], + UserAttributes: [{ Name: 'sub', Value: 'sub' }], }; } @@ -48,17 +48,17 @@ jest.mock('@aws-sdk/client-cognito-identity-provider', () => { if ( decodedJwt.iss === - 'https://cognito-idp.eu-west-2.amazonaws.com/user-pool-id-cognito-no-email' + 'https://cognito-idp.eu-west-2.amazonaws.com/user-pool-id-cognito-no-sub' ) { return { Username: 'username', - UserAttributes: [{ Name: 'NOT-EMAIL', Value: 'not-email' }], + UserAttributes: [{ Name: 'NOT-SUB', Value: 'not-sub' }], }; } return { Username: 'username', - UserAttributes: [{ Name: 'email', Value: 'email' }], + UserAttributes: [{ Name: 'sub', Value: 'sub' }], }; } } @@ -100,8 +100,7 @@ const allowPolicy = { ], }, context: { - username: 'username', - email: 'email', + user: 'sub', }, }; @@ -361,14 +360,14 @@ test.each([ expect(warnMock).toHaveBeenCalledWith('Missing user'); }); -test('returns Deny policy, when no email on Cognito UserAttributes', async () => { - process.env.USER_POOL_ID = 'user-pool-id-cognito-no-email'; +test('returns Deny policy, when no sub on Cognito UserAttributes', async () => { + process.env.USER_POOL_ID = 'user-pool-id-cognito-no-sub'; const jwt = sign( { token_use: 'access', client_id: 'user-pool-client-id', - iss: 'https://cognito-idp.eu-west-2.amazonaws.com/user-pool-id-cognito-no-email', + iss: 'https://cognito-idp.eu-west-2.amazonaws.com/user-pool-id-cognito-no-sub', }, 'key', { @@ -387,7 +386,7 @@ test('returns Deny policy, when no email on Cognito UserAttributes', async () => ); expect(res).toEqual(denyPolicy); - expect(warnMock).toHaveBeenCalledWith('Missing user email address'); + expect(warnMock).toHaveBeenCalledWith('Missing user subject'); }); test('returns Allow policy on valid token', async () => { diff --git a/frontend/src/__tests__/utils/remove-undefined.test.ts b/lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts similarity index 100% rename from frontend/src/__tests__/utils/remove-undefined.test.ts rename to lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts diff --git a/frontend/src/utils/remove-undefined.ts b/lambdas/backend-api/src/utils/remove-undefined.ts similarity index 100% rename from frontend/src/utils/remove-undefined.ts rename to lambdas/backend-api/src/utils/remove-undefined.ts From 33bd1510feefd7f438f9bcebfa0b2b5e6ac0ade4 Mon Sep 17 00:00:00 2001 From: "ben.hansell1" Date: Thu, 19 Dec 2024 16:35:01 +0000 Subject: [PATCH 03/47] CCM-7465: remove undefined from API response --- .../backend-api/src/__tests__/utils/remove-undefined.test.ts | 2 +- lambdas/backend-api/src/templates/api/responses.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts b/lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts index 505bb128..22855c87 100644 --- a/lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts +++ b/lambdas/backend-api/src/__tests__/utils/remove-undefined.test.ts @@ -1,5 +1,5 @@ /* eslint-disable unicorn/no-null */ -import { removeUndefinedFromObject } from '@utils/remove-undefined'; +import { removeUndefinedFromObject } from '@backend-api/utils/remove-undefined'; type TestType = { param1: string; diff --git a/lambdas/backend-api/src/templates/api/responses.ts b/lambdas/backend-api/src/templates/api/responses.ts index 3c6a42a5..c845565f 100644 --- a/lambdas/backend-api/src/templates/api/responses.ts +++ b/lambdas/backend-api/src/templates/api/responses.ts @@ -1,3 +1,4 @@ +import { removeUndefinedFromObject } from '@backend-api/utils/remove-undefined'; import { Failure, Success, @@ -14,7 +15,7 @@ export const apiSuccess = ( statusCode, body: JSON.stringify({ statusCode, - templates: result, + templates: result.map((item) => removeUndefinedFromObject(item)), } satisfies SuccessList), }; } @@ -23,7 +24,7 @@ export const apiSuccess = ( statusCode, body: JSON.stringify({ statusCode, - template: result, + template: removeUndefinedFromObject(result), } satisfies Success), }; }; From 583b521d1fb0ace466accf2ded7aa2ce7dc64ab4 Mon Sep 17 00:00:00 2001 From: "ben.hansell1" Date: Fri, 20 Dec 2024 08:37:47 +0000 Subject: [PATCH 04/47] CCM-7465: implement backend api and update automated tests --- frontend/package.json | 1 + .../SubmitTemplate/server-action.test.ts | 14 +- .../src/__tests__/utils/amplify-utils.test.ts | 46 +- .../src/__tests__/utils/form-actions.test.ts | 471 +++++++-------- .../forms/SubmitTemplate/server-action.ts | 3 +- .../ManageTemplates/ManageTemplates.tsx | 3 +- frontend/src/utils/amplify-utils.ts | 20 + frontend/src/utils/form-actions.ts | 132 ++--- frontend/src/utils/validate-template.ts | 17 +- .../aws_cognito_user_pool_client_sandbox.tf | 2 +- .../modules/backend-api/spec.tmpl.json | 6 +- .../__tests__/templates/api/create.test.ts | 1 + .../src/__tests__/templates/api/get.test.ts | 1 + .../src/__tests__/templates/api/list.test.ts | 1 + .../__tests__/templates/api/responses.test.ts | 1 + .../__tests__/templates/api/update.test.ts | 1 + .../src/types/generated/models/TemplateDTO.ts | 1 + package-lock.json | 545 ++++++++++++++++++ tests/test-team/auth/.gitignore | 1 + tests/test-team/config/auth.setup.ts | 18 + tests/test-team/config/auth.teardown.ts | 16 + tests/test-team/config/global.setup.ts | 32 +- tests/test-team/config/local.config.ts | 16 + tests/test-team/global.d.ts | 6 + .../test-team/helpers/cognito-user-helper.ts | 94 +++ .../helpers/database-tablename-helper.ts | 63 -- tests/test-team/helpers/outputs-helper.ts | 35 ++ tests/test-team/helpers/template-factory.ts | 2 +- tests/test-team/helpers/types.ts | 2 +- tests/test-team/package.json | 4 + .../pages/templates-mgmt-login-page.ts | 51 ++ ...plate-mgmt-preview-email-page.component.ts | 2 +- ...ate-mgmt-preview-nhs-app-page.component.ts | 2 +- ...emplate-mgmt-preview-sms-page.component.ts | 2 +- .../template-mgmt-common.steps.ts | 8 +- .../template-mgmt-start-page.component.ts | 3 + .../template-mgmt-submit-page.component.ts | 6 +- utils/package.json | 3 +- utils/src/enum.ts | 12 +- utils/src/zod-validators.ts | 2 +- 40 files changed, 1181 insertions(+), 465 deletions(-) create mode 100644 tests/test-team/auth/.gitignore create mode 100644 tests/test-team/config/auth.setup.ts create mode 100644 tests/test-team/config/auth.teardown.ts create mode 100644 tests/test-team/helpers/cognito-user-helper.ts delete mode 100644 tests/test-team/helpers/database-tablename-helper.ts create mode 100644 tests/test-team/helpers/outputs-helper.ts create mode 100644 tests/test-team/pages/templates-mgmt-login-page.ts diff --git a/frontend/package.json b/frontend/package.json index 6fbce8a9..01669bde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "next": "14.2.13", "nhs-notify-web-template-management-amplify": "*", "nhs-notify-web-template-management-utils": "*", + "nhs-notify-backend-client": "*", "nhsuk-frontend": "^8.3.0", "nhsuk-react-components": "^4.1.1", "path": "^0.12.7", diff --git a/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts b/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts index 0b598448..312fc78f 100644 --- a/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts +++ b/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts @@ -93,12 +93,7 @@ describe('submitTemplate', () => { await submitTemplate('submit-route', formData); - expect(sendEmailMock).toHaveBeenCalledWith( - mockNhsAppTemplate.id, - mockNhsAppTemplate.name, - mockNhsAppTemplate.message, - null - ); + expect(sendEmailMock).toHaveBeenCalledWith(mockNhsAppTemplate.id); expect(redirectMock).toHaveBeenCalledWith('/submit-route/1', 'push'); }); @@ -122,11 +117,6 @@ describe('submitTemplate', () => { await submitTemplate('submit-route', formData); - expect(sendEmailMock).toHaveBeenCalledWith( - mockEmailTemplate.id, - mockEmailTemplate.name, - mockEmailTemplate.message, - mockEmailTemplate.subject - ); + expect(sendEmailMock).toHaveBeenCalledWith(mockEmailTemplate.id); }); }); diff --git a/frontend/src/__tests__/utils/amplify-utils.test.ts b/frontend/src/__tests__/utils/amplify-utils.test.ts index 714fdbe0..fa445b98 100644 --- a/frontend/src/__tests__/utils/amplify-utils.test.ts +++ b/frontend/src/__tests__/utils/amplify-utils.test.ts @@ -1,10 +1,15 @@ /** * @jest-environment node */ -import { getAmplifyBackendClient } from '@utils/amplify-utils'; +import { + getAmplifyBackendClient, + getAccessTokenServer, +} from '@utils/amplify-utils'; import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'; +import { fetchAuthSession } from 'aws-amplify/auth/server'; import nextHeaders from 'next/headers'; +jest.mock('aws-amplify/auth/server'); jest.mock('@aws-amplify/adapter-nextjs/api'); jest.mock('@/amplify_outputs.json', () => ({ name: 'mockConfig', @@ -13,6 +18,8 @@ jest.mock('next/headers', () => ({ cookies: () => {}, })); +const fetchAuthSessionMock = jest.mocked(fetchAuthSession); + test('getAmplifyBackendClient', () => { // arrange const generateServerClientUsingCookiesMock = jest.mocked( @@ -31,3 +38,40 @@ test('getAmplifyBackendClient', () => { authMode: 'iam', }); }); + +describe('getAccessTokenServer', () => { + test('should return the auth token', async () => { + fetchAuthSessionMock.mockResolvedValue({ + tokens: { + accessToken: { + toString: () => 'mockSub', + payload: { + sub: 'mockSub', + }, + }, + }, + }); + + const result = await getAccessTokenServer(); + + expect(result).toEqual('mockSub'); + }); + + test('should return undefined when no auth session', async () => { + fetchAuthSessionMock.mockResolvedValue({}); + + const result = await getAccessTokenServer(); + + expect(result).toBeUndefined(); + }); + + test('should return undefined an error occurs', async () => { + fetchAuthSessionMock.mockImplementationOnce(() => { + throw new Error('JWT Expired'); + }); + + const result = await getAccessTokenServer(); + + expect(result).toBeUndefined(); + }); +}); diff --git a/frontend/src/__tests__/utils/form-actions.test.ts b/frontend/src/__tests__/utils/form-actions.test.ts index 4919e83f..f25747bc 100644 --- a/frontend/src/__tests__/utils/form-actions.test.ts +++ b/frontend/src/__tests__/utils/form-actions.test.ts @@ -7,7 +7,6 @@ import { TemplateType, TemplateStatus, } from 'nhs-notify-web-template-management-utils'; -import { logger } from 'nhs-notify-web-template-management-utils/logger'; import { createTemplate, saveTemplate, @@ -15,342 +14,262 @@ import { sendEmail, getTemplates, } from '@utils/form-actions'; -import { getAmplifyBackendClient } from '@utils/amplify-utils'; +import { getAccessTokenServer } from '@utils/amplify-utils'; import { mockDeep } from 'jest-mock-extended'; -import type { Template } from 'nhs-notify-web-template-management-utils'; - -jest.mock('@aws-amplify/adapter-nextjs/data'); -jest.mock('node:crypto'); - -const mockResponseData = { - id: 'id', - templateId: 'template-id', - createdAt: 'created-at', - updatedAt: 'updated-at', - name: 'template-name', - message: 'template-message', -}; - -const mockTemplates: Template[] = [ - { - id: '1', - version: 1, - templateType: TemplateType.NHS_APP, - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'Template 1', - message: 'Message', - subject: 'Subject Line', - createdAt: '2021-01-01T00:00:00.000Z', - }, -]; +import { IBackendClient } from 'nhs-notify-backend-client/src/types/backend-client'; + +const mockedBackendClient = mockDeep(); +const authIdTokenServerMock = jest.mocked(getAccessTokenServer); jest.mock('@utils/amplify-utils'); +jest.mock('nhs-notify-backend-client/src/backend-api-client', () => ({ + BackendClient: () => mockedBackendClient, +})); + +describe('form-actions', () => { + beforeEach(() => { + jest.resetAllMocks(); + authIdTokenServerMock.mockResolvedValueOnce('token'); + }); -beforeEach(() => { - jest.resetAllMocks(); -}); + test('createTemplate', async () => { + const responseData = { + id: 'id', + version: 1, + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; -type MockSchema = ReturnType; + mockedBackendClient.templates.createTemplate.mockResolvedValueOnce({ + data: responseData, + }); -type MockSchemaInput = Parameters>[0]; + const createTemplateInput: Draft = { + version: 1, + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + }; -const setup = (schema: MockSchemaInput) => { - const mockSchema = mockDeep(schema); + const response = await createTemplate(createTemplateInput); - jest.mocked(getAmplifyBackendClient).mockReturnValue(mockSchema); -}; + expect(mockedBackendClient.templates.createTemplate).toHaveBeenCalledWith( + createTemplateInput + ); -test('createTemplate', async () => { - const mockCreateTemplate = jest - .fn() - .mockReturnValue({ data: mockResponseData }); - setup({ - models: { - TemplateStorage: { - create: mockCreateTemplate, - }, - }, + expect(response).toEqual(responseData); }); - const createTemplateInput: Draft = { - version: 1, - templateType: TemplateType.NHS_APP, - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'name', - message: 'message', - }; + test('createTemplate - should thrown error when saving unexpectedly fails', async () => { + mockedBackendClient.templates.createTemplate.mockResolvedValueOnce({ + error: { + code: 400, + message: 'Bad request', + }, + }); - const response = await createTemplate(createTemplateInput); + const createTemplateInput: Draft = { + version: 1, + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + }; - expect(mockCreateTemplate).toHaveBeenCalledWith(createTemplateInput); - expect(response).toEqual(mockResponseData); -}); + await expect(createTemplate(createTemplateInput)).rejects.toThrow( + 'Failed to create new template' + ); -test('createTemplate - error handling', async () => { - const mockcreateTemplate = jest.fn().mockReturnValue({ - errors: [ - { - message: 'test-error-message', - errorType: 'test-error-type', - errorInfo: { error: 'test-error' }, - }, - ], - }); - setup({ - models: { - TemplateStorage: { - create: mockcreateTemplate, - }, - }, + expect(mockedBackendClient.templates.createTemplate).toHaveBeenCalledWith( + createTemplateInput + ); }); - await expect( - createTemplate({ + test('saveTemplate', async () => { + const responseData = { + id: 'id', version: 1, templateType: TemplateType.NHS_APP, - } as unknown as Template) - ).rejects.toThrow('Failed to create new template'); -}); + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; + + mockedBackendClient.templates.updateTemplate.mockResolvedValueOnce({ + data: responseData, + }); + + const updateTemplateInput: NHSAppTemplate = { + id: 'pickle', + version: 1, + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + }; -test('saveTemplate', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ data: mockResponseData }), - }, - }, - }); + const response = await saveTemplate(updateTemplateInput); - const response = await saveTemplate({ - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', - version: 1, - templateType: TemplateType.NHS_APP, - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', - }); + expect(mockedBackendClient.templates.updateTemplate).toHaveBeenCalledWith( + updateTemplateInput.id, + updateTemplateInput + ); - expect(response).toEqual(mockResponseData); -}); + expect(response).toEqual(responseData); + }); -test('saveTemplate - error handling', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ - errors: [ - { - message: 'test-error-message', - errorType: 'test-error-type', - errorInfo: { error: 'test-error' }, - }, - ], - }), + test('saveTemplate - should thrown error when saving unexpectedly fails', async () => { + mockedBackendClient.templates.updateTemplate.mockResolvedValueOnce({ + error: { + code: 400, + message: 'Bad request', }, - }, - }); + }); - await expect( - saveTemplate({ - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', + const updateTemplateInput: NHSAppTemplate = { + id: 'pickle', version: 1, templateType: TemplateType.NHS_APP, templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', - }) - ).rejects.toThrow('Failed to save template data'); -}); - -test('saveTemplate - error handling - when no data returned', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ - errors: undefined, - data: undefined, - }), - }, - }, + name: 'name', + message: 'message', + }; + + await expect(saveTemplate(updateTemplateInput)).rejects.toThrow( + 'Failed to save template data' + ); + + expect(mockedBackendClient.templates.updateTemplate).toHaveBeenCalledWith( + updateTemplateInput.id, + updateTemplateInput + ); }); - await expect( - saveTemplate({ - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', + test('getTemplate', async () => { + const responseData = { + id: 'id', version: 1, templateType: TemplateType.NHS_APP, templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', - }) - ).rejects.toThrow( - 'Template in unknown state. No errors reported but entity returned as falsy' - ); -}); + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; -test('getTemplate', async () => { - setup({ - models: { - TemplateStorage: { - get: jest.fn().mockReturnValue({ data: mockResponseData }), - }, - }, - }); + mockedBackendClient.templates.getTemplate.mockResolvedValueOnce({ + data: responseData, + }); - const response = await getTemplate('template-id'); + const response = await getTemplate('id'); - expect(response).toEqual(mockResponseData); -}); + expect(mockedBackendClient.templates.getTemplate).toHaveBeenCalledWith( + 'id' + ); -test('getTemplate - returns undefined if template is not found', async () => { - setup({ - models: { - TemplateStorage: { - get: jest.fn().mockReturnValue({ - errors: [ - { - message: 'test-error-message', - errorType: 'test-error-type', - errorInfo: { error: 'test-error' }, - }, - ], - }), - }, - }, + expect(response).toEqual(responseData); }); - const response = await getTemplate('template-id'); + test('getTemplate - should return undefined when no data', async () => { + mockedBackendClient.templates.getTemplate.mockResolvedValueOnce({ + data: undefined, + error: { + code: 404, + message: 'Not found', + }, + }); + + const response = await getTemplate('id'); - expect(response).toBeUndefined(); -}); + expect(mockedBackendClient.templates.getTemplate).toHaveBeenCalledWith( + 'id' + ); -test('sendEmail - no errors', async () => { - setup({ - queries: { - sendEmail: jest.fn().mockReturnValue({}), - }, + expect(response).toEqual(undefined); }); - const mockErrorLogger = jest.spyOn(logger, 'error'); - await sendEmail('template-id', 'template-name', 'template-message', null); + test('getTemplates', async () => { + const responseData = { + id: 'id', + version: 1, + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; - expect(mockErrorLogger).not.toHaveBeenCalled(); -}); + mockedBackendClient.templates.listTemplates.mockResolvedValueOnce({ + data: [responseData], + }); -test('sendEmail - errors', async () => { - setup({ - queries: { - sendEmail: jest.fn().mockReturnValue({ errors: ['email error'] }), - }, - }); + const response = await getTemplates(); - const mockErrorLogger = jest.spyOn(logger, 'error'); - await sendEmail( - 'template-id-error', - 'template-name', - 'template-message', - null - ); - - expect(mockErrorLogger).toHaveBeenCalledWith({ - description: 'Error sending email', - res: { - errors: ['email error'], - }, + expect(mockedBackendClient.templates.listTemplates).toHaveBeenCalledWith(); + + expect(response).toEqual([responseData]); }); -}); -test('getTemplates', async () => { - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: mockTemplates }), + test('getTemplates - should return empty array when fetching unexpectedly fails', async () => { + mockedBackendClient.templates.listTemplates.mockResolvedValueOnce({ + data: undefined, + error: { + code: 500, + message: 'Internal server error', }, - }, - }); - const response = await getTemplates(); + }); - expect(response).toEqual(mockTemplates); -}); + const response = await getTemplates(); -test('getTemplates - remove invalid templates from response', async () => { - const templatesWithInvalidData = [ - ...mockTemplates, - { - id: '1', - version: 1, - templateType: 'invalidType', - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'Template 1', - message: 'Message', - subject: 'Subject Line', - createdAt: '2021-01-01T00:00:00.000Z', - }, - ]; - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: templatesWithInvalidData }), - }, - }, + expect(response).toEqual([]); }); - const response = await getTemplates(); - expect(response).toEqual(mockTemplates); -}); + test('sendEmail', async () => { + mockedBackendClient.functions.sendEmail.mockResolvedValueOnce({ + data: undefined, + error: undefined, + }); -test('getTemplates - returns empty array if there are no templates/data returned', async () => { - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: [] }), - }, - }, - }); + const response = await sendEmail('id'); - const response = await getTemplates(); + expect(mockedBackendClient.functions.sendEmail).toHaveBeenCalledWith('id'); - expect(response).toEqual([]); -}); + expect(response).toEqual(undefined); + }); -test('getTemplates - errors', async () => { - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ - errors: [ - { - message: 'test-error-message', - errorType: 'test-error-type', - errorInfo: { error: 'test-error' }, - }, - ], - }), + test('getTemplates - should return nothing when an error occurs', async () => { + mockedBackendClient.functions.sendEmail.mockResolvedValueOnce({ + data: undefined, + error: { + code: 404, + message: 'Not found', }, - }, - }); + }); - const mockErrorLogger = jest.spyOn(logger, 'error'); - const response = await getTemplates(); + const response = await sendEmail('id'); - expect(mockErrorLogger).toHaveBeenCalledWith('Failed to get templates', [ - { - errorInfo: { error: 'test-error' }, - errorType: 'test-error-type', - message: 'test-error-message', - }, - ]); + expect(mockedBackendClient.functions.sendEmail).toHaveBeenCalledWith('id'); - expect(response).toEqual([]); + expect(response).toEqual(undefined); + }); }); test('getTemplates - order by createdAt and then id', async () => { const baseTemplate = { version: 1, - templateType: 'SMS', + templateType: TemplateType.SMS, templateStatus: TemplateStatus.NOT_YET_SUBMITTED, name: 'Template', message: 'Message', + updatedAt: '2021-01-01T00:00:00.000Z', }; const templates = [ @@ -358,10 +277,10 @@ test('getTemplates - order by createdAt and then id', async () => { { ...baseTemplate, id: '08', createdAt: '2020-01-01T00:00:00.000Z' }, { ...baseTemplate, id: '05', createdAt: '2021-01-01T00:00:00.000Z' }, { ...baseTemplate, id: '02', createdAt: '2021-01-01T00:00:00.000Z' }, - { ...baseTemplate, id: '09' }, - { ...baseTemplate, id: '10' }, + { ...baseTemplate, id: '09', createdAt: undefined as unknown as string }, + { ...baseTemplate, id: '10', createdAt: undefined as unknown as string }, { ...baseTemplate, id: '01', createdAt: '2021-01-01T00:00:00.000Z' }, - { ...baseTemplate, id: '07' }, + { ...baseTemplate, id: '07', createdAt: undefined as unknown as string }, { ...baseTemplate, id: '03', createdAt: '2021-01-01T00:00:00.000Z' }, { ...baseTemplate, id: '04', createdAt: '2021-01-01T00:00:00.000Z' }, ]; @@ -382,12 +301,8 @@ test('getTemplates - order by createdAt and then id', async () => { '10', ]; - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: templates }), - }, - }, + mockedBackendClient.templates.listTemplates.mockResolvedValueOnce({ + data: templates, }); const response = await getTemplates(); diff --git a/frontend/src/components/forms/SubmitTemplate/server-action.ts b/frontend/src/components/forms/SubmitTemplate/server-action.ts index 8b7120b2..c24dda71 100644 --- a/frontend/src/components/forms/SubmitTemplate/server-action.ts +++ b/frontend/src/components/forms/SubmitTemplate/server-action.ts @@ -32,8 +32,7 @@ export async function submitTemplate(route: string, formData: FormData) { templateStatus: TemplateStatus.SUBMITTED, }); - const { name, subject, message } = { subject: null, ...validatedTemplate }; - await sendEmail(templateId, name, message, subject); + await sendEmail(templateId); } catch (error) { logger.error('Failed to submit template', { error, diff --git a/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx b/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx index 1db8e4a4..d65b9d87 100644 --- a/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx +++ b/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx @@ -14,6 +14,7 @@ import { templateTypeDisplayMappings, viewSubmittedTemplatePages, } from 'nhs-notify-web-template-management-utils'; +import { TemplateDTO } from 'nhs-notify-backend-client'; const manageTemplatesContent = content.pages.manageTemplates; @@ -28,7 +29,7 @@ const generateViewTemplateLink = (template: Template): string => { export function ManageTemplates({ templateList, }: { - templateList: Template[]; + templateList: Template[] | TemplateDTO[]; }) { return (
diff --git a/frontend/src/utils/amplify-utils.ts b/frontend/src/utils/amplify-utils.ts index 4b5af9f6..527ee5fc 100644 --- a/frontend/src/utils/amplify-utils.ts +++ b/frontend/src/utils/amplify-utils.ts @@ -5,12 +5,32 @@ import { cookies } from 'next/headers'; import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/data'; import { Schema } from 'nhs-notify-web-template-management-amplify'; +import { createServerRunner } from '@aws-amplify/adapter-nextjs'; +import { fetchAuthSession } from 'aws-amplify/auth/server'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; const config = require('@/amplify_outputs.json'); +export const { runWithAmplifyServerContext } = createServerRunner({ + config, +}); + export const getAmplifyBackendClient = () => generateServerClientUsingCookies({ config, cookies, authMode: 'iam', }); + +export async function getAccessTokenServer(): Promise { + try { + const { tokens } = await runWithAmplifyServerContext({ + nextServerContext: { cookies }, + operation: fetchAuthSession, + }); + + return tokens?.accessToken?.toString(); + } catch (error) { + logger.error('Failed to fetch auth token:', error); + } +} diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index b2c6737d..67a57da2 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -1,115 +1,101 @@ -/* eslint-disable array-callback-return */ - 'use server'; -import { getAmplifyBackendClient } from '@utils/amplify-utils'; -import { DbOperationError } from '@domain/errors'; -import { - Template, - Draft, - isTemplateValid, -} from 'nhs-notify-web-template-management-utils'; +import { getAccessTokenServer } from '@utils/amplify-utils'; +import { Template, Draft } from 'nhs-notify-web-template-management-utils'; +import { BackendClient, TemplateDTO } from 'nhs-notify-backend-client'; import { logger } from 'nhs-notify-web-template-management-utils/logger'; export async function createTemplate( template: Draft