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
+
+
+
+
+
+ )
+}