diff --git a/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx b/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx index 96d2f8491..702d4dfec 100644 --- a/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx +++ b/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.test.tsx @@ -134,7 +134,7 @@ describe('DocumentSearchResultsOptions', () => { }); describe('Navigation', () => { - it('navigates to home page when API returns 403', async () => { + it('navigates to session expire page when API returns 403', async () => { const history = createMemoryHistory({ initialEntries: ['/example'], initialIndex: 1, @@ -155,7 +155,7 @@ describe('DocumentSearchResultsOptions', () => { userEvent.click(screen.getByRole('button', { name: 'Download All Documents' })); await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); it('navigates to error page when API returns 5XX', async () => { diff --git a/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx b/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx index ceb278d2d..c995a6267 100644 --- a/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx +++ b/app/src/components/blocks/_arf/documentSearchResultsOptions/DocumentSearchResultsOptions.tsx @@ -57,7 +57,7 @@ const DocumentSearchResultsOptions = (props: Props) => { } catch (e) { const error = e as AxiosError; if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); } else { navigate(routes.SERVER_ERROR + errorToParams(error)); } diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx index f2201c269..42a071825 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.test.tsx @@ -197,7 +197,7 @@ describe('DeleteDocumentsStage', () => { }); describe('Navigation', () => { - it('navigates to home page when API call returns 403', async () => { + it('navigates to session expire page when API call returns 403', async () => { const errorResponse = { response: { status: 403, @@ -218,7 +218,7 @@ describe('Navigation', () => { }); await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); }); diff --git a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx index 863acf2d4..7ac0e8906 100644 --- a/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx +++ b/app/src/components/blocks/deleteDocumentsStage/DeleteDocumentsStage.tsx @@ -95,7 +95,7 @@ function DeleteDocumentsStage({ onSuccess(); } else { if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); } else { navigate(routes.SERVER_ERROR + errorToParams(error)); } diff --git a/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx b/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx index 42f6ab06f..db1f98290 100644 --- a/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx +++ b/app/src/components/blocks/feedbackForm/FeedbackForm.test.tsx @@ -254,11 +254,41 @@ describe('', () => { ); expect(mockSetStage).toBeCalledWith(SUBMISSION_STAGE.Submitting); - await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith( - routes.SERVER_ERROR + '?encodedError=WyJTUF8xMDAxIiwiMTU3NzgzNjgwMCJd', - ); + expect(mockedUseNavigate).toHaveBeenCalledWith( + routes.SERVER_ERROR + '?encodedError=WyJTUF8xMDAxIiwiMTU3NzgzNjgwMCJd', + ); + }); + + it('navigates to Session Expire page when call to feedback endpoint return 403', async () => { + const errorResponse = { + response: { + status: 403, + data: { message: 'Unauthorized' }, + }, + }; + mockedAxios.post.mockImplementation(() => Promise.reject(errorResponse)); + + const mockInputData = { + feedbackContent: 'Mock feedback content', + howSatisfied: SATISFACTION_CHOICES.VerySatisfied, + respondentName: 'Jane Smith', + respondentEmail: 'jane_smith@testing.com', + }; + + renderComponent(); + + act(() => { + fillInForm(mockInputData); + clickSubmitButton(); }); + + await waitFor(() => + expect(mockedAxios.post).toBeCalledWith(baseURL + '/Feedback', mockInputData, { + headers: {}, + }), + ); + expect(mockSetStage).toBeCalledWith(SUBMISSION_STAGE.Submitting); + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); }); diff --git a/app/src/components/blocks/feedbackForm/FeedbackForm.tsx b/app/src/components/blocks/feedbackForm/FeedbackForm.tsx index 6581fef96..3edd3398c 100644 --- a/app/src/components/blocks/feedbackForm/FeedbackForm.tsx +++ b/app/src/components/blocks/feedbackForm/FeedbackForm.tsx @@ -37,12 +37,15 @@ function FeedbackForm({ stage, setStage }: Props) { const submit: SubmitHandler = async (formData) => { setStage(SUBMISSION_STAGE.Submitting); - // add tests for failing and passing cases when real email service is implemented try { await sendEmail({ formData, baseUrl, baseHeaders }); setStage(SUBMISSION_STAGE.Successful); } catch (e) { const error = e as AxiosError; + if (error.response?.status === 403) { + navigate(routes.SESSION_EXPIRED); + return; + } navigate(routes.SERVER_ERROR + errorToParams(error)); } }; diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx index ee7cd1fd5..fe06a037b 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.test.tsx @@ -206,7 +206,7 @@ describe('', () => { ); }); }); - it('navigates to Start page when a document search fails', async () => { + it('navigates to session expire page when a document search return 403 unauthorised error', async () => { const errorResponse = { response: { status: 403, @@ -218,7 +218,7 @@ describe('', () => { render(); await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); }); diff --git a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx index 25c548ca4..01a700388 100644 --- a/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx +++ b/app/src/pages/documentSearchResultsPage/DocumentSearchResultsPage.tsx @@ -52,7 +52,7 @@ function DocumentSearchResultsPage() { } catch (e) { const error = e as AxiosError; if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); } else if (error.response?.status && error.response?.status >= 500) { navigate(routes.SERVER_ERROR + errorToParams(error)); } else { diff --git a/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.test.tsx b/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.test.tsx index e9ed61ee2..57ef09128 100644 --- a/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.test.tsx +++ b/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.test.tsx @@ -323,7 +323,7 @@ describe('LloydGeorgeUploadPage', () => { ); }); }); - it('navigates to start page when when call to lg record view return 403', async () => { + it('navigates to session expire page when when call to lg record view return 403', async () => { const errorResponse = { response: { status: 403, @@ -348,10 +348,10 @@ describe('LloydGeorgeUploadPage', () => { expect(mockUploadDocuments).toHaveBeenCalled(); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(routes.START); + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); - it('navigates to start page when confirmation returns 403', async () => { + it('navigates to session expire page when confirmation returns 403', async () => { mockS3Upload.mockReturnValue(Promise.resolve()); mockVirusScan.mockReturnValue(DOCUMENT_UPLOAD_STATE.CLEAN); mockUploadConfirmation.mockImplementation(() => @@ -383,7 +383,7 @@ describe('LloydGeorgeUploadPage', () => { expect(mockUploadConfirmation).toHaveBeenCalled(); }); await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith(routes.START); + expect(mockNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); }); diff --git a/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.tsx b/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.tsx index a6ad82ee9..7ae7927c4 100644 --- a/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.tsx +++ b/app/src/pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage.tsx @@ -101,7 +101,7 @@ function LloydGeorgeUploadPage() { } catch (e) { const error = e as AxiosError; if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); return; } setStage(LG_UPLOAD_STAGE.FAILED); @@ -196,7 +196,7 @@ function LloydGeorgeUploadPage() { } catch (e) { const error = e as AxiosError; if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); } else if (error.response?.status === 423) { navigate(routes.SERVER_ERROR + errorToParams(error)); } else if (isMock(error)) { diff --git a/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx b/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx index 43b696fa3..9962d6841 100644 --- a/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx +++ b/app/src/pages/patientSearchPage/PatientSearchPage.test.tsx @@ -177,7 +177,7 @@ describe('PatientSearchPage', () => { }, ); - it('navigates to start page when user is unauthorized to make request', async () => { + it('navigates to session expired page page when user is unauthorized to make request', async () => { const errorResponse = { response: { status: 403, @@ -192,7 +192,7 @@ describe('PatientSearchPage', () => { userEvent.click(screen.getByRole('button', { name: 'Search' })); await waitFor(() => { - expect(mockedUseNavigate).toHaveBeenCalledWith(routes.START); + expect(mockedUseNavigate).toHaveBeenCalledWith(routes.SESSION_EXPIRED); }); }); diff --git a/app/src/pages/patientSearchPage/PatientSearchPage.tsx b/app/src/pages/patientSearchPage/PatientSearchPage.tsx index 50e4dfd71..8526e4e3c 100644 --- a/app/src/pages/patientSearchPage/PatientSearchPage.tsx +++ b/app/src/pages/patientSearchPage/PatientSearchPage.tsx @@ -68,7 +68,7 @@ function PatientSearchPage() { if (error.response?.status === 400) { setInputError('Enter a valid patient NHS number.'); } else if (error.response?.status === 403) { - navigate(routes.START); + navigate(routes.SESSION_EXPIRED); } else if (error.response?.status === 404) { setInputError('Sorry, patient data not found.'); } else { diff --git a/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.test.tsx b/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.test.tsx new file mode 100644 index 000000000..8350939a4 --- /dev/null +++ b/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import SessionExpiredErrorPage from './SessionExpiredErrorPage'; +import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; +import { endpoints } from '../../types/generic/endpoints'; + +jest.mock('../../helpers/hooks/useBaseAPIUrl'); + +const originalWindowLocation = window.location; +const mockLocationReplace = jest.fn(); +const mockUseBaseUrl = useBaseAPIUrl as jest.Mock; + +describe('SessionExpiredErrorPage', () => { + afterAll(() => { + Object.defineProperty(window, 'location', { + value: originalWindowLocation, + }); + }); + + it('render a page with a user friendly message to state that their session expired', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'We signed you out due to inactivity' }), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "This is to protect your information. You'll need to enter any information you submitted again.", + ), + ).toBeInTheDocument(); + }); + + it('move to login endpoint when user click the button', async () => { + const mockBackendUrl = 'http://localhost/mock_url/'; + mockUseBaseUrl.mockReturnValue(mockBackendUrl); + + Object.defineProperty(window, 'location', { + value: { + replace: mockLocationReplace, + }, + }); + + render(); + + const signBackInButton = screen.getByRole('button', { + name: 'Sign back in', + }); + expect(signBackInButton).toBeInTheDocument(); + + act(() => { + signBackInButton.click(); + }); + + await waitFor(() => + expect(mockLocationReplace).toBeCalledWith(mockBackendUrl + endpoints.LOGIN), + ); + }); +}); diff --git a/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.tsx b/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.tsx new file mode 100644 index 000000000..6c9109882 --- /dev/null +++ b/app/src/pages/sessionExpiredErrorPage/SessionExpiredErrorPage.tsx @@ -0,0 +1,32 @@ +import { ButtonLink } from 'nhsuk-react-components'; +import React, { MouseEvent, useState } from 'react'; +import { endpoints } from '../../types/generic/endpoints'; +import Spinner from '../../components/generic/spinner/Spinner'; +import useBaseAPIUrl from '../../helpers/hooks/useBaseAPIUrl'; + +const SessionExpiredErrorPage = () => { + const baseAPIUrl = useBaseAPIUrl(); + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = (e: MouseEvent) => { + setIsLoading(true); + e.preventDefault(); + window.location.replace(`${baseAPIUrl}${endpoints.LOGIN}`); + }; + + return !isLoading ? ( + <> +

We signed you out due to inactivity

+

+ This is to protect your information. You'll need to enter any information you + submitted again. +

+ + Sign back in + + + ) : ( + + ); +}; +export default SessionExpiredErrorPage; diff --git a/app/src/router/AppRouter.tsx b/app/src/router/AppRouter.tsx index 4b93a2323..141f89bc9 100644 --- a/app/src/router/AppRouter.tsx +++ b/app/src/router/AppRouter.tsx @@ -23,6 +23,7 @@ import FeedbackPage from '../pages/feedbackPage/FeedbackPage'; import ServerErrorPage from '../pages/serverErrorPage/ServerErrorPage'; import PrivacyPage from '../pages/privacyPage/PrivacyPage'; import LloydGeorgeUploadPage from '../pages/lloydGeorgeUploadPage/LloydGeorgeUploadPage'; +import SessionExpiredErrorPage from '../pages/sessionExpiredErrorPage/SessionExpiredErrorPage'; const { START, @@ -33,6 +34,7 @@ const { UNAUTHORISED_LOGIN, AUTH_ERROR, SERVER_ERROR, + SESSION_EXPIRED, FEEDBACK, LOGOUT, SEARCH_PATIENT, @@ -78,6 +80,10 @@ export const routeMap: Routes = { page: , type: ROUTE_TYPE.PUBLIC, }, + [SESSION_EXPIRED]: { + page: , + type: ROUTE_TYPE.PUBLIC, + }, [PRIVACY_POLICY]: { page: , type: ROUTE_TYPE.PUBLIC, diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index 02dd00d84..800eca68e 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -9,6 +9,7 @@ export enum routes { AUTH_ERROR = '/auth-error', UNAUTHORISED_LOGIN = '/unauthorised-login', SERVER_ERROR = '/server-error', + SESSION_EXPIRED = '/session-expired', PRIVACY_POLICY = '/privacy-policy', LOGOUT = '/logout', FEEDBACK = '/feedback',