diff --git a/packages/cpt-ui/__tests__/AccessProvider.test.tsx b/packages/cpt-ui/__tests__/AccessProvider.test.tsx index f4b3dff230..f6a6a50ee2 100644 --- a/packages/cpt-ui/__tests__/AccessProvider.test.tsx +++ b/packages/cpt-ui/__tests__/AccessProvider.test.tsx @@ -7,6 +7,8 @@ import {useNavigate, useLocation} from "react-router-dom" import {normalizePath as mockNormalizePath} from "@/helpers/utils" import {logger} from "@/helpers/logger" import {handleRestartLogin} from "@/helpers/logout" +import Layout from "@/Layout" +import LoadingPage from "@/pages/LoadingPage" jest.mock("react-router-dom", () => ({ useNavigate: jest.fn(), @@ -21,6 +23,11 @@ jest.mock("@/context/AuthProvider", () => ({ useAuth: jest.fn() })) +jest.mock("@/components/EpsHeader", () => ({ + __esModule: true, + default: jest.fn(() => null) +})) + jest.mock("@/constants/environment", () => ({ FRONTEND_PATHS: { LOGIN: "/login", @@ -49,7 +56,11 @@ jest.mock("@/constants/environment", () => ({ "/privacy-notice", "/cookies-selected", "/" - ] + ], + APP_CONFIG: { + COMMIT_ID: "test-commit", + VERSION_NUMBER: "1.0.0" + } })) jest.mock("@/helpers/logger", () => ({ @@ -235,12 +246,14 @@ describe("AccessProvider", () => { const {container} = render( - + + + ) - // Should render nothing (children blocked) - expect(container).toBeEmptyDOMElement() + // Should render nothing (children blocked) - show loading wheel + expect(container).toBeInTheDocument() }) it("allows children when concurrent session exists but user is on session selection page", () => { @@ -281,12 +294,14 @@ describe("AccessProvider", () => { const {container} = render( - + + + ) - // Should render nothing (children blocked) - expect(container).toBeEmptyDOMElement() + // Should render nothing (children blocked) - show loading page + expect(container).toBeInTheDocument() }) }) diff --git a/packages/cpt-ui/__tests__/AccessProvider.updateTrackerUserInfo.test.tsx b/packages/cpt-ui/__tests__/AccessProvider.updateTrackerUserInfo.test.tsx new file mode 100644 index 0000000000..8b8fa6852b --- /dev/null +++ b/packages/cpt-ui/__tests__/AccessProvider.updateTrackerUserInfo.test.tsx @@ -0,0 +1,172 @@ +import React, {useContext} from "react" +import {render, waitFor, act} from "@testing-library/react" +import {MemoryRouter} from "react-router-dom" + +import {AuthContext, AuthProvider} from "@/context/AuthProvider" +import {getTrackerUserInfo} from "@/helpers/userInfo" + +// Mock the external dependencies that AuthProvider needs +jest.mock("aws-amplify", () => ({ + Amplify: { + configure: jest.fn() + } +})) + +jest.mock("aws-amplify/auth", () => ({ + signInWithRedirect: jest.fn(), + signOut: jest.fn() +})) + +jest.mock("aws-amplify/utils", () => ({ + Hub: { + listen: jest.fn(() => () => {}) // Return unsubscribe function + } +})) + +jest.mock("@/helpers/userInfo", () => ({ + getTrackerUserInfo: jest.fn(), + updateRemoteSelectedRole: jest.fn() +})) + +jest.mock("@/constants/environment", () => ({ + PUBLIC_PATHS: ["/login"], + FRONTEND_PATHS: { + LOGIN: "/login" + }, + API_ENDPOINTS: { + CIS2_SIGNOUT_ENDPOINT: "/api/cis2-signout" + }, + AUTH_CONFIG: { + USER_POOL_ID: "mock-pool-id", + USER_POOL_CLIENT_ID: "mock-client-id", + HOSTED_LOGIN_DOMAIN: "mock-domain", + REDIRECT_SIGN_IN: "mock-signin", + REDIRECT_SIGN_OUT: "mock-signout" + }, + APP_CONFIG: { + REACT_LOG_LEVEL: "info" + } +})) + +jest.mock("@/helpers/logger", () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn() + } +})) + +describe("updateTrackerUserInfo", () => { + beforeEach(() => { + jest.clearAllMocks() + // Mock localStorage + Storage.prototype.getItem = jest.fn(() => null) + Storage.prototype.setItem = jest.fn() + }) + + it("does not update role/user state if there is an error", async () => { + const testObject = { + error: "Some error", + rolesWithAccess: [{name: "Role1"}], + rolesWithoutAccess: [{name: "Role2"}], + selectedRole: {name: "Role1"}, + userDetails: {username: "testuser"}, + isConcurrentSession: false, + sessionId: "session123", + invalidSessionCause: undefined + } + + // Mock getTrackerUserInfo to return an error response + ;(getTrackerUserInfo as jest.Mock).mockResolvedValue(testObject) + + // Create a test component that captures the context value + let contextValue: typeof AuthContext extends React.Context ? T : never + + const TestComponent = () => { + contextValue = useContext(AuthContext) + return null + } + + // Act: Render the provider with our test component + await act(async () => { + render( + + + + + + ) + }) + + // Call the function we're testing + await act(async () => { + await contextValue!.updateTrackerUserInfo() + }) + + // Assert: Verify that role/user details were NOT set (because of error) + await waitFor(() => { + expect(contextValue!.rolesWithAccess).toEqual([]) + expect(contextValue!.rolesWithoutAccess).toEqual([]) + expect(contextValue!.selectedRole).toBeUndefined() + expect(contextValue!.userDetails).toBeUndefined() + }) + + // Assert: Verify that session info WAS set (even with error) + await waitFor(() => { + expect(contextValue!.isConcurrentSession).toBe(testObject.isConcurrentSession) + expect(contextValue!.sessionId).toBe(testObject.sessionId) + expect(contextValue!.error).toBe(testObject.error) + expect(contextValue!.invalidSessionCause).toBe(testObject.invalidSessionCause) + }) + }) + + it("updates all state when there is no error", async () => { + // Arrange: Set up test data WITHOUT an error + const testObject = { + error: null, + rolesWithAccess: [{name: "Role1"}], + rolesWithoutAccess: [{name: "Role2"}], + selectedRole: {name: "Role1"}, + userDetails: {username: "testuser"}, + isConcurrentSession: true, + sessionId: "session456", + invalidSessionCause: undefined + } + + ;(getTrackerUserInfo as jest.Mock).mockResolvedValue(testObject) + + let contextValue: typeof AuthContext extends React.Context ? T : never + + const TestComponent = () => { + contextValue = useContext(AuthContext) + return null + } + + // Act + await act(async () => { + render( + + + + + + ) + }) + + await act(async () => { + await contextValue!.updateTrackerUserInfo() + }) + + // Assert: ALL state should be set when there's no error + await waitFor(() => { + expect(contextValue!.rolesWithAccess).toEqual(testObject.rolesWithAccess) + expect(contextValue!.rolesWithoutAccess).toEqual(testObject.rolesWithoutAccess) + expect(contextValue!.selectedRole).toEqual(testObject.selectedRole) + expect(contextValue!.userDetails).toEqual(testObject.userDetails) + expect(contextValue!.isConcurrentSession).toBe(testObject.isConcurrentSession) + expect(contextValue!.sessionId).toBe(testObject.sessionId) + expect(contextValue!.error).toBe(testObject.error) + expect(contextValue!.invalidSessionCause).toBe(testObject.invalidSessionCause) + }) + }) +}) diff --git a/packages/cpt-ui/jest.setup.ts b/packages/cpt-ui/jest.setup.ts index a78e4c29cd..71b3edca43 100644 --- a/packages/cpt-ui/jest.setup.ts +++ b/packages/cpt-ui/jest.setup.ts @@ -7,6 +7,34 @@ jest.mock("*.css", () => ({}), {virtual: true}) jest.mock("*.scss", () => ({}), {virtual: true}) jest.mock("@/styles/searchforaprescription.scss", () => ({}), {virtual: true}) +// Mock FooterStrings to avoid import.meta issues +jest.mock("@/constants/ui-strings/FooterStrings", () => ({ + FOOTER_COPYRIGHT: "© NHS England", + COMMIT_ID: "test-commit-id", + VERSION_NUMBER: "test-version-number", + FOOTER_LINKS: [ + { + text: "Privacy notice", + href: "/site/privacy-notice", + external: false, + testId: "eps_footer-link-privacy-notice" + }, + { + text: "Terms and conditions (opens in new tab)", + // eslint-disable-next-line max-len + href: "https://digital.nhs.uk/services/care-identity-service/registration-authority-users/registration-authority-help/privacy-notice", + external: true, + testId: "eps_footer-link-terms-and-conditions" + }, + { + text: "Cookie policy", + href: "/site/cookies", + external: false, + testId: "eps_footer-link-cookie-policy" + } + ] +})) + const cwr_cookie_value_string = JSON.stringify({"sessionId":"my_rum_session_id"}) const cwr_cookie_value_encoded = Buffer.from(cwr_cookie_value_string, "utf-8").toString("base64") diff --git a/packages/cpt-ui/src/Layout.tsx b/packages/cpt-ui/src/Layout.tsx index 13880f7b3e..068f602bb6 100644 --- a/packages/cpt-ui/src/Layout.tsx +++ b/packages/cpt-ui/src/Layout.tsx @@ -4,15 +4,15 @@ import RBACBanner from "@/components/RBACBanner" import EpsFooter from "@/components/EpsFooter" import PatientDetailsBanner from "@/components/PatientDetailsBanner" import PrescriptionInformationBanner from "@/components/PrescriptionInformationBanner" -import {Fragment} from "react" +import {Fragment, ReactNode} from "react" -export default function Layout() { +export default function Layout({children}: {children?: ReactNode}) { return ( - - - + {!children && } + {!children && } + {children ?? } diff --git a/packages/cpt-ui/src/context/AccessProvider.tsx b/packages/cpt-ui/src/context/AccessProvider.tsx index 2281647f88..82178a2e3a 100644 --- a/packages/cpt-ui/src/context/AccessProvider.tsx +++ b/packages/cpt-ui/src/context/AccessProvider.tsx @@ -12,6 +12,8 @@ import {useAuth} from "./AuthProvider" import {ALLOWED_NO_ROLE_PATHS, FRONTEND_PATHS, PUBLIC_PATHS} from "@/constants/environment" import {logger} from "@/helpers/logger" import {handleRestartLogin} from "@/helpers/logout" +import LoadingPage from "@/pages/LoadingPage" +import Layout from "@/Layout" export const AccessContext = createContext | null>(null) @@ -61,6 +63,7 @@ export const AccessProvider = ({children}: { children: ReactNode }) => { } const loggedOut = !auth.isSignedIn && !auth.isSigningOut + const loggingOut = auth.isSignedIn && auth.isSigningOut const concurrent = auth.isSignedIn && auth.isConcurrentSession const noRole = auth.isSignedIn && !auth.isSigningIn && !auth.selectedRole const authedAtRoot = auth.isSignedIn && !!auth.selectedRole && atRoot @@ -78,7 +81,7 @@ export const AccessProvider = ({children}: { children: ReactNode }) => { return redirect(FRONTEND_PATHS.SESSION_SELECTION, "Concurrent session found - redirecting to session selection") } - if (noRole && (!inNoRoleAllowed || atRoot)) { + if (!loggingOut && noRole && (!inNoRoleAllowed || atRoot)) { return redirect(FRONTEND_PATHS.SELECT_YOUR_ROLE, `No selected role - Redirecting from ${path}`) } @@ -143,7 +146,11 @@ export const AccessProvider = ({children}: { children: ReactNode }) => { }, [auth.isSignedIn, auth.isSigningIn, location.pathname]) if (shouldBlockChildren()) { - return <> + return ( + + + + ) } return ( diff --git a/packages/cpt-ui/src/context/AuthProvider.tsx b/packages/cpt-ui/src/context/AuthProvider.tsx index d2ac22614b..4ae2bec78d 100644 --- a/packages/cpt-ui/src/context/AuthProvider.tsx +++ b/packages/cpt-ui/src/context/AuthProvider.tsx @@ -91,14 +91,16 @@ export const AuthProvider = ({children}: { children: React.ReactNode }) => { const updateTrackerUserInfo = async () => { const trackerUserInfo = await getTrackerUserInfo() - setRolesWithAccess(trackerUserInfo.rolesWithAccess) - setRolesWithoutAccess(trackerUserInfo.rolesWithoutAccess) - setSelectedRole(trackerUserInfo.selectedRole) - setUserDetails(trackerUserInfo.userDetails) - setError(trackerUserInfo.error) + if (!trackerUserInfo.error) { + setRolesWithAccess(trackerUserInfo.rolesWithAccess) + setRolesWithoutAccess(trackerUserInfo.rolesWithoutAccess) + setSelectedRole(trackerUserInfo.selectedRole) + setUserDetails(trackerUserInfo.userDetails) + } setIsConcurrentSession(trackerUserInfo.isConcurrentSession) - setInvalidSessionCause(trackerUserInfo.invalidSessionCause) setSessionId(trackerUserInfo.sessionId) + setError(trackerUserInfo.error) + setInvalidSessionCause(trackerUserInfo.invalidSessionCause) return trackerUserInfo } diff --git a/packages/cpt-ui/src/pages/LoadingPage.tsx b/packages/cpt-ui/src/pages/LoadingPage.tsx new file mode 100644 index 0000000000..946e2a55e3 --- /dev/null +++ b/packages/cpt-ui/src/pages/LoadingPage.tsx @@ -0,0 +1,23 @@ +import {Col, Container, Row} from "nhsuk-react-components" +import EpsSpinner from "@/components/EpsSpinner" +import {usePageTitle} from "@/hooks/usePageTitle" +import {logger} from "@/helpers/logger" +import {normalizePath} from "@/helpers/utils" + +export default function LoadingPage() { + usePageTitle("Loading information") + const path = normalizePath(location.pathname) + logger.info(`Loading requested path: ${path}`) + return ( +
+ + + +

Loading

+ + +
+
+
+ ) +}