From ef247bbec1a805c36729dfb0c9a8c8329c74922b Mon Sep 17 00:00:00 2001 From: Rio Knightley <128376976+RioKnightleyNHS@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:55:01 +0000 Subject: [PATCH] PRMDR 448 UI RBAC (#152) Add RBACs router coverage to app --- app/package-lock.json | 7 + app/package.json | 2 +- app/src/App.tsx | 80 +-------- .../DeleteDocumentsStage.test.tsx | 63 +++++-- .../DeleteDocumentsStage.tsx | 16 +- .../DeletionConfirmationStage.test.tsx | 3 +- .../DeletionConfirmationStage.tsx | 2 +- .../LloydGeorgeDownloadAllStage.tsx | 2 +- .../LloydGeorgeDownloadComplete.test.tsx | 2 +- .../LloydGeorgeDownloadComplete.tsx | 2 +- .../LloydGeorgeRecordDetails.test.tsx | 133 ++++++++++----- .../LloydGeorgeRecordDetails.tsx | 58 +++---- .../LloydGeorgeRecordStage.test.tsx | 4 +- .../LloydGeorgeRecordStage.tsx | 2 +- .../LloydGeorgeRecordPage.tsx | 7 +- app/src/router/AppRouter.tsx | 158 ++++++++++++++++++ .../guards}/authGuard/AuthGuard.test.tsx | 0 .../guards}/authGuard/AuthGuard.tsx | 0 .../patientGuard/PatientGuard.test.tsx | 0 .../guards}/patientGuard/PatientGuard.tsx | 0 .../guards/roleGuard/RoleGuard.test.tsx | 59 +++++++ app/src/router/guards/roleGuard/RoleGuard.tsx | 28 ++++ app/src/types/blocks/lloydGeorgeActions.ts | 29 ++++ app/src/types/blocks/lloydGeorgeStages.ts | 6 + app/src/types/generic/routes.ts | 18 +- 25 files changed, 482 insertions(+), 199 deletions(-) create mode 100644 app/src/router/AppRouter.tsx rename app/src/{components/blocks => router/guards}/authGuard/AuthGuard.test.tsx (100%) rename app/src/{components/blocks => router/guards}/authGuard/AuthGuard.tsx (100%) rename app/src/{components/blocks => router/guards}/patientGuard/PatientGuard.test.tsx (100%) rename app/src/{components/blocks => router/guards}/patientGuard/PatientGuard.tsx (100%) create mode 100644 app/src/router/guards/roleGuard/RoleGuard.test.tsx create mode 100644 app/src/router/guards/roleGuard/RoleGuard.tsx create mode 100644 app/src/types/blocks/lloydGeorgeActions.ts create mode 100644 app/src/types/blocks/lloydGeorgeStages.ts diff --git a/app/package-lock.json b/app/package-lock.json index d0efbd5e5..df8cd7132 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -59,6 +59,7 @@ "lint-staged": "^14.0.1", "prettier": "^3.0.3", "prop-types": "^15.8.1", + "react-router-to-array": "^0.1.3", "storybook": "^7.4.0", "webpack": "^5.88.2" } @@ -24765,6 +24766,12 @@ "react-dom": ">=16.8" } }, + "node_modules/react-router-to-array": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-router-to-array/-/react-router-to-array-0.1.3.tgz", + "integrity": "sha512-rg4zwEzRApWrenY1rGO32t9z+R156wsZUUj3eTD3H2tv497ItWiEJkn5ekvGKZ6aYJXz7Fhb1GW0dqJeVPExog==", + "dev": true + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/app/package.json b/app/package.json index b8203b973..22af3450e 100644 --- a/app/package.json +++ b/app/package.json @@ -87,4 +87,4 @@ "axios": "axios/dist/node/axios.cjs" } } -} \ No newline at end of file +} diff --git a/app/src/App.tsx b/app/src/App.tsx index d05aecca8..80d97f430 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,93 +1,17 @@ import React from 'react'; import './styles/App.scss'; -import HomePage from './pages/homePage/HomePage'; import ConfigProvider from './providers/configProvider/ConfigProvider'; import config from './config'; -import { routes } from './types/generic/routes'; -import Layout from './components/layout/Layout'; import PatientDetailsProvider from './providers/patientProvider/PatientProvider'; -import { BrowserRouter as Router, Route, Routes, Outlet } from 'react-router-dom'; import SessionProvider from './providers/sessionProvider/SessionProvider'; -import AuthCallbackPage from './pages/authCallbackPage/AuthCallbackPage'; -import NotFoundPage from './pages/notFoundPage/NotFoundPage'; -import UnauthorisedPage from './pages/unauthorisedPage/UnauthorisedPage'; -import AuthGuard from './components/blocks/authGuard/AuthGuard'; -import PatientSearchPage from './pages/patientSearchPage/PatientSearchPage'; -import LogoutPage from './pages/logoutPage/LogoutPage'; -import PatientGuard from './components/blocks/patientGuard/PatientGuard'; -import PatientResultPage from './pages/patientResultPage/PatientResultPage'; -import UploadDocumentsPage from './pages/uploadDocumentsPage/UploadDocumentsPage'; -import DocumentSearchResultsPage from './pages/documentSearchResultsPage/DocumentSearchResultsPage'; -import AuthErrorPage from './pages/authErrorPage/AuthErrorPage'; -import LloydGeorgeRecordPage from './pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; +import AppRouter from './router/AppRouter'; function App() { return ( - - - - } path={routes.HOME} /> - - } path={routes.NOT_FOUND} /> - } path={routes.UNAUTHORISED} /> - } path={routes.AUTH_ERROR} /> - - } path={routes.AUTH_CALLBACK} /> - - - - - } - > - {[routes.DOWNLOAD_SEARCH, routes.UPLOAD_SEARCH].map( - (searchRoute) => ( - } - path={searchRoute} - /> - ), - )} - - } path={routes.LOGOUT} /> - - - - } - > - {[routes.DOWNLOAD_VERIFY, routes.UPLOAD_VERIFY].map( - (searchResultRoute) => ( - } - path={searchResultRoute} - /> - ), - )} - } - path={routes.LLOYD_GEORGE} - /> - } - path={routes.UPLOAD_DOCUMENTS} - /> - } - path={routes.DOWNLOAD_DOCUMENTS} - /> - - - - - + diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx index e85c8b3c0..2135edca9 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx @@ -4,7 +4,6 @@ import DeleteDocumentsStage, { Props } from './DeleteDocumentsStage'; import { getFormattedDate } from '../../../helpers/utils/formatDate'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import { DOCUMENT_TYPE } from '../../../types/pages/UploadDocumentsPage/types'; import axios from 'axios/index'; import * as ReactRouter from 'react-router'; @@ -12,6 +11,7 @@ import { createMemoryHistory } from 'history'; import useRole from '../../../helpers/hooks/useRole'; import { REPOSITORY_ROLE, authorisedRoles } from '../../../types/generic/authRole'; import { routes } from '../../../types/generic/routes'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; jest.mock('../../../helpers/hooks/useBaseAPIHeaders'); jest.mock('../../../helpers/hooks/useRole'); @@ -60,23 +60,20 @@ describe('DeleteDocumentsStage', () => { }, ); - it.each([REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL])( - "renders LgRecordStage when No is selected and Continue clicked, when user role is '%s'", - async (role) => { - mockedUseRole.mockReturnValue(role); + it('renders DocumentSearchResults when No is selected and Continue clicked, when user role is GP Admin', async () => { + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); - renderComponent(DOCUMENT_TYPE.LLOYD_GEORGE); + renderComponent(DOCUMENT_TYPE.LLOYD_GEORGE); - act(() => { - userEvent.click(screen.getByRole('radio', { name: 'No' })); - userEvent.click(screen.getByRole('button', { name: 'Continue' })); - }); + act(() => { + userEvent.click(screen.getByRole('radio', { name: 'No' })); + userEvent.click(screen.getByRole('button', { name: 'Continue' })); + }); - await waitFor(() => { - expect(mockSetStage).toHaveBeenCalledWith(LG_RECORD_STAGE.RECORD); - }); - }, - ); + await waitFor(() => { + expect(mockSetStage).toHaveBeenCalledWith(LG_RECORD_STAGE.RECORD); + }); + }); it('renders DocumentSearchResults when No is selected and Continue clicked, when user role is PCSE', async () => { mockedUseRole.mockReturnValue(REPOSITORY_ROLE.PCSE); @@ -93,7 +90,22 @@ describe('DeleteDocumentsStage', () => { }); }); - it.each([REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL, REPOSITORY_ROLE.PCSE])( + it('does not render a view DocumentSearchResults when No is selected and Continue clicked, when user role is GP Clinical', async () => { + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); + + renderComponent(DOCUMENT_TYPE.LLOYD_GEORGE); + + act(() => { + userEvent.click(screen.getByRole('radio', { name: 'No' })); + userEvent.click(screen.getByRole('button', { name: 'Continue' })); + }); + + await waitFor(() => { + expect(mockSetStage).toHaveBeenCalledTimes(0); + }); + }); + + it.each([REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.PCSE])( "renders DeletionConfirmationStage when the Yes is selected and Continue clicked, when user role is '%s'", async (role) => { mockedAxios.delete.mockReturnValue( @@ -117,6 +129,25 @@ describe('DeleteDocumentsStage', () => { }, ); + it('does not render DeletionConfirmationStage when the Yes is selected, Continue clicked, and user role is GP Clinical', async () => { + mockedAxios.delete.mockReturnValue(Promise.resolve({ status: 200, data: 'Success' })); + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_CLINICAL); + + renderComponent(DOCUMENT_TYPE.LLOYD_GEORGE); + + expect(screen.getByRole('radio', { name: 'Yes' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument(); + + act(() => { + userEvent.click(screen.getByRole('radio', { name: 'Yes' })); + userEvent.click(screen.getByRole('button', { name: 'Continue' })); + }); + + await waitFor(() => { + expect(screen.queryByText('Deletion complete')).not.toBeInTheDocument(); + }); + }); + it('renders a service error when service is down', async () => { const errorResponse = { response: { diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx index 332d79168..ef7140969 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx @@ -3,7 +3,6 @@ import { FieldValues, useForm } from 'react-hook-form'; import { Button, Fieldset, Radios } from 'nhsuk-react-components'; import { getFormattedDate } from '../../../helpers/utils/formatDate'; import { PatientDetails } from '../../../types/generic/patientDetails'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import DeletionConfirmationStage from '../deletionConfirmationStage/DeletionConfirmationStage'; import deleteAllDocuments, { DeleteResponse } from '../../../helpers/requests/deleteAllDocuments'; import { useBaseAPIUrl } from '../../../providers/configProvider/ConfigProvider'; @@ -19,6 +18,7 @@ import { routes } from '../../../types/generic/routes'; import { useNavigate } from 'react-router-dom'; import useRole from '../../../helpers/hooks/useRole'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; export type Props = { docType: DOCUMENT_TYPE; @@ -96,8 +96,7 @@ function DeleteDocumentsStage({ }; const handleNoOption = () => { - const isGp = role === REPOSITORY_ROLE.GP_ADMIN || role === REPOSITORY_ROLE.GP_CLINICAL; - if (isGp && setStage) { + if (role === REPOSITORY_ROLE.GP_ADMIN && setStage) { setStage(LG_RECORD_STAGE.RECORD); } else if (role === REPOSITORY_ROLE.PCSE && setIsDeletingDocuments) { setIsDeletingDocuments(false); @@ -105,10 +104,13 @@ function DeleteDocumentsStage({ }; const submit = async (fieldValues: FieldValues) => { - if (fieldValues.deleteDocs === DELETE_DOCUMENTS_OPTION.YES) { - await handleYesOption(); - } else if (fieldValues.deleteDocs === DELETE_DOCUMENTS_OPTION.NO) { - handleNoOption(); + const allowedRoles = [REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.PCSE]; + if (role && allowedRoles.includes(role)) { + if (fieldValues.deleteDocs === DELETE_DOCUMENTS_OPTION.YES) { + await handleYesOption(); + } else if (fieldValues.deleteDocs === DELETE_DOCUMENTS_OPTION.NO) { + handleNoOption(); + } } }; diff --git a/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.test.tsx b/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.test.tsx index da6407666..d1615ad66 100644 --- a/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.test.tsx +++ b/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.test.tsx @@ -3,13 +3,12 @@ import { buildLgSearchResult, buildPatientDetails } from '../../../helpers/test/ import DeletionConfirmationStage from './DeletionConfirmationStage'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import * as ReactRouter from 'react-router'; import { createMemoryHistory } from 'history'; import { routes } from '../../../types/generic/routes'; import useRole from '../../../helpers/hooks/useRole'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; - +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; jest.mock('../../../helpers/hooks/useRole'); const mockedUseRole = useRole as jest.Mock; diff --git a/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.tsx b/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.tsx index 6f84b7992..aec4bd13b 100644 --- a/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.tsx +++ b/app/src/components/blocks/deletionConfirmationStage/DeletionConfirmationStage.tsx @@ -1,13 +1,13 @@ import React, { Dispatch, SetStateAction } from 'react'; import { ButtonLink, Card } from 'nhsuk-react-components'; import { PatientDetails } from '../../../types/generic/patientDetails'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import { routes } from '../../../types/generic/routes'; import { Link } from 'react-router-dom'; import { useNavigate } from 'react-router'; import { formatNhsNumber } from '../../../helpers/utils/formatNhsNumber'; import useRole from '../../../helpers/hooks/useRole'; import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; export type Props = { numberOfFiles: number; diff --git a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx index 79799622b..d5c0dbbae 100644 --- a/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx +++ b/app/src/components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage.tsx @@ -9,13 +9,13 @@ import React, { } from 'react'; import { Card } from 'nhsuk-react-components'; import { Link } from 'react-router-dom'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import { PatientDetails } from '../../../types/generic/patientDetails'; import { useBaseAPIUrl } from '../../../providers/configProvider/ConfigProvider'; import useBaseAPIHeaders from '../../../helpers/hooks/useBaseAPIHeaders'; import getPresignedUrlForZip from '../../../helpers/requests/getPresignedUrlForZip'; import { DOCUMENT_TYPE } from '../../../types/pages/UploadDocumentsPage/types'; import LgDownloadComplete from '../lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; const FakeProgress = require('fake-progress'); export type Props = { diff --git a/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.test.tsx b/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.test.tsx index e390a0225..bfbbee83d 100644 --- a/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.test.tsx +++ b/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.test.tsx @@ -1,5 +1,5 @@ import { buildPatientDetails } from '../../../helpers/test/testBuilders'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; import { PatientDetails } from '../../../types/generic/patientDetails'; import LgDownloadComplete, { Props } from './LloydGeorgeDownloadComplete'; import { render, screen, waitFor } from '@testing-library/react'; diff --git a/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.tsx b/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.tsx index 78f2def51..ad8246e1a 100644 --- a/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.tsx +++ b/app/src/components/blocks/lloydGeorgeDownloadComplete/LloydGeorgeDownloadComplete.tsx @@ -1,7 +1,7 @@ import React, { Dispatch, SetStateAction } from 'react'; import { PatientDetails } from '../../../types/generic/patientDetails'; import { Button, Card } from 'nhsuk-react-components'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; export type Props = { patientDetails: PatientDetails; diff --git a/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.test.tsx b/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.test.tsx index 12b91e652..fd6971d0c 100644 --- a/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.test.tsx +++ b/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.test.tsx @@ -1,63 +1,60 @@ import { render, screen, waitFor } from '@testing-library/react'; import LgRecordDetails, { Props } from './LloydGeorgeRecordDetails'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import { buildLgSearchResult } from '../../../helpers/test/testBuilders'; import formatFileSize from '../../../helpers/utils/formatFileSize'; import * as ReactRouter from 'react-router'; import { createMemoryHistory } from 'history'; import userEvent from '@testing-library/user-event'; import { act } from 'react-dom/test-utils'; -const mockPdf = buildLgSearchResult(); +import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; +import useRole from '../../../helpers/hooks/useRole'; +import { actionLinks } from '../../../types/blocks/lloydGeorgeActions'; +jest.mock('../../../helpers/hooks/useRole'); +const mockPdf = buildLgSearchResult(); const mockSetStaqe = jest.fn(); +const mockedUseRole = useRole as jest.Mock; describe('LloydGeorgeRecordDetails', () => { - const actionLinkStrings = [ - { label: 'See all files', expectedStage: LG_RECORD_STAGE.SEE_ALL }, - { label: 'Download all files', expectedStage: LG_RECORD_STAGE.DOWNLOAD_ALL }, - { label: 'Delete all files', expectedStage: LG_RECORD_STAGE.DELETE_ALL }, - ]; - beforeEach(() => { + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.PCSE); process.env.REACT_APP_ENVIRONMENT = 'jest'; }); afterEach(() => { jest.clearAllMocks(); }); + describe('Rendering', () => { + it('renders the record details component', () => { + renderComponent(); - it('renders the record details component', () => { - renderComponent(); - - expect(screen.getByText(`Last updated: ${mockPdf.last_updated}`)).toBeInTheDocument(); - expect(screen.getByText(`${mockPdf.number_of_files} files`)).toBeInTheDocument(); - expect( - screen.getByText(`File size: ${formatFileSize(mockPdf.total_file_size_in_byte)}`), - ).toBeInTheDocument(); - expect(screen.getByText('File format: PDF')).toBeInTheDocument(); - }); + expect(screen.getByText(`Last updated: ${mockPdf.last_updated}`)).toBeInTheDocument(); + expect(screen.getByText(`${mockPdf.number_of_files} files`)).toBeInTheDocument(); + expect( + screen.getByText(`File size: ${formatFileSize(mockPdf.total_file_size_in_byte)}`), + ).toBeInTheDocument(); + expect(screen.getByText('File format: PDF')).toBeInTheDocument(); + }); - it('renders record details actions menu', async () => { - renderComponent(); + it('renders record details actions menu', async () => { + renderComponent(); - expect(screen.getByText(`Select an action...`)).toBeInTheDocument(); - expect(screen.getByTestId('actions-menu')).toBeInTheDocument(); - actionLinkStrings.forEach((action) => { - expect(screen.queryByText(action.label)).not.toBeInTheDocument(); - }); + expect(screen.getByText(`Select an action...`)).toBeInTheDocument(); + expect(screen.getByTestId('actions-menu')).toBeInTheDocument(); + actionLinks.forEach((action) => { + expect(screen.queryByText(action.label)).not.toBeInTheDocument(); + }); - act(() => { - userEvent.click(screen.getByTestId('actions-menu')); - }); - await waitFor(async () => { - actionLinkStrings.forEach((action) => { - expect(screen.getByText(action.label)).toBeInTheDocument(); + act(() => { + userEvent.click(screen.getByTestId('actions-menu')); + }); + await waitFor(async () => { + actionLinks.forEach((action) => { + expect(screen.getByText(action.label)).toBeInTheDocument(); + }); }); }); - }); - it.each(actionLinkStrings)( - "navigates to a required stage when action '%s' is clicked", - async (action) => { + it.each(actionLinks)("renders actionLink '$label'", async (action) => { renderComponent(); expect(screen.getByText(`Select an action...`)).toBeInTheDocument(); @@ -69,15 +66,65 @@ describe('LloydGeorgeRecordDetails', () => { await waitFor(async () => { expect(screen.getByText(action.label)).toBeInTheDocument(); }); + }); + }); - act(() => { - userEvent.click(screen.getByText(action.label)); - }); - await waitFor(async () => { - expect(mockSetStaqe).toHaveBeenCalledWith(action.expectedStage); - }); - }, - ); + describe('Navigation', () => { + it.each(actionLinks)( + "navigates to '$stage' when action '$label' is clicked", + async (action) => { + renderComponent(); + + expect(screen.getByText(`Select an action...`)).toBeInTheDocument(); + expect(screen.getByTestId('actions-menu')).toBeInTheDocument(); + + act(() => { + userEvent.click(screen.getByTestId('actions-menu')); + }); + await waitFor(async () => { + expect(screen.getByText(action.label)).toBeInTheDocument(); + }); + + act(() => { + userEvent.click(screen.getByText(action.label)); + }); + await waitFor(async () => { + expect(mockSetStaqe).toHaveBeenCalledWith(action.stage); + }); + }, + ); + }); + + describe('Unauthorised', () => { + const unauthorisedLinks = actionLinks.filter((a) => Array.isArray(a.unauthorised)); + + it.each(unauthorisedLinks)( + "does not render actionLink '$label' if role is unauthorised", + async (action) => { + const [unauthorisedRole] = action.unauthorised ?? []; + mockedUseRole.mockReturnValue(unauthorisedRole); + + renderComponent(); + + expect(screen.getByText(`Select an action...`)).toBeInTheDocument(); + expect(screen.getByTestId('actions-menu')).toBeInTheDocument(); + + act(() => { + userEvent.click(screen.getByTestId('actions-menu')); + }); + await waitFor(async () => { + expect(screen.queryByText(action.label)).not.toBeInTheDocument(); + }); + }, + ); + + it.each(unauthorisedLinks)( + "does not render actionLink '$label' for GP Clinical Role", + async (action) => { + expect(action.unauthorised).toContain(REPOSITORY_ROLE.GP_CLINICAL); + }, + ); + }); }); const TestApp = (props: Omit) => { diff --git a/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.tsx b/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.tsx index 21813e29c..1879e61e9 100644 --- a/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.tsx +++ b/app/src/components/blocks/lloydGeorgeRecordDetails/LloydGeorgeRecordDetails.tsx @@ -1,10 +1,12 @@ import React, { Dispatch, SetStateAction, useRef, useState } from 'react'; import { ReactComponent as Chevron } from '../../../styles/down-chevron.svg'; import formatFileSize from '../../../helpers/utils/formatFileSize'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import { useOnClickOutside } from 'usehooks-ts'; import { Card } from 'nhsuk-react-components'; import { Link } from 'react-router-dom'; +import useRole from '../../../helpers/hooks/useRole'; +import { actionLinks } from '../../../types/blocks/lloydGeorgeActions'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; export type Props = { lastUpdated: string; @@ -13,11 +15,6 @@ export type Props = { setStage: Dispatch>; }; -type PdfActionLink = { - label: string; - key: string; - handler: () => void; -}; function LloydGeorgeRecordDetails({ lastUpdated, numberOfFiles, @@ -26,32 +23,13 @@ function LloydGeorgeRecordDetails({ }: Props) { const [showActionsMenu, setShowActionsMenu] = useState(false); const actionsRef = useRef(null); - + const role = useRole(); const handleMoreActions = () => { setShowActionsMenu(!showActionsMenu); }; useOnClickOutside(actionsRef, (e) => { setShowActionsMenu(false); }); - - const actionLinks: Array = [ - { - label: 'See all files', - key: 'see-all-files-link', - handler: () => setStage(LG_RECORD_STAGE.SEE_ALL), - }, - { - label: 'Download all files', - key: 'download-all-files-link', - handler: () => setStage(LG_RECORD_STAGE.DOWNLOAD_ALL), - }, - { - label: 'Delete all files', - key: 'delete-all-files-link', - handler: () => setStage(LG_RECORD_STAGE.DELETE_ALL), - }, - ]; - return (
@@ -92,19 +70,21 @@ function LloydGeorgeRecordDetails({
    - {actionLinks.map((link) => ( -
  1. - { - e.preventDefault(); - link.handler(); - }} - > - {link.label} - -
  2. - ))} + {actionLinks.map((link) => + role && !link.unauthorised?.includes(role) ? ( +
  3. + { + e.preventDefault(); + setStage(link.stage); + }} + > + {link.label} + +
  4. + ) : null, + )}
diff --git a/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.test.tsx b/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.test.tsx index 9a6de02a1..8e38cb8b8 100644 --- a/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.test.tsx +++ b/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.test.tsx @@ -5,12 +5,14 @@ import LgRecordStage, { Props } from './LloydGeorgeRecordStage'; import { getFormattedDate } from '../../../helpers/utils/formatDate'; import { DOWNLOAD_STAGE } from '../../../types/generic/downloadStage'; import { useState } from 'react'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import formatFileSize from '../../../helpers/utils/formatFileSize'; import { act } from 'react-dom/test-utils'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; const mockPdf = buildLgSearchResult(); const mockPatientDetails = buildPatientDetails(); +jest.mock('../../../helpers/hooks/useRole'); + describe('LloydGeorgeRecordStage', () => { beforeEach(() => { process.env.REACT_APP_ENVIRONMENT = 'jest'; diff --git a/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.tsx b/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.tsx index 7b2416bca..a05ddfd4b 100644 --- a/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.tsx +++ b/app/src/components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage.tsx @@ -4,9 +4,9 @@ import { BackLink, Card, Details } from 'nhsuk-react-components'; import { getFormattedDate } from '../../../helpers/utils/formatDate'; import { DOWNLOAD_STAGE } from '../../../types/generic/downloadStage'; import PdfViewer from '../../generic/pdfViewer/PdfViewer'; -import { LG_RECORD_STAGE } from '../../../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; import LloydGeorgeRecordDetails from '../lloydGeorgeRecordDetails/LloydGeorgeRecordDetails'; import { formatNhsNumber } from '../../../helpers/utils/formatNhsNumber'; +import { LG_RECORD_STAGE } from '../../../types/blocks/lloydGeorgeStages'; export type Props = { patientDetails: PatientDetails; diff --git a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx index f64db89e1..421387875 100644 --- a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx +++ b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx @@ -10,13 +10,8 @@ import getLloydGeorgeRecord from '../../helpers/requests/getLloydGeorgeRecord'; import LloydGeorgeRecordStage from '../../components/blocks/lloydGeorgeRecordStage/LloydGeorgeRecordStage'; import LloydGeorgeDownloadAllStage from '../../components/blocks/lloydGeorgeDownloadAllStage/LloydGeorgeDownloadAllStage'; import { DOCUMENT_TYPE } from '../../types/pages/UploadDocumentsPage/types'; +import { LG_RECORD_STAGE } from '../../types/blocks/lloydGeorgeStages'; -export enum LG_RECORD_STAGE { - RECORD = 0, - DOWNLOAD_ALL = 1, - SEE_ALL = 2, - DELETE_ALL = 3, -} function LloydGeorgeRecordPage() { const [patientDetails] = usePatientDetailsContext(); const [downloadStage, setDownloadStage] = useState(DOWNLOAD_STAGE.INITIAL); diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx new file mode 100644 index 000000000..200c28608 --- /dev/null +++ b/app/src/router/AppRouter.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes as Switch, Route, Outlet } from 'react-router-dom'; +import Layout from '../components/layout/Layout'; +import { ROUTE_TYPE, route, routes } from '../types/generic/routes'; +import HomePage from '../pages/homePage/HomePage'; +import AuthCallbackPage from '../pages/authCallbackPage/AuthCallbackPage'; +import NotFoundPage from '../pages/notFoundPage/NotFoundPage'; +import AuthErrorPage from '../pages/authErrorPage/AuthErrorPage'; +import UnauthorisedPage from '../pages/unauthorisedPage/UnauthorisedPage'; +import LogoutPage from '../pages/logoutPage/LogoutPage'; +import PatientSearchPage from '../pages/patientSearchPage/PatientSearchPage'; +import PatientResultPage from '../pages/patientResultPage/PatientResultPage'; +import UploadDocumentsPage from '../pages/uploadDocumentsPage/UploadDocumentsPage'; +import DocumentSearchResultsPage from '../pages/documentSearchResultsPage/DocumentSearchResultsPage'; +import LloydGeorgeRecordPage from '../pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; +import AuthGuard from './guards/authGuard/AuthGuard'; +import PatientGuard from './guards/patientGuard/PatientGuard'; +import { REPOSITORY_ROLE } from '../types/generic/authRole'; +import RoleGuard from './guards/roleGuard/RoleGuard'; +const { + HOME, + AUTH_CALLBACK, + NOT_FOUND, + UNAUTHORISED, + AUTH_ERROR, + LOGOUT, + DOWNLOAD_SEARCH, + DOWNLOAD_VERIFY, + DOWNLOAD_DOCUMENTS, + LLOYD_GEORGE, + UPLOAD_SEARCH, + UPLOAD_VERIFY, + UPLOAD_DOCUMENTS, +} = routes; + +type Routes = { + [key in routes]: route; +}; + +export const routeMap: Routes = { + // Public routes + [HOME]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, + [AUTH_CALLBACK]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, + [NOT_FOUND]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, + [AUTH_ERROR]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, + [UNAUTHORISED]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, + + // Auth guard routes + [LOGOUT]: { + page: , + type: ROUTE_TYPE.PRIVATE, + }, + + // App guard routes + [DOWNLOAD_SEARCH]: { + page: , + type: ROUTE_TYPE.PRIVATE, + unauthorized: [REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL], + }, + [UPLOAD_SEARCH]: { + page: , + type: ROUTE_TYPE.PRIVATE, + unauthorized: [REPOSITORY_ROLE.PCSE], + }, + [DOWNLOAD_VERIFY]: { + page: , + type: ROUTE_TYPE.PATIENT, + unauthorized: [REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL], + }, + [UPLOAD_VERIFY]: { + page: , + type: ROUTE_TYPE.PATIENT, + unauthorized: [REPOSITORY_ROLE.PCSE], + }, + [UPLOAD_DOCUMENTS]: { + page: , + type: ROUTE_TYPE.PATIENT, + unauthorized: [REPOSITORY_ROLE.PCSE], + }, + [DOWNLOAD_DOCUMENTS]: { + page: , + type: ROUTE_TYPE.PATIENT, + unauthorized: [REPOSITORY_ROLE.GP_ADMIN, REPOSITORY_ROLE.GP_CLINICAL], + }, + [LLOYD_GEORGE]: { + page: , + type: ROUTE_TYPE.PATIENT, + unauthorized: [REPOSITORY_ROLE.PCSE], + }, +}; + +const createRoutesFromType = (routeType: ROUTE_TYPE) => + Object.entries(routeMap).reduce( + (acc, [path, route]) => + route.type === routeType + ? [...acc, ] + : acc, + [] as Array, + ); + +const AppRoutes = () => { + const publicRoutes = createRoutesFromType(ROUTE_TYPE.PUBLIC); + const privateRoutes = createRoutesFromType(ROUTE_TYPE.PRIVATE); + const patientRoutes = createRoutesFromType(ROUTE_TYPE.PATIENT); + + return ( + + {publicRoutes} + + + + + + } + > + {privateRoutes} + + + + } + > + {patientRoutes} + + + + ); +}; + +const AppRouter = () => { + return ( + + + + + + ); +}; + +export default AppRouter; diff --git a/app/src/components/blocks/authGuard/AuthGuard.test.tsx b/app/src/router/guards/authGuard/AuthGuard.test.tsx similarity index 100% rename from app/src/components/blocks/authGuard/AuthGuard.test.tsx rename to app/src/router/guards/authGuard/AuthGuard.test.tsx diff --git a/app/src/components/blocks/authGuard/AuthGuard.tsx b/app/src/router/guards/authGuard/AuthGuard.tsx similarity index 100% rename from app/src/components/blocks/authGuard/AuthGuard.tsx rename to app/src/router/guards/authGuard/AuthGuard.tsx diff --git a/app/src/components/blocks/patientGuard/PatientGuard.test.tsx b/app/src/router/guards/patientGuard/PatientGuard.test.tsx similarity index 100% rename from app/src/components/blocks/patientGuard/PatientGuard.test.tsx rename to app/src/router/guards/patientGuard/PatientGuard.test.tsx diff --git a/app/src/components/blocks/patientGuard/PatientGuard.tsx b/app/src/router/guards/patientGuard/PatientGuard.tsx similarity index 100% rename from app/src/components/blocks/patientGuard/PatientGuard.tsx rename to app/src/router/guards/patientGuard/PatientGuard.tsx diff --git a/app/src/router/guards/roleGuard/RoleGuard.test.tsx b/app/src/router/guards/roleGuard/RoleGuard.test.tsx new file mode 100644 index 000000000..805f25904 --- /dev/null +++ b/app/src/router/guards/roleGuard/RoleGuard.test.tsx @@ -0,0 +1,59 @@ +import { render, waitFor } from '@testing-library/react'; +import * as ReactRouter from 'react-router'; +import { History, createMemoryHistory } from 'history'; +import { routes } from '../../../types/generic/routes'; +import RoleGuard from './RoleGuard'; +import useRole from '../../../helpers/hooks/useRole'; +import { REPOSITORY_ROLE } from '../../../types/generic/authRole'; + +jest.mock('../../../helpers/hooks/useRole'); +const mockedUseRole = useRole as jest.Mock; + +const guardPage = routes.LLOYD_GEORGE; +describe('RoleGuard', () => { + beforeEach(() => { + process.env.REACT_APP_ENVIRONMENT = 'jest'; + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('navigates user to unauthorised when role is not accepted', async () => { + const history = createMemoryHistory({ + initialEntries: [guardPage], + initialIndex: 0, + }); + + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.PCSE); + expect(history.location.pathname).toBe(guardPage); + renderAuthGuard(history); + + await waitFor(async () => { + expect(history.location.pathname).toBe(routes.UNAUTHORISED); + }); + }); + + it('navigates user to correct page when role is accepted', async () => { + const history = createMemoryHistory({ + initialEntries: [guardPage], + initialIndex: 0, + }); + + mockedUseRole.mockReturnValue(REPOSITORY_ROLE.GP_ADMIN); + expect(history.location.pathname).toBe(guardPage); + renderAuthGuard(history); + + await waitFor(async () => { + expect(history.location.pathname).toBe(guardPage); + }); + }); +}); + +const renderAuthGuard = (history: History) => { + return render( + + +
+ + , + ); +}; diff --git a/app/src/router/guards/roleGuard/RoleGuard.tsx b/app/src/router/guards/roleGuard/RoleGuard.tsx new file mode 100644 index 000000000..21dcaf20d --- /dev/null +++ b/app/src/router/guards/roleGuard/RoleGuard.tsx @@ -0,0 +1,28 @@ +import { useEffect, type ReactNode } from 'react'; +import { useNavigate } from 'react-router'; +import { useLocation } from 'react-router-dom'; +import { routes } from '../../../types/generic/routes'; +import { routeMap } from '../../AppRouter'; +import useRole from '../../../helpers/hooks/useRole'; + +type Props = { + children: ReactNode; +}; + +function RoleGuard({ children }: Props) { + const role = useRole(); + const navigate = useNavigate(); + const location = useLocation(); + useEffect(() => { + const routeKey = location.pathname as keyof typeof routeMap; + const { unauthorized } = routeMap[routeKey]; + const denyResource = Array.isArray(unauthorized) && role && unauthorized.includes(role); + + if (denyResource) { + navigate(routes.UNAUTHORISED); + } + }, [role, location, navigate]); + return <>{children}; +} + +export default RoleGuard; diff --git a/app/src/types/blocks/lloydGeorgeActions.ts b/app/src/types/blocks/lloydGeorgeActions.ts new file mode 100644 index 000000000..8c7f34df5 --- /dev/null +++ b/app/src/types/blocks/lloydGeorgeActions.ts @@ -0,0 +1,29 @@ +import { REPOSITORY_ROLE } from '../generic/authRole'; +import { LG_RECORD_STAGE } from './lloydGeorgeStages'; + +type PdfActionLink = { + label: string; + key: string; + stage: LG_RECORD_STAGE; + unauthorised?: Array; +}; + +export const actionLinks: Array = [ + { + label: 'See all files', + key: 'see-all-files-link', + stage: LG_RECORD_STAGE.SEE_ALL, + }, + { + label: 'Download all files', + key: 'download-all-files-link', + stage: LG_RECORD_STAGE.DOWNLOAD_ALL, + unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], + }, + { + label: 'Delete all files', + key: 'delete-all-files-link', + stage: LG_RECORD_STAGE.DELETE_ALL, + unauthorised: [REPOSITORY_ROLE.GP_CLINICAL], + }, +]; diff --git a/app/src/types/blocks/lloydGeorgeStages.ts b/app/src/types/blocks/lloydGeorgeStages.ts new file mode 100644 index 000000000..662c9e86c --- /dev/null +++ b/app/src/types/blocks/lloydGeorgeStages.ts @@ -0,0 +1,6 @@ +export enum LG_RECORD_STAGE { + RECORD = 0, + DOWNLOAD_ALL = 1, + SEE_ALL = 2, + DELETE_ALL = 3, +} diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index ab05ac056..f174cb859 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -1,3 +1,5 @@ +import { REPOSITORY_ROLE } from './authRole'; + export enum routes { HOME = '/', AUTH_CALLBACK = '/auth-callback', @@ -10,7 +12,6 @@ export enum routes { DOWNLOAD_SEARCH = '/search/patient', DOWNLOAD_VERIFY = '/search/patient/result', DOWNLOAD_DOCUMENTS = '/search/results', - DELETE_DOCUMENTS = '/search/results/delete', LLOYD_GEORGE = '/search/patient/lloyd-george-record', @@ -18,3 +19,18 @@ export enum routes { UPLOAD_VERIFY = '/search/upload/result', UPLOAD_DOCUMENTS = '/upload/submit', } + +export enum ROUTE_TYPE { + // No guard + PUBLIC = 0, + // Auth route guard + PRIVATE = 1, + // All route guards + PATIENT = 2, +} + +export type route = { + page: JSX.Element; + type: ROUTE_TYPE; + unauthorized?: Array; +};