diff --git a/.github/actions/create-lines-of-code-report/action.yaml b/.github/actions/create-lines-of-code-report/action.yaml index e3554ae4..86396f7a 100644 --- a/.github/actions/create-lines-of-code-report/action.yaml +++ b/.github/actions/create-lines-of-code-report/action.yaml @@ -32,7 +32,7 @@ runs: run: zip lines-of-code-report.json.zip lines-of-code-report.json - name: "Upload CLOC report as an artefact" if: ${{ !env.ACT }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: lines-of-code-report.json.zip path: ./lines-of-code-report.json.zip diff --git a/.github/actions/scan-dependencies/action.yaml b/.github/actions/scan-dependencies/action.yaml index c85a8db3..1000df14 100644 --- a/.github/actions/scan-dependencies/action.yaml +++ b/.github/actions/scan-dependencies/action.yaml @@ -32,7 +32,7 @@ runs: run: zip sbom-repository-report.json.zip sbom-repository-report.json - name: "Upload SBOM report as an artefact" if: ${{ !env.ACT }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: sbom-repository-report.json.zip path: ./sbom-repository-report.json.zip @@ -47,7 +47,7 @@ runs: run: zip vulnerabilities-repository-report.json.zip vulnerabilities-repository-report.json - name: "Upload vulnerabilities report as an artefact" if: ${{ !env.ACT }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: vulnerabilities-repository-report.json.zip path: ./vulnerabilities-repository-report.json.zip diff --git a/.github/workflows/stage-4-acceptance.yaml b/.github/workflows/stage-4-acceptance.yaml index 60cfce86..b171974b 100644 --- a/.github/workflows/stage-4-acceptance.yaml +++ b/.github/workflows/stage-4-acceptance.yaml @@ -40,37 +40,9 @@ permissions: contents: read # This is required for actions/checkout jobs: - environment-set-up: - name: "Environment set up" - runs-on: ubuntu-latest - environment: dev - timeout-minutes: 15 - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - name: "Repo setup" - run: | - npm ci - - name: "Generate dependencies" - run: | - npm run generate-dependencies --workspaces --if-present - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ASSUME_ROLE_NAME }} - role-session-name: deployInfra - aws-region: ${{ env.AWS_REGION }} - - name: "Create Amplify sandbox" - run: | - ./scripts/create_amplify_sandbox.sh - - uses: actions/upload-artifact@v4 - with: - name: amplify_outputs.json - path: frontend/amplify_outputs.json - sandbox-set-up: name: "Sandbox set up" - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 environment: dev timeout-minutes: 15 steps: @@ -92,11 +64,10 @@ jobs: with: name: sandbox_tf_outputs.json path: sandbox_tf_outputs.json - test-security: name: "Security test" - runs-on: ubuntu-latest - needs: [environment-set-up, sandbox-set-up] + runs-on: ubuntu-22.04 + needs: [sandbox-set-up] timeout-minutes: 10 steps: - name: "Checkout code" @@ -109,17 +80,19 @@ jobs: echo "Nothing to save" test-accessibility: name: "Accessibility test" - runs-on: ubuntu-latest - needs: [environment-set-up, sandbox-set-up] + runs-on: ubuntu-22.04 + needs: [sandbox-set-up] environment: dev + env: + INCLUDE_AUTH_PAGES: 'true' timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: - name: amplify_outputs.json - path: frontend/ + name: sandbox_tf_outputs.json + path: ./ - name: "Repo setup" run: | npm ci @@ -133,7 +106,9 @@ jobs: role-session-name: deployInfra aws-region: eu-west-2 - name: "Run accessibility test" - run: make test-accessibility + run: | + npm run create-amplify-outputs file + make test-accessibility - name: Archive accessibility results uses: actions/upload-artifact@v4 with: @@ -141,17 +116,19 @@ jobs: path: ".reports/accessibility" test-ui-component: name: "UI Component test" - runs-on: ubuntu-latest - needs: [environment-set-up, sandbox-set-up] + runs-on: ubuntu-22.04 + needs: [sandbox-set-up] environment: dev + env: + INCLUDE_AUTH_PAGES: 'true' timeout-minutes: 10 steps: - name: "Checkout code" uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: - name: amplify_outputs.json - path: frontend/ + name: sandbox_tf_outputs.json + path: ./ - name: "Repo setup" run: | npm ci @@ -168,45 +145,19 @@ jobs: aws-region: eu-west-2 - name: "Run ui component test" run: | + npm run create-amplify-outputs file cd tests/test-team npm run test:local-ui - name: Archive component test results + if: success() || failure() uses: actions/upload-artifact@v4 with: name: component test report path: "tests/test-team/playwright-report" - environment-tear-down: - name: "Environment tear down" - if: success() || failure() - runs-on: ubuntu-latest - needs: [test-accessibility, test-ui-component] - environment: dev - steps: - - name: "Checkout code" - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: amplify_outputs.json - path: frontend/ - - name: "Repo setup" - run: | - npm ci - - name: "Generate dependencies" - run: | - npm run generate-dependencies --workspaces --if-present - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ASSUME_ROLE_NAME }} - role-session-name: deployInfra - aws-region: eu-west-2 - - name: "Destroy Amplify sandbox" - run: | - (cd frontend && npm run destroy-sandbox -- --identifier "wf-${GITHUB_RUN_ID}") sandbox-tear-down: name: "Sandbox tear down" if: success() || failure() - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: [test-accessibility, test-ui-component] environment: dev steps: diff --git a/.gitignore b/.gitignore index 2203489d..83c7a273 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ sandbox_cognito_auth_token.json frontend/public/testing .vscode/launch.json +auth.json diff --git a/amplify.yml b/amplify.yml index ceedba94..8964028a 100644 --- a/amplify.yml +++ b/amplify.yml @@ -10,8 +10,8 @@ applications: - nvm use 20.13.1 - npm ci --cache .npm --prefer-offline - npm run generate-dependencies --workspaces --if-present + - npm run create-amplify-outputs env - cd frontend - - npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID frontend: phases: build: diff --git a/frontend/.env.template b/frontend/.env.template new file mode 100644 index 00000000..1045ec70 --- /dev/null +++ b/frontend/.env.template @@ -0,0 +1,2 @@ +# Includes auth pages when building web frontend in production mode. +INCLUDE_AUTH_PAGES='' diff --git a/frontend/amplify/backend.ts b/frontend/amplify/backend.ts index aefe1484..4f36ee13 100644 --- a/frontend/amplify/backend.ts +++ b/frontend/amplify/backend.ts @@ -1,28 +1 @@ -import { defineBackend } from '@aws-amplify/backend'; -import { PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam'; -import { auth } from './auth/resource'; -import { data } from './data/resource'; -import { sendEmail } from './functions/send-email/resource'; - -const backend = defineBackend({ - auth, - data, - sendEmail, -}); - -const sendEmailLambda = backend.sendEmail.resources.lambda; - -const attachPolicy = new PolicyStatement({ - sid: 'AmplifySendEmail', - effect: Effect.ALLOW, - actions: ['ses:SendRawEmail'], - resources: [`arn:aws:ses:eu-west-2:${process.env.ACCOUNT_ID}:identity/*`], -}); - -sendEmailLambda.addToRolePolicy(attachPolicy); - -backend.data.resources.cfnResources.amplifyDynamoDbTables.TemplateStorage.timeToLiveAttribute = - { - attributeName: 'ttl', - enabled: true, - }; +/* eslint-disable unicorn/no-empty-file */ diff --git a/frontend/next.config.js b/frontend/next.config.js index 3f4fb117..ead9269b 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,17 +1,21 @@ /** @type {import('next').NextConfig} */ const { PHASE_DEVELOPMENT_SERVER } = require('next/constants'); +const amplifyConfig = require('./amplify_outputs.json'); const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? '/templates'; const domain = process.env.NOTIFY_DOMAIN_NAME ?? 'localhost:3000'; const nextConfig = (phase) => { const isDevServer = phase === PHASE_DEVELOPMENT_SERVER; + const includeAuthPages = + process.env.INCLUDE_AUTH_PAGES === 'true' || isDevServer; return { basePath, env: { basePath, + BACKEND_API_URL: amplifyConfig?.meta?.backend_api_url, }, experimental: { @@ -32,7 +36,7 @@ const nextConfig = (phase) => { }, async rewrites() { - if (isDevServer) { + if (includeAuthPages) { return [ { source: '/auth/signout', @@ -52,7 +56,7 @@ const nextConfig = (phase) => { // pages with e.g. .dev.tsx extension are only included when running locally pageExtensions: ['ts', 'tsx', 'js', 'jsx'].flatMap((extension) => { - return isDevServer ? [`dev.${extension}`, extension] : [extension]; + return includeAuthPages ? [`dev.${extension}`, extension] : [extension]; }), }; }; diff --git a/frontend/package.json b/frontend/package.json index 6fbce8a9..ae84675f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,9 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint --dir .", - "lint:fix": "next lint --dir . --fix", - "test:unit": "jest", + "lint": "npm run mock-amplify-outputs && next lint --dir .", + "lint:fix": "npm run lint -- --fix", + "test:unit": "npm run mock-amplify-outputs && jest", "app:start": "pm2 start npm -- start", "app:wait": "wait-on -l http://localhost:3000/templates/create-and-submit-templates", "app:stop": "pm2 kill", @@ -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/DeleteTemplate/server-action.test.ts b/frontend/src/__tests__/components/forms/DeleteTemplate/server-action.test.ts index 41e159f6..abb8f1d9 100644 --- a/frontend/src/__tests__/components/forms/DeleteTemplate/server-action.test.ts +++ b/frontend/src/__tests__/components/forms/DeleteTemplate/server-action.test.ts @@ -29,13 +29,10 @@ test('calls form action and redirects', async () => { await deleteTemplateAction(mockTemplate); - expect(mockSaveTemplate).toHaveBeenCalledWith( - { - ...mockTemplate, - templateStatus: TemplateStatus.DELETED, - }, - 1_643_619_600 - ); + expect(mockSaveTemplate).toHaveBeenCalledWith({ + ...mockTemplate, + templateStatus: TemplateStatus.DELETED, + }); expect(mockRedirect).toHaveBeenCalledWith( '/manage-templates', 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 344f16ba..439b48c3 100644 --- a/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts +++ b/frontend/src/__tests__/components/forms/SubmitTemplate/server-action.test.ts @@ -92,12 +92,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'); }); @@ -120,11 +115,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__/components/molecules/__snapshots__/AuthLink.test.tsx.snap b/frontend/src/__tests__/components/molecules/__snapshots__/AuthLink.test.tsx.snap index 2c3c5b50..7662aa01 100644 --- a/frontend/src/__tests__/components/molecules/__snapshots__/AuthLink.test.tsx.snap +++ b/frontend/src/__tests__/components/molecules/__snapshots__/AuthLink.test.tsx.snap @@ -42,7 +42,7 @@ exports[`AuthLink renders Log out link when authStatus is authenticated 1`] = ` > Log out diff --git a/frontend/src/__tests__/utils/amplify-utils.test.ts b/frontend/src/__tests__/utils/amplify-utils.test.ts index 714fdbe0..c68eed4c 100644 --- a/frontend/src/__tests__/utils/amplify-utils.test.ts +++ b/frontend/src/__tests__/utils/amplify-utils.test.ts @@ -1,33 +1,79 @@ /** * @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('next/headers', () => ({ + cookies: () => ({ + getAll: jest.fn(), + }), +})); jest.mock('@/amplify_outputs.json', () => ({ name: 'mockConfig', })); -jest.mock('next/headers', () => ({ - cookies: () => {}, -})); -test('getAmplifyBackendClient', () => { - // arrange - const generateServerClientUsingCookiesMock = jest.mocked( - generateServerClientUsingCookies - ); - const cookiesSpy = jest.spyOn(nextHeaders, 'cookies'); - - // act - getAmplifyBackendClient(); - - // assert - expect(generateServerClientUsingCookiesMock).toHaveBeenCalledTimes(1); - expect(generateServerClientUsingCookiesMock).toHaveBeenCalledWith({ - config: { name: 'mockConfig' }, - cookies: cookiesSpy, - authMode: 'iam', +const fetchAuthSessionMock = jest.mocked(fetchAuthSession); + +describe('amplify-utils', () => { + test('getAmplifyBackendClient', () => { + // arrange + const generateServerClientUsingCookiesMock = jest.mocked( + generateServerClientUsingCookies + ); + const cookiesSpy = jest.spyOn(nextHeaders, 'cookies'); + + // act + getAmplifyBackendClient(); + + // assert + expect(generateServerClientUsingCookiesMock).toHaveBeenCalledTimes(1); + expect(generateServerClientUsingCookiesMock).toHaveBeenCalledWith({ + config: { name: 'mockConfig' }, + cookies: cookiesSpy, + authMode: 'iam', + }); + }); + + test('getAccessTokenServer - should return the auth token', async () => { + fetchAuthSessionMock.mockResolvedValue({ + tokens: { + accessToken: { + toString: () => 'mockSub', + payload: { + sub: 'mockSub', + }, + }, + }, + }); + + const result = await getAccessTokenServer(); + + expect(result).toEqual('mockSub'); + }); + + test('getAccessTokenServer - should return undefined when no auth session', async () => { + fetchAuthSessionMock.mockResolvedValue({}); + + const result = await getAccessTokenServer(); + + expect(result).toBeUndefined(); + }); + + test('getAccessTokenServer - 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 2808bf02..68a6b2ed 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,402 +14,351 @@ 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', - 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 = { + 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 = { - templateType: TemplateType.NHS_APP, - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'name', - message: 'message', - }; - - const response = await createTemplate(createTemplateInput); - - expect(mockCreateTemplate).toHaveBeenCalledWith(createTemplateInput); - expect(response).toEqual(mockResponseData); -}); - -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, + test('createTemplate - should thrown error when saving unexpectedly fails', async () => { + mockedBackendClient.templates.createTemplate.mockResolvedValueOnce({ + error: { + code: 400, + message: 'Bad request', }, - }, - }); + }); - await expect( - createTemplate({ + const createTemplateInput: Draft = { templateType: TemplateType.NHS_APP, - } as unknown as Template) - ).rejects.toThrow('Failed to create new template'); -}); + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + }; -test('saveTemplate', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ data: mockResponseData }), - }, - }, - }); + await expect(createTemplate(createTemplateInput)).rejects.toThrow( + 'Failed to create new template' + ); - const response = await saveTemplate({ - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', - templateType: TemplateType.NHS_APP, - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', + expect(mockedBackendClient.templates.createTemplate).toHaveBeenCalledWith( + createTemplateInput + ); }); - expect(response).toEqual(mockResponseData); -}); + test('createTemplate - should thrown error when no token', async () => { + authIdTokenServerMock.mockReset(); + authIdTokenServerMock.mockResolvedValueOnce(undefined); -test('saveTemplate - includes TTL', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ data: mockResponseData }), - }, - }, + const createTemplateInput: Draft = { + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + }; + + await expect(createTemplate(createTemplateInput)).rejects.toThrow( + 'Failed to get access token' + ); }); - const response = await saveTemplate( - { - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', + test('saveTemplate', async () => { + const responseData = { + id: 'id', + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; + + mockedBackendClient.templates.updateTemplate.mockResolvedValueOnce({ + data: responseData, + }); + + const updateTemplateInput: NHSAppTemplate = { + id: 'pickle', templateType: TemplateType.NHS_APP, templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', - }, - 10 - ); + name: 'name', + message: 'message', + }; - expect(response).toEqual(mockResponseData); -}); + const response = await saveTemplate(updateTemplateInput); -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' }, - }, - ], - }), + expect(mockedBackendClient.templates.updateTemplate).toHaveBeenCalledWith( + updateTemplateInput.id, + updateTemplateInput + ); + + expect(response).toEqual(responseData); + }); + + test('saveTemplate - should thrown error when saving unexpectedly fails', async () => { + mockedBackendClient.templates.updateTemplate.mockResolvedValueOnce({ + error: { + code: 400, + message: 'Bad request', }, - }, + }); + + const updateTemplateInput: NHSAppTemplate = { + id: 'pickle', + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + 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('saveTemplate - should thrown error when no token', async () => { + authIdTokenServerMock.mockReset(); + authIdTokenServerMock.mockResolvedValueOnce(undefined); + + const updateTemplateInput: NHSAppTemplate = { + id: 'pickle', templateType: TemplateType.NHS_APP, templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'template-name', - message: 'template-message', - }) - ).rejects.toThrow('Failed to save template data'); -}); + name: 'name', + message: 'message', + }; -test('saveTemplate - error handling - when no data returned', async () => { - setup({ - models: { - TemplateStorage: { - update: jest.fn().mockReturnValue({ - errors: undefined, - data: undefined, - }), - }, - }, + await expect(saveTemplate(updateTemplateInput)).rejects.toThrow( + 'Failed to get access token' + ); }); - await expect( - saveTemplate({ - id: '0c1d3422-a2f6-44ef-969d-d513c7c9d212', + test('getTemplate', async () => { + const responseData = { + id: 'id', 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', + }, + }); - expect(response).toBeUndefined(); -}); + const response = await getTemplate('id'); -test('sendEmail - no errors', async () => { - setup({ - queries: { - sendEmail: jest.fn().mockReturnValue({}), - }, - }); + expect(mockedBackendClient.templates.getTemplate).toHaveBeenCalledWith( + 'id' + ); - const mockErrorLogger = jest.spyOn(logger, 'error'); - await sendEmail('template-id', 'template-name', 'template-message', null); + expect(response).toEqual(undefined); + }); - expect(mockErrorLogger).not.toHaveBeenCalled(); -}); + test('getTemplate - should thrown error when no token', async () => { + authIdTokenServerMock.mockReset(); + authIdTokenServerMock.mockResolvedValueOnce(undefined); -test('sendEmail - errors', async () => { - setup({ - queries: { - sendEmail: jest.fn().mockReturnValue({ errors: ['email error'] }), - }, + await expect(getTemplate('id')).rejects.toThrow( + 'Failed to get access token' + ); }); - 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'], - }, + test('getTemplates', async () => { + const responseData = { + id: 'id', + templateType: TemplateType.NHS_APP, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'name', + message: 'message', + createdAt: 'today', + updatedAt: 'today', + }; + + mockedBackendClient.templates.listTemplates.mockResolvedValueOnce({ + data: [responseData], + }); + + const response = await getTemplates(); + + 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', - 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('getTemplates - should thrown error when no token', async () => { + authIdTokenServerMock.mockReset(); + authIdTokenServerMock.mockResolvedValueOnce(undefined); -test('getTemplates - returns empty array if there are no templates/data returned', async () => { - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: [] }), - }, - }, + await expect(getTemplates()).rejects.toThrow('Failed to get access token'); }); - const response = await getTemplates(); + test('sendEmail', async () => { + mockedBackendClient.functions.sendEmail.mockResolvedValueOnce({ + data: undefined, + error: undefined, + }); - expect(response).toEqual([]); -}); + const response = await sendEmail('id'); -test('getTemplates - errors', async () => { - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ - errors: [ - { - message: 'test-error-message', - errorType: 'test-error-type', - errorInfo: { error: 'test-error' }, - }, - ], - }), - }, - }, - }); + expect(mockedBackendClient.functions.sendEmail).toHaveBeenCalledWith('id'); - const mockErrorLogger = jest.spyOn(logger, 'error'); - const response = await getTemplates(); + expect(response).toEqual(undefined); + }); - expect(mockErrorLogger).toHaveBeenCalledWith('Failed to get templates', [ - { - errorInfo: { error: 'test-error' }, - errorType: 'test-error-type', - message: 'test-error-message', - }, - ]); + test('sendEmail - should thrown error when no token', async () => { + authIdTokenServerMock.mockReset(); + authIdTokenServerMock.mockResolvedValueOnce(undefined); - expect(response).toEqual([]); -}); + await expect(sendEmail('id')).rejects.toThrow('Failed to get access token'); + }); -test('getTemplates - order by createdAt and then id', async () => { - const baseTemplate = { - templateType: 'SMS', - templateStatus: TemplateStatus.NOT_YET_SUBMITTED, - name: 'Template', - message: 'Message', - }; - - const templates = [ - { ...baseTemplate, id: '06', createdAt: '2022-01-01T00:00:00.000Z' }, - { ...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: '01', createdAt: '2021-01-01T00:00:00.000Z' }, - { ...baseTemplate, id: '07' }, - { ...baseTemplate, id: '03', createdAt: '2021-01-01T00:00:00.000Z' }, - { ...baseTemplate, id: '04', createdAt: '2021-01-01T00:00:00.000Z' }, - ]; - - // 06 is the newest, 08 is the oldest. - // Templates without a createdAt, 07, 09 and 10, go at the end. - // 01 - 05 all have the same createdAt. - const expectedOrder = [ - '06', - '01', - '02', - '03', - '04', - '05', - '08', - '07', - '09', - '10', - ]; - - setup({ - models: { - TemplateStorage: { - list: jest.fn().mockReturnValue({ data: templates }), + test('getTemplates - should return nothing when an error occurs', async () => { + mockedBackendClient.functions.sendEmail.mockResolvedValueOnce({ + data: undefined, + error: { + code: 404, + message: 'Not found', }, - }, - }); + }); - const response = await getTemplates(); + const response = await sendEmail('id'); - const actualOrder = []; - for (const template of response) { - actualOrder.push(template.id); - } + expect(mockedBackendClient.functions.sendEmail).toHaveBeenCalledWith('id'); - expect(actualOrder).toEqual(expectedOrder); + expect(response).toEqual(undefined); + }); + + test('getTemplates - order by createdAt and then id', async () => { + const baseTemplate = { + templateType: TemplateType.SMS, + templateStatus: TemplateStatus.NOT_YET_SUBMITTED, + name: 'Template', + message: 'Message', + updatedAt: '2021-01-01T00:00:00.000Z', + }; + + const templates = [ + { ...baseTemplate, id: '06', createdAt: '2022-01-01T00:00:00.000Z' }, + { ...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', 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', 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' }, + ]; + + // 06 is the newest, 08 is the oldest. + // Templates without a createdAt, 07, 09 and 10, go at the end. + // 01 - 05 all have the same createdAt. + const expectedOrder = [ + '06', + '01', + '02', + '03', + '04', + '05', + '08', + '07', + '09', + '10', + ]; + + mockedBackendClient.templates.listTemplates.mockResolvedValueOnce({ + data: templates, + }); + + const response = await getTemplates(); + + const actualOrder = []; + for (const template of response) { + actualOrder.push(template.id); + } + + expect(actualOrder).toEqual(expectedOrder); + }); }); diff --git a/frontend/src/app/auth/page.dev.tsx b/frontend/src/app/auth/page.dev.tsx index 0bc3104a..09611ef2 100644 --- a/frontend/src/app/auth/page.dev.tsx +++ b/frontend/src/app/auth/page.dev.tsx @@ -32,7 +32,17 @@ export const Redirect = () => { export default function Page() { return ( Loading...

}> - +
diff --git a/frontend/src/app/manage-templates/page.tsx b/frontend/src/app/manage-templates/page.tsx index 5ce9e839..b9a39f2b 100644 --- a/frontend/src/app/manage-templates/page.tsx +++ b/frontend/src/app/manage-templates/page.tsx @@ -1,15 +1,19 @@ -'use server'; - import { NHSNotifyButton } from '@atoms/NHSNotifyButton/NHSNotifyButton'; import content from '@content/content'; import { ManageTemplates } from '@molecules/ManageTemplates/ManageTemplates'; -import { Template } from 'nhs-notify-web-template-management-utils'; import { getTemplates } from '@utils/form-actions'; +// Note: force this page to be dynamically rendered +// This is because Next defaults this page as a static rendered page +// which causes a build failure due to getTemplates attempting to get a server-side session via cookies and failing +// The other pages which do similar thing expect a templateId parameter +// Which informs next it needs to be dynamically rendered +export const dynamic = 'force-dynamic'; + const manageTemplatesContent = content.pages.manageTemplates; export default async function ManageTemplatesPage() { - const availableTemplateList: Template[] | [] = await getTemplates(); + const availableTemplateList = await getTemplates(); return (
diff --git a/frontend/src/components/forms/DeleteTemplate/server-action.ts b/frontend/src/components/forms/DeleteTemplate/server-action.ts index 11ed4972..e403fa17 100644 --- a/frontend/src/components/forms/DeleteTemplate/server-action.ts +++ b/frontend/src/components/forms/DeleteTemplate/server-action.ts @@ -5,30 +5,13 @@ import { } from 'nhs-notify-web-template-management-utils'; import { saveTemplate } from '@utils/form-actions'; -// remove this when we stop using amplify backend, the API handles TTL logic -const calculateTTL = () => { - const currentTimeSeconds = Math.floor(Date.now() / 1000); - - const maxSessionLengthInSeconds = Number.parseInt( - process.env.MAX_SESSION_LENGTH_IN_SECONDS ?? '2592000', - 10 - ); // 30 days in seconds - - return currentTimeSeconds + maxSessionLengthInSeconds; -}; - export const deleteTemplateAction = async ( template: ChannelTemplate ): Promise => { - const ttl = calculateTTL(); - - await saveTemplate( - { - ...template, - templateStatus: TemplateStatus.DELETED, - }, - ttl - ); + await saveTemplate({ + ...template, + templateStatus: TemplateStatus.DELETED, + }); redirect('/manage-templates', RedirectType.push); }; 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/layouts/container/container.tsx b/frontend/src/components/layouts/container/container.tsx index 8d6e70e0..56ba1527 100644 --- a/frontend/src/components/layouts/container/container.tsx +++ b/frontend/src/components/layouts/container/container.tsx @@ -1,4 +1,4 @@ -export async function NHSNotifyContainer({ +export function NHSNotifyContainer({ children, }: { children: React.ReactNode; diff --git a/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx b/frontend/src/components/molecules/ManageTemplates/ManageTemplates.tsx index 966d58ca..447c3060 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/content/content.ts b/frontend/src/content/content.ts index ab7bd21c..ab758b30 100644 --- a/frontend/src/content/content.ts +++ b/frontend/src/content/content.ts @@ -12,7 +12,9 @@ const headerComponent = { }, logOut: { text: 'Log out', - href: '/auth/signout', + href: `/auth/signout?redirect=${encodeURIComponent( + `${getBasePath()}/create-and-submit-templates` + )}`, }, }, }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 00000000..ef17e4d6 --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { getAccessTokenServer } from '@utils/amplify-utils'; +import { getBasePath } from '@utils/get-base-path'; + +function isExcludedPath(path: string, excludedPaths: string[]): boolean { + return excludedPaths.some((excludedPath) => path.startsWith(excludedPath)); +} + +export async function middleware(request: NextRequest) { + const excludedPaths = ['/create-and-submit-templates', '/auth']; + + if (isExcludedPath(request.nextUrl.pathname, excludedPaths)) { + return NextResponse.next(); + } + + const token = await getAccessTokenServer(); + + if (!token) { + return Response.redirect( + new URL( + `/auth?redirect=${encodeURIComponent( + `${getBasePath()}/${request.nextUrl.pathname}` + )}`, + request.url + ) + ); + } +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!_next/static|_next/image|favicon.ico).*)', + ], +}; diff --git a/frontend/src/utils/amplify-utils.ts b/frontend/src/utils/amplify-utils.ts index 4b5af9f6..1e28a765 100644 --- a/frontend/src/utils/amplify-utils.ts +++ b/frontend/src/utils/amplify-utils.ts @@ -5,12 +5,31 @@ 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'; 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 { + // no-op + } +} diff --git a/frontend/src/utils/form-actions.ts b/frontend/src/utils/form-actions.ts index c0567358..3345eb8d 100644 --- a/frontend/src/utils/form-actions.ts +++ b/frontend/src/utils/form-actions.ts @@ -1,117 +1,109 @@ -/* eslint-disable array-callback-return */ - 'use server'; -import { getAmplifyBackendClient } from '@utils/amplify-utils'; -import { DbOperationError } from '@domain/errors'; +import { getAccessTokenServer } from '@utils/amplify-utils'; import { Template, Draft, isTemplateValid, - TemplateStatus, } 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