diff --git a/.vscode/eps-prescription-tracker-ui.code-workspace b/.vscode/eps-prescription-tracker-ui.code-workspace index 6dee7f5c0f..b001080157 100644 --- a/.vscode/eps-prescription-tracker-ui.code-workspace +++ b/.vscode/eps-prescription-tracker-ui.code-workspace @@ -131,6 +131,7 @@ "venv", "versionable", "whens", + "yourselectedrole", "jwks", "oidcjwks" ], diff --git a/README.md b/README.md index 120aa1ab3c..8d415773b8 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,52 @@ You will now be able to use AWS and CDK CLI commands to access the dev account. When the token expires, you may need to reauthorise using `make aws-login` +### Local Environment Configuration + +To run the CPT UI locally (with mock auth and actual API usage), you can configure your `.envrc` file with a few variables. Below is an example configuration: + +``` +################ +# UPDATE THESE # +################ +export SERVICE_NAME=cpt-ui-pr-123 +export NEXT_PUBLIC_userPoolClientId="1234567890deadbeef" +export NEXT_PUBLIC_userPoolId="eu-west-2_deadbeef" +export LOCAL_DEV=true + +# DON'T TOUCH! +export API_DOMAIN_OVERRIDE=https://${SERVICE_NAME}.dev.eps.national.nhs.uk/ + +export NEXT_PUBLIC_hostedLoginDomain=${SERVICE_NAME}.auth.eu-west-2.amazoncognito.com +export NEXT_PUBLIC_redirectSignIn=http://localhost:3000/auth_demo/ +export NEXT_PUBLIC_redirectSignOut=http://localhost:3000/ + +export NEXT_PUBLIC_COMMIT_ID="Local Development Server" + +export REACT_APP_hostedLoginDomain=$NEXT_PUBLIC_hostedLoginDomain +export REACT_APP_userPoolClientId=$NEXT_PUBLIC_userPoolClientId +export REACT_APP_userPoolId=$NEXT_PUBLIC_userPoolId +export REACT_APP_redirectSignIn=$NEXT_PUBLIC_redirectSignIn +export REACT_APP_redirectSignOut=$NEXT_PUBLIC_redirectSignOut +``` + +To enable mock auth for the local dev server, we only need the user pool details. To fetch these, you can use the following AWS CLI commands: + +``` +export SERVICE_NAME=cpt-ui-pr- +userPoolClientId=$(aws cloudformation list-exports --region eu-west-2 --query "Exports[?Name=='${SERVICE_NAME}-stateful-resources:userPoolClient:userPoolClientId'].Value" --output text) +userPoolId=$(aws cloudformation list-exports --region eu-west-2 --query "Exports[?Name=='${SERVICE_NAME}-stateful-resources:userPool:Id'].Value" --output text) +echo $userPoolClientId +echo $userPoolId +``` + +For me, the aws terminal console installed in the dev container refuses to work. Another approach is to use the browser console, accessed by clicking the terminal icon next to the search bar on the AWS web dashboard. + +n.b. Ensure you've properly sourced these variables! Direnv can sometimes miss changes. +``` +source .envrc +``` + ### React app React/Next.js code resides in app folder. More details to be added as dev progresses, see make section for relevant commands diff --git a/packages/cdk/resources/Cognito.ts b/packages/cdk/resources/Cognito.ts index bcc366ebc5..9de654e121 100644 --- a/packages/cdk/resources/Cognito.ts +++ b/packages/cdk/resources/Cognito.ts @@ -187,8 +187,10 @@ export class Cognito extends Construct { const callbackUrls = [ `https://${props.fullCloudfrontDomain}/site/`, + // FIXME: This is temporary, until we get routing fixed `https://${props.fullCloudfrontDomain}/site/auth_demo.html`, - `https://${props.fullCloudfrontDomain}/auth_demo/` + `https://${props.fullCloudfrontDomain}/auth_demo/`, + `https://${props.fullCloudfrontDomain}/oauth2/idpresponse` ] const logoutUrls = [ diff --git a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx b/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx index b90a7d7c25..11619cedf9 100644 --- a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx +++ b/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx @@ -74,7 +74,7 @@ const MockAuthProvider = ({ children }) => { // Since we've referenced AuthContext in the mock provider, we need to re-import it here // after the mock is set up. -import { AuthContext } from "../context/AuthContext"; +import { AuthContext } from "../context/AuthProvider"; import AuthPage from "../app/auth_demo/page"; describe("AuthPage", () => { diff --git a/packages/cpt-ui/__tests__/AuthProvider.test.tsx b/packages/cpt-ui/__tests__/AuthProvider.test.tsx index 5d20b06855..d708b26072 100644 --- a/packages/cpt-ui/__tests__/AuthProvider.test.tsx +++ b/packages/cpt-ui/__tests__/AuthProvider.test.tsx @@ -1,46 +1,46 @@ import React, { useContext } from 'react'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, act } from '@testing-library/react'; import { Buffer } from 'buffer'; import { Amplify } from 'aws-amplify'; import { Hub } from "aws-amplify/utils"; import { signInWithRedirect, signOut, getCurrentUser, fetchAuthSession } from 'aws-amplify/auth'; -import { AuthContext, AuthProvider } from "@/context/AuthContext"; +import { AuthContext, AuthProvider } from "@/context/AuthProvider"; - -// Mock environment variables if needed +// Mock environment variables to mimic the real environment process.env.NEXT_PUBLIC_userPoolId = 'testUserPoolId'; process.env.NEXT_PUBLIC_userPoolClientId = 'testUserPoolClientId'; process.env.NEXT_PUBLIC_hostedLoginDomain = 'testDomain'; process.env.NEXT_PUBLIC_redirectSignIn = 'http://localhost:3000'; process.env.NEXT_PUBLIC_redirectSignOut = 'http://localhost:3000'; -// Mock the AWS Amplify related functions and Hub +// Mock AWS Amplify functions to isolate AuthProvider logic jest.mock('aws-amplify', () => ({ Amplify: { - configure: jest.fn() + configure: jest.fn(), // Mock Amplify configuration }, })); jest.mock('aws-amplify/auth', () => ({ - signInWithRedirect: jest.fn(), - signOut: jest.fn(), - getCurrentUser: jest.fn(), - fetchAuthSession: jest.fn() + signInWithRedirect: jest.fn(), // Mock redirect sign-in + signOut: jest.fn(), // Mock sign-out + getCurrentUser: jest.fn(), // Mock current user retrieval + fetchAuthSession: jest.fn(), // Mock session fetch })); jest.mock('aws-amplify/utils', () => ({ Hub: { - listen: jest.fn(), + listen: jest.fn(), // Mock Amplify Hub for event listening }, })); -// A test component that consumes the AuthContext +// A helper component to consume the AuthContext and expose its values for testing const TestConsumer = () => { - const auth = useContext(AuthContext); - if (!auth) return null; + const auth = useContext(AuthContext); // Access the AuthContext + if (!auth) return null; // Return nothing if context is not available + // Render state values for testing return (
{auth.isSignedIn ? 'true' : 'false'}
@@ -52,17 +52,21 @@ const TestConsumer = () => { ); }; +// Test suite for AuthProvider describe('AuthProvider', () => { + // Variable to store the callback for Amplify Hub events let hubCallback: ((data: any) => void) | null = null; - const idTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; - const accessTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; + // Token payloads for mock sessions + const idTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; // Valid token + const accessTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; // Valid token + // Helper function to create mock tokens const createTokenMocks = () => ({ tokens: { idToken: { toString: () => `header.${btoa(JSON.stringify(idTokenPayload))}.signature`, - payload: idTokenPayload + payload: idTokenPayload, }, accessToken: { toString: () => `header.${btoa(JSON.stringify(accessTokenPayload))}.signature`, @@ -76,60 +80,75 @@ describe('AuthProvider', () => { userMock?: any | null; // userMock can be `null` or `any` TestComponent?: JSX.Element; }; - + const renderWithProvider = async ({ sessionMock = { tokens: {} }, - userMock = null, + userMock = null as { username: string } | null, // Allow userMock to be null or an object with username TestComponent = , }: RenderWithProviderOptions = {}) => { + // Mock session and user fetch (fetchAuthSession as jest.Mock).mockResolvedValue(sessionMock); (getCurrentUser as jest.Mock).mockResolvedValue(userMock); - render( - - {TestComponent} - - ); + // Render the AuthProvider with the specified TestComponent + await act(async () => { + render( + + {TestComponent} + + ); + }); + + // Ensure Amplify.configure was called await waitFor(() => { expect(Amplify.configure).toHaveBeenCalled(); }); }; + // Global setup for encoding functions beforeAll(() => { global.atob = (str) => Buffer.from(str, 'base64').toString('binary'); global.btoa = (str) => Buffer.from(str, 'binary').toString('base64'); }); + // Reset mocks before each test beforeEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); // Restore all mock implementations (Hub.listen as jest.Mock).mockImplementation((channel, callback) => { if (channel === 'auth') { - hubCallback = callback; + hubCallback = callback; // Store the Hub callback } - return () => {}; // unsubscribe mock + return () => {}; // Mock unsubscribe function }); }); + // Initialization and Configuration it('should configure Amplify on mount', async () => { + // Verify Amplify.configure is called when the provider mounts await renderWithProvider(); - // Just by rendering, we verify configuration was called + expect(Amplify.configure).toHaveBeenCalled(); }); + // Session Handling it('should set isSignedIn to false if no valid tokens are returned', async () => { + // Render without valid tokens await renderWithProvider(); await waitFor(() => { + // Check that the signed-in state is false and user is null expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); expect(screen.getByTestId('user').textContent).toBe(''); }); }); it('should set isSignedIn to true and user when valid tokens are returned', async () => { + // Render with valid tokens and a mock user await renderWithProvider({ sessionMock: createTokenMocks(), - userMock: { username: 'testuser' } + userMock: { username: 'testuser' }, }); await waitFor(() => { + // Check that the signed-in state is true and user is present expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); expect(screen.getByTestId('user').textContent).toBe('UserPresent'); expect(screen.getByTestId('idToken').textContent).toBe('IdTokenPresent'); @@ -137,78 +156,315 @@ describe('AuthProvider', () => { }); }); - it('should handle Hub event signInWithRedirect and call getUser', async () => { - // Initially no tokens - const { rerender } = render( - - - - ); + it('should handle missing tokens during session fetch', async () => { + // Simulate a session fetch with missing tokens + const incompleteSession = { tokens: {} }; - (fetchAuthSession as jest.Mock).mockResolvedValueOnce({ tokens: {} }); + await renderWithProvider({ sessionMock: incompleteSession }); await waitFor(() => { + // Assert that the user is not signed in due to missing tokens expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); }); + }); - // On signInWithRedirect event, simulate tokens and user - (fetchAuthSession as jest.Mock).mockResolvedValue(createTokenMocks()); - (getCurrentUser as jest.Mock).mockResolvedValue({ username: 'testuser' }); + // Error Handling + it('should handle fetchAuthSession failure', async () => { + // Mock fetchAuthSession to throw an error + (fetchAuthSession as jest.Mock).mockRejectedValue(new Error('Session fetch failed')); - // Trigger Hub event - if (hubCallback) { - hubCallback({ payload: { event: 'signInWithRedirect' } }); - } + await renderWithProvider(); - rerender( - - - - ); + await waitFor(() => { + // Assert that the user is not signed in due to session fetch failure + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + }); + + it('should handle getCurrentUser failure gracefully', async () => { + // Mock getCurrentUser to throw an error + (getCurrentUser as jest.Mock).mockRejectedValue(new Error('User fetch failed')); + + await renderWithProvider({ + sessionMock: createTokenMocks(), + }); await waitFor(() => { + // Assert that valid tokens do not automatically result in user data due to user fetch failure expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); - expect(screen.getByTestId('user').textContent).toBe('UserPresent'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + }); + + it('should log an error and reset state when fetching user session fails', async () => { + // Mock console.error to track calls + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock fetchAuthSession to throw an error + const sessionError = new Error('Session fetch failed'); + (fetchAuthSession as jest.Mock).mockRejectedValueOnce(sessionError); + + // Render the provider + await renderWithProvider(); + + // Wait for the state to be reset + await waitFor(() => { + // Verify that the state is reset correctly + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + + // Verify that the error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching user session:', + sessionError + ); + + // Restore the original console.error implementation + consoleErrorSpy.mockRestore(); + }); + + it('should log an error if signOut fails', async () => { + // Mock console.error to track calls + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock signOut to throw an error + const signOutError = new Error('Sign out failed'); + (signOut as jest.Mock).mockRejectedValue(signOutError); + + let contextValue: any; + + const TestComponent = () => { + contextValue = useContext(AuthContext); + return null; + }; + + // Render the provider + await act(async () => { + render( + + + + ); + }); + + // Attempt to sign out and verify the logged error + await act(async () => { + await contextValue.cognitoSignOut(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to sign out:', signOutError); // Error logged + + // Restore the original console.error implementation + consoleErrorSpy.mockRestore(); + }); + + // Token Handling + it('should log a warning and reset state when the ID token is expired', async () => { + // Mock console.warn to track calls + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Create mock tokens with an expired ID token + const expiredIdToken = { + tokens: { + idToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + accessToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) + 3600 }, + }, + }, + }; + + // Render the provider with the expired ID token + await renderWithProvider({ sessionMock: expiredIdToken }); + + // Wait for the state to be reset and verify the warning + await waitFor(() => { + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); // State reset + expect(screen.getByTestId('user').textContent).toBe(''); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'ID token is expired. Consider refreshing the token.' + ); // Warning logged + }); + + // Restore the original console.warn implementation + consoleWarnSpy.mockRestore(); + }); + + it('should handle expired tokens', async () => { + // Create mock expired tokens + const expiredTokens = { + tokens: { + idToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + accessToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + }, + }; + + // Render with expired tokens + await renderWithProvider({ sessionMock: expiredTokens }); + + await waitFor(() => { + // Verify signed-in state is false and user is cleared + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + }); + + // Hub Events + it('should handle Hub event signInWithRedirect', async () => { + // Mock session and user for a successful signInWithRedirect Hub event + const mockSession = createTokenMocks(); // Create valid mock tokens + const mockUser = { username: 'testuser' }; // Create a mock user object + + // Mock Amplify functions to return the mocked session and user + (fetchAuthSession as jest.Mock).mockResolvedValue(mockSession); + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + // Render the AuthProvider with a TestConsumer to observe context changes + await act(async () => { + render( + + + + ); + }); + + // Ensure the Hub event listener (hubCallback) is initialized + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); + } + + // Simulate the Hub event "signInWithRedirect" + act(() => { + // Simulate a successful Hub event for signInWithRedirect + hubCallback!({ payload: { event: 'signInWithRedirect' } }); + }); + + // Wait for the context state to update and verify changes + await waitFor(() => { + // Assert that the user is signed in after the Hub event + expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); // User is signed in + expect(screen.getByTestId('user').textContent).toBe('UserPresent'); // User object is present }); }); it('should handle Hub event signInWithRedirect_failure', async () => { + // Render the AuthProvider with a TestConsumer to observe context changes await renderWithProvider(); + + // Ensure the Hub event listener (hubCallback) is initialized + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); + } + + // Simulate the Hub event "signInWithRedirect_failure" + act(() => { + hubCallback!({ payload: { event: 'signInWithRedirect_failure' } }); + }); + + // Wait for the context state to update and verify changes + await waitFor(() => { + // Assert that an error is set after the Hub event failure + expect(screen.getByTestId('error').textContent).toBe( + 'An error has occurred during the OAuth flow.' // Error state is updated + ); + }); + }); + + it('should handle tokenRefresh event successfully', async () => { + await renderWithProvider({ + sessionMock: createTokenMocks(), + userMock: { username: 'testuser' }, + }); + + await waitFor(() => { + // Check that the signed-in state is true and user is present + expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); + expect(screen.getByTestId('user').textContent).toBe('UserPresent'); + expect(screen.getByTestId('idToken').textContent).toBe('IdTokenPresent'); + expect(screen.getByTestId('accessToken').textContent).toBe('AccessTokenPresent'); + }); + + // Now simulate a token refresh event + (fetchAuthSession as jest.Mock).mockResolvedValueOnce(createTokenMocks()); + (getCurrentUser as jest.Mock).mockResolvedValue({ username: 'testuser' }); + + // Trigger the tokenRefresh Hub event if (hubCallback) { - hubCallback({ payload: { event: 'signInWithRedirect_failure' } }); + hubCallback({ payload: { event: 'tokenRefresh' } }); } + // After the tokenRefresh event, ensure no error and user remains signed in await waitFor(() => { - expect(screen.getByTestId('error').textContent).toBe('An error has occurred during the OAuth flow.'); + expect(screen.getByTestId('error').textContent).toBe(''); + expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); + expect(screen.getByTestId('user').textContent).toBe('UserPresent'); }); }); - it('should handle customOAuthState event', async () => { + it('should handle Hub event signedOut', async () => { + // Render the AuthProvider and capture the Hub callback await renderWithProvider(); - if (hubCallback) { - hubCallback({ payload: { event: 'customOAuthState', data: 'my-custom-state' } }); + + // Ensure the Hub event listener (hubCallback) is initialized + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); } + + // Simulate the 'signedOut' Hub event + act(() => { + hubCallback!({ payload: { event: 'signedOut' } }); + }); + + // Verify that the context state is reset + await waitFor(() => { + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + expect(screen.getByTestId('idToken').textContent).toBe(''); + expect(screen.getByTestId('accessToken').textContent).toBe(''); + expect(screen.getByTestId('error').textContent).toBe(''); + }); }); + // Auth Functions it('should provide cognitoSignIn and cognitoSignOut functions', async () => { let contextValue: any; - const TestConsumerWithFunctions = () => { + + // Create a test consumer to access the context + const TestComponent = () => { contextValue = useContext(AuthContext); return null; }; - await renderWithProvider({ TestComponent: }); - - await waitFor(() => { - expect(contextValue).toBeTruthy(); - expect(typeof contextValue.cognitoSignIn).toBe('function'); - expect(typeof contextValue.cognitoSignOut).toBe('function'); + await act(async () => { + render( + + + + ); }); - await contextValue.cognitoSignIn(); + // Verify that cognitoSignIn calls signInWithRedirect + await act(async () => { + await contextValue.cognitoSignIn(); + }); expect(signInWithRedirect).toHaveBeenCalled(); - await contextValue.cognitoSignOut(); + // Verify that cognitoSignOut calls signOut + await act(async () => { + await contextValue.cognitoSignOut(); + }); expect(signOut).toHaveBeenCalled(); }); }); diff --git a/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx b/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx deleted file mode 100644 index 0ee89fdfba..0000000000 --- a/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import Page from "../app/selectyourrole/page"; -import EpsHeader from "../components/EpsHeader"; -import React from "react"; - -// Mock `next/navigation` globally -jest.mock("next/navigation", () => ({ - usePathname: jest.fn(), - useRouter: jest.fn(), -})); - -import { usePathname } from "next/navigation"; - -describe("SelectYourRole Page", () => { - it("renders a heading", () => { - render(); - - const heading = screen.getByRole("heading", { level: 1 }); - - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent("Select your role"); - }); - - it("renders the caption", () => { - render(); - - const caption = screen.getByText(/Select the role you wish to use to access the service/i); - - expect(caption).toBeInTheDocument(); - }); - - it("renders the main container", () => { - render(); - - const container = screen.getByRole("contentinfo"); - - expect(container).toBeInTheDocument(); - }); -}); diff --git a/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx b/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx new file mode 100644 index 0000000000..9c264861b7 --- /dev/null +++ b/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx @@ -0,0 +1,205 @@ +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import SelectYourRolePage from "@/app/selectyourrole/page"; +import { AuthContext } from "@/context/AuthProvider"; + +// Mock the card strings, so we have known text for the tests + +// Mock the module and directly reference the variable +jest.mock("@/constants/ui-strings/CardStrings", () => { + const SELECT_YOUR_ROLE_PAGE_TEXT = { + title: "Select your role", + caption: "Select the role you wish to use to access the service.", + insetText: { + visuallyHidden: "Information: ", + message: + "You are currently logged in at GREENE'S PHARMACY (ODS: FG419) with Health Professional Access Role.", + }, + confirmButton: { + text: "Continue to find a prescription", + link: "tracker-presc-no", + }, + alternativeMessage: "Alternatively, you can choose a new role below.", + organisation: "Organisation", + role: "Role", + roles_without_access_table_title: + "View your roles without access to the clinical prescription tracking service.", + noOrgName: "NO ORG NAME", + rolesWithoutAccessHeader: "Your roles without access", + noODSCode: "No ODS code", + noRoleName: "No role name", + noAddress: "No address", + errorDuringRoleSelection: "Error during role selection", + loadingMessage: "Loading...", + } + + return { SELECT_YOUR_ROLE_PAGE_TEXT }; +}); + +// Mock `next/navigation` to prevent errors during component rendering in test +jest.mock("next/navigation", () => ({ + usePathname: jest.fn(), + useRouter: jest.fn(), +})); + +// Create a global mock for `fetch` to simulate API requests +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// Default mock values for the `AuthContext` to simulate authentication state +const defaultAuthContext = { + error: null, // No errors by default + user: null, // User is initially null (not logged in) + isSignedIn: false, // Default state is "not signed in" + idToken: null, // No ID token available + accessToken: null, // No access token available + cognitoSignIn: jest.fn(), // Mock Cognito sign-in function + cognitoSignOut: jest.fn(), // Mock Cognito sign-out function +}; + +// Utility function to render the component with custom AuthContext overrides +const renderWithAuth = (authOverrides = {}) => { + const authValue = { ...defaultAuthContext, ...authOverrides }; + return render( + + + + ); +}; + +import { SELECT_YOUR_ROLE_PAGE_TEXT } from "@/constants/ui-strings/CardStrings"; + +describe("SelectYourRolePage", () => { + // Clear all mock calls before each test to avoid state leaks + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders loading state when signed in but fetch hasn't resolved yet", async () => { + // Mock fetch to hang indefinitely, simulating a pending request + mockFetch.mockImplementation(() => new Promise(() => {})); + + // Render the page with user signed in + renderWithAuth({ isSignedIn: true, idToken: "mock-id-token" }); + + // Verify that the loading text appears + const loadingText = screen.getByText(SELECT_YOUR_ROLE_PAGE_TEXT.loadingMessage); + expect(loadingText).toBeInTheDocument(); + }); + + it("renders error summary if fetch returns non-200 status", async () => { + // Mock fetch to return a 500 status code (server error) + mockFetch.mockResolvedValue({ status: 500 }); + + // Render the page with user signed in + renderWithAuth({ isSignedIn: true, idToken: "mock-id-token" }); + + // Wait for the error message to appear + await waitFor(() => { + // Check for error summary heading + const errorHeading = screen.getByRole("heading", { + name: SELECT_YOUR_ROLE_PAGE_TEXT.errorDuringRoleSelection, + }); + expect(errorHeading).toBeInTheDocument(); + + // Check for specific error text + const errorItem = screen.getByText("Failed to fetch CPT user info"); + expect(errorItem).toBeInTheDocument(); + }); + }); + + it("renders error summary if fetch returns 200 but no userInfo is present", async () => { + // Mock fetch to return 200 OK but with an empty JSON body + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({}), // No `userInfo` key in response + }); + + // Render the page with user signed in + renderWithAuth({ isSignedIn: true, idToken: "mock-id-token" }); + + // Wait for the error message to appear + await waitFor(() => { + // Check for error summary heading + const errorHeading = screen.getByRole("heading", { + name: SELECT_YOUR_ROLE_PAGE_TEXT.errorDuringRoleSelection, + }); + expect(errorHeading).toBeInTheDocument(); + + // Check for specific error text + const errorItem = screen.getByText("Failed to fetch CPT user info"); + expect(errorItem).toBeInTheDocument(); + }); + }); + + it("renders the page content when valid userInfo is returned", async () => { + // Mock user data to simulate valid API response + const mockUserInfo = { + roles_with_access: [ + { + role_name: "Pharmacist", + org_name: "Test Pharmacy Org", + org_code: "ORG123", + site_address: "1 Fake Street", + }, + ], + roles_without_access: [ + { + role_name: "Technician", + org_name: "Tech Org", + org_code: "ORG456", + site_address: "2 Fake Street", + }, + ], + }; + + // Mock fetch to return 200 OK with valid userInfo + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ userInfo: mockUserInfo }), + }); + + // Render the page with user signed in + renderWithAuth({ isSignedIn: true, idToken: "mock-id-token" }); + + // Wait for the main content to load + await waitFor(() => { + // Check for the page heading + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent(SELECT_YOUR_ROLE_PAGE_TEXT.title); + }); + + // Verify the page caption + const caption = screen.getByText(SELECT_YOUR_ROLE_PAGE_TEXT.caption); + expect(caption).toBeInTheDocument(); + + // Verify the "Roles without access" section (expander) + const expanderText = SELECT_YOUR_ROLE_PAGE_TEXT.roles_without_access_table_title; + const expander = screen.getByText(expanderText); + expect(expander).toBeInTheDocument(); + + // Check for the table data in "Roles without access" + const tableOrg = screen.getByText(/Tech Org \(ODS: ORG456\)/i); + expect(tableOrg).toBeInTheDocument(); + const tableRole = screen.getByText("Technician"); + expect(tableRole).toBeInTheDocument(); + }); + + it("renders error summary when not signed in", async () => { + // Render the page with `isSignedIn` set to false + renderWithAuth({ isSignedIn: false, error: "Missing access or ID token" }); + + // Wait for the error message to appear + await waitFor(() => { + // Check for error summary heading + const errorHeading = screen.getByRole("heading", { + name: SELECT_YOUR_ROLE_PAGE_TEXT.errorDuringRoleSelection, + }); + expect(errorHeading).toBeInTheDocument(); + + const errorItem = screen.getByText("Missing access or ID token"); + expect(errorItem).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/cpt-ui/__tests__/page.test.jsx b/packages/cpt-ui/__tests__/page.test.jsx index dbf23a6a55..cd83c0ed11 100644 --- a/packages/cpt-ui/__tests__/page.test.jsx +++ b/packages/cpt-ui/__tests__/page.test.jsx @@ -28,7 +28,7 @@ jest.mock("../context/configureAmplify", () => ({ })); // Mock the AuthContext with a valid value -jest.mock("../context/AuthContext", () => { +jest.mock("../context/AuthProvider", () => { const mockAuthContext = { signInWithRedirect: jest.fn(), signOut: jest.fn(), diff --git a/packages/cpt-ui/app/auth_demo/page.tsx b/packages/cpt-ui/app/auth_demo/page.tsx index d664d4cb26..232c73fcce 100644 --- a/packages/cpt-ui/app/auth_demo/page.tsx +++ b/packages/cpt-ui/app/auth_demo/page.tsx @@ -2,7 +2,7 @@ import React, {useContext, useEffect} from "react"; import { Container, Col, Row, Button } from "nhsuk-react-components"; -import { AuthContext } from "@/context/AuthContext"; +import { AuthContext } from "@/context/AuthProvider"; export default function AuthPage() { const auth = useContext(AuthContext); diff --git a/packages/cpt-ui/app/layout.tsx b/packages/cpt-ui/app/layout.tsx index 054f1bf27a..29f08f85af 100644 --- a/packages/cpt-ui/app/layout.tsx +++ b/packages/cpt-ui/app/layout.tsx @@ -4,7 +4,7 @@ import React from "react"; import 'nhsuk-frontend/dist/nhsuk.css'; import EpsHeader from '@/components/EpsHeader' import EpsFooter from '@/components/EpsFooter' -import { AuthProvider } from '@/context/AuthContext' +import { AuthProvider } from '@/context/AuthProvider' export default function RootLayout({ children, diff --git a/packages/cpt-ui/app/selectyourrole/page.tsx b/packages/cpt-ui/app/selectyourrole/page.tsx index 70dfc05791..4b293a12f5 100644 --- a/packages/cpt-ui/app/selectyourrole/page.tsx +++ b/packages/cpt-ui/app/selectyourrole/page.tsx @@ -1,26 +1,262 @@ 'use client' -import React from "react" +import React, {useState, useEffect, useContext, useCallback } from "react" +import { Container, Col, Row, Details, Table, ErrorSummary, Button, InsetText } from "nhsuk-react-components" +import { AuthContext } from "@/context/AuthProvider"; +import EpsCard, { EpsCardProps } from "@/components/EpsCard"; +import {SELECT_YOUR_ROLE_PAGE_TEXT} from "@/constants/ui-strings/CardStrings"; + +export type RoleDetails = { + role_name?: string; + role_id?: string; + org_code?: string; + org_name?: string; + site_name?: string; + site_address?: string; + uuid?: string; +}; + +export type TrackerUserInfo = { + roles_with_access: Array; + roles_without_access: Array; + currently_selected_role?: RoleDetails; +}; + +// Extends the EpsCardProps to include a unique identifier +export type RolesWithAccessProps = EpsCardProps & { + uuid: string; +} + +export type RolesWithoutAccessProps = { + uuid: string; + orgName: string; + odsCode: string; + roleName: string; +} + +const trackerUserInfoEndpoint = "/api/tracker-user-info" + +const { + title, + caption, + insetText, + confirmButton, + alternativeMessage, + organisation, + role, + roles_without_access_table_title, + noOrgName, + rolesWithoutAccessHeader, + noODSCode, + noRoleName, + noAddress, + errorDuringRoleSelection, + loadingMessage +} = SELECT_YOUR_ROLE_PAGE_TEXT; + +export default function SelectYourRolePage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [rolesWithAccess, setRolesWithAccess] = useState([]) + const [rolesWithoutAccess, setRolesWithoutAccess] = useState([]) + + const auth = useContext(AuthContext); + + const fetchTrackerUserInfo = useCallback(async () => { + setLoading(true) + setError(null) + setRolesWithAccess([]) + setRolesWithoutAccess([]) + + if (!auth?.isSignedIn || !auth) { + setLoading(false) + setError("Not signed in") + return; + } + + try { + const response = await fetch(trackerUserInfoEndpoint, { + headers: { + Authorization: `Bearer ${auth?.idToken}`, + 'NHSD-Session-URID': '555254242106' + } + }) + + if (response.status !== 200) { + throw new Error(`Server did not return CPT user info, response ${response.status}`) + } + + const data = await response.json() + + if (!data.userInfo) { + throw new Error("Server response did not contain data") + } + + const userInfo: TrackerUserInfo = data.userInfo; + + const rolesWithAccess = userInfo.roles_with_access + const rolesWithoutAccess = userInfo.roles_without_access + // Unused for now + // const currentlySelectedRole = userInfo.currently_selected_role ? { + // ...userInfo.currently_selected_role, + // uuid: `selected_role_0` + // } : undefined + + // Populate the EPS card props + setRolesWithAccess( + rolesWithAccess.map((role: RoleDetails, index: number) => ({ + uuid: `{role_with_access_${index}}`, + orgName: role.org_name ? role.org_name : noOrgName, + odsCode: role.org_code ? role.org_code : noODSCode, + siteAddress: role.site_address ? role.site_address : noAddress, + roleName: role.role_name ? role.role_name : noRoleName, + link: "yourselectedrole" + })) + ) + + setRolesWithoutAccess( + rolesWithoutAccess.map((role: RoleDetails, index: number) => ({ + uuid: `{role_without_access_${index}}`, + roleName: role.role_name ? role.role_name : noRoleName, + orgName: role.org_name ? role.org_name : noOrgName, + odsCode: role.org_code ? role.org_code : noODSCode + })) + ) + + } catch (err) { + setError("Failed to fetch CPT user info") + console.error("error fetching tracker user info:", err) + } finally { + setLoading(false) + } + }, [auth]) + + useEffect(() => { + if (auth?.isSignedIn === undefined) { + return + } + + if (auth?.isSignedIn) { + fetchTrackerUserInfo() + } else { + setError("No login session found") + } + }, [auth?.isSignedIn, fetchTrackerUserInfo]) + + useEffect(() => { + console.log("Auth error updated:", auth?.error) + // Have to do this to make `` work with `` + setError(auth?.error ?? null); + if (auth?.error) { + setLoading(false); + } + }, [auth?.error]) + + // If the data is being fetched, replace the content with a spinner + if (loading) { + return ( +
+ + + + {loadingMessage} + + + +
+ ); + } + + // If the process encounters an error, replace the content with an error summary + if (error) { + return ( +
+ + + + + {errorDuringRoleSelection} + + + + {error} + + + + + +
+ ); + } -import {Container, Col, Row} from "nhsuk-react-components" -export default function Page() { return ( -
- +
+ + {/* Title Section */} - - - -

- Select your role - - - - Select the role you wish to use to access the service. -

- -
-
+ +

+ + {title} + + - + {caption} + + +

+ {/* Inset Text Section */} +
+ + {insetText.visuallyHidden} +

{insetText.message}

+
+ {/* Confirm Button */} + +

{alternativeMessage}

+
+ + + {/* Roles with access Section */} + +
+ {rolesWithAccess.map((role: RolesWithAccessProps) => ( + + ))} +
+ + + {/* Roles without access Section */} + +

{rolesWithoutAccessHeader}

+
+ + {roles_without_access_table_title} + + + + + + {organisation} + {role} + + + + {rolesWithoutAccess.map((role: RolesWithoutAccessProps) => ( + + + {role.orgName} (ODS: {role.odsCode}) + + + {role.roleName} + + + ))} + +
+
+
+
- ) + ); } diff --git a/packages/cpt-ui/assets/styles/card.scss b/packages/cpt-ui/assets/styles/card.scss new file mode 100644 index 0000000000..c8097251c4 --- /dev/null +++ b/packages/cpt-ui/assets/styles/card.scss @@ -0,0 +1,45 @@ +.eps-card { + margin-bottom: 1.5rem; + padding: 0rem; + + &__content { + display: flex; + padding: 0rem; + align-items: flex-start; + } + + &__roleName { + margin-top: 0.5rem; + margin-bottom: 24px; + font-weight: normal; + font-size: 16px; + line-height: 1.5; + color: #4c6272; + } + + &__siteAddress { + text-align: left; + color: #4c6272; + margin-top: 0.5rem; + font-size: 1rem; + line-height: 1.5; + &-line { + display: block; + } + } + + &__link { + background-color: #ffffff; + border: 1px solid transparent; + box-shadow: 0 4px 0 0 #d8dde0; + display: block; + height: 100%; + position: relative; + text-decoration: none; +} + + .nhsuk-grid-column-two-thirds { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/packages/cpt-ui/components/EpsCard.tsx b/packages/cpt-ui/components/EpsCard.tsx new file mode 100644 index 0000000000..ebe20e0115 --- /dev/null +++ b/packages/cpt-ui/components/EpsCard.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Card, Col, Row } from "nhsuk-react-components"; +import "@/assets/styles/card.scss"; + +export interface EpsCardProps { + orgName: string; + odsCode: string; + siteAddress: string | null; + roleName: string; + link: string; +} + +export default function EpsCard({ + orgName, + odsCode, + siteAddress, + roleName, + link, +}: EpsCardProps) { + return ( + + + + + {/* Left Column: org_name and role_name */} + + + + {orgName} +
+ (ODS: {odsCode}) +
+
+ + {roleName} + + + + {/* Right Column: siteAddress */} + + + {siteAddress && + siteAddress.split("\n").map((line: string, index: number) => ( + + {line} +
+
+ ))} +
+ +
+
+
+ ); +} diff --git a/packages/cpt-ui/constants/ui-strings/CardStrings.ts b/packages/cpt-ui/constants/ui-strings/CardStrings.ts new file mode 100644 index 0000000000..922e3a2307 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/CardStrings.ts @@ -0,0 +1,24 @@ +export const SELECT_YOUR_ROLE_PAGE_TEXT = { + title: "Select your role", + caption: "Select the role you wish to use to access the service.", + insetText: { + visuallyHidden: "Information: ", + message: + "You are currently logged in at GREENE'S PHARMACY (ODS: FG419) with Health Professional Access Role." + }, + confirmButton: { + text: "Continue to find a prescription", + link: "tracker-presc-no" + }, + alternativeMessage: "Alternatively, you can choose a new role below.", + organisation: "Organisation", + role: "Role", + roles_without_access_table_title: "View your roles without access to the clinical prescription tracking service.", + noOrgName: "NO ORG NAME", + rolesWithoutAccessHeader: "Your roles without access", + noODSCode: "No ODS code", + noRoleName: "No role name", + noAddress: "Address not found", + errorDuringRoleSelection: "Error during role selection", + loadingMessage: "Loading..." +} diff --git a/packages/cpt-ui/context/AuthContext.tsx b/packages/cpt-ui/context/AuthProvider.tsx similarity index 90% rename from packages/cpt-ui/context/AuthContext.tsx rename to packages/cpt-ui/context/AuthProvider.tsx index f8a13806e7..26323eebf6 100644 --- a/packages/cpt-ui/context/AuthContext.tsx +++ b/packages/cpt-ui/context/AuthProvider.tsx @@ -33,6 +33,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const sessionIdToken = authSession.tokens?.idToken; const sessionAccessToken = authSession.tokens?.accessToken; + console.log("Tokens: ", sessionIdToken, sessionAccessToken) + if (sessionIdToken && sessionAccessToken) { // Extract expiration times directly from the token payloads. const currentTime = Math.floor(Date.now() / 1000); @@ -44,6 +46,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setUser(null); setIdToken(null); setAccessToken(null); + setError("Cognito access token expired") return; } @@ -54,6 +57,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setUser(null); setIdToken(null); setAccessToken(null); + setError("Cognito ID token expired") return; } @@ -64,12 +68,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const currentUser = await getCurrentUser(); setUser(currentUser); + setError(null); } else { console.warn("Missing access or ID token."); setIsSignedIn(false); setUser(null); setIdToken(null); setAccessToken(null); + setError("Missing access or ID token") } } catch (fetchError) { console.error("Error fetching user session:", fetchError); @@ -78,6 +84,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setAccessToken(null); setIdToken(null); setIsSignedIn(false); + setError(String(fetchError)) } }; @@ -92,8 +99,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { case "signedIn": console.log("User %s logged in", payload.data.username); setError(null); + break; case "tokenRefresh": console.log("Refreshing token"); + setError(null); + break; case "signInWithRedirect": setError(null); break; @@ -113,6 +123,11 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { case "signedOut": console.log("User signing out"); + setIsSignedIn(false); + setUser(null); + setIdToken(null); + setAccessToken(null); + setError(null); break; default: @@ -121,11 +136,6 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { } }); - // Initial attempt to get user session when component mounts - getUser().catch((err) => { - console.error("Failed to get user session on mount:", err); - }); - return () => { unsubscribe(); }; @@ -137,9 +147,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { console.log("Configuring Amplify with authConfig:", authConfig); Amplify.configure(authConfig, { ssr: true }); - getUser().catch((err) => { - console.error("Failed to get user session after Amplify config:", err); - }); + getUser(); }, [authConfig]); /** @@ -152,12 +160,15 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { setAccessToken(null); setIdToken(null); setIsSignedIn(false); + setError(null) try { await signOut({ global: true }); console.log("Signed out successfully!"); + setError(null); } catch (err) { console.error("Failed to sign out:", err); + setError(String(err)); } }; diff --git a/packages/cpt-ui/jest.config.ts b/packages/cpt-ui/jest.config.ts index 72080ab22c..505a4a0be5 100644 --- a/packages/cpt-ui/jest.config.ts +++ b/packages/cpt-ui/jest.config.ts @@ -1,12 +1,23 @@ import nextJest from "next/jest" + const createJestConfig = nextJest({ dir: "./" }) + const customJestConfig = { collectCoverage: true, coverageDirectory: "coverage", coverageProvider: "v8", + rootDir: "./", moduleDirectories: ["node_modules", "/"], - testEnvironment: "jest-environment-jsdom" + testEnvironment: "jest-environment-jsdom", + moduleNameMapper: { + "^@/context/(.*)$": "/context/$1", + "^@/app/(.*)$": "/app/$1", + "^@/constants/(.*)$": "/constants/$1", + "^@/assets/(.*)$": "/assets/$1", + "^@/components/(.*)$": "/components/$1" + } } + module.exports = createJestConfig(customJestConfig) diff --git a/packages/cpt-ui/next-env.d.ts b/packages/cpt-ui/next-env.d.ts index 40c3d68096..1b3be0840f 100644 --- a/packages/cpt-ui/next-env.d.ts +++ b/packages/cpt-ui/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/cpt-ui/next.config.js b/packages/cpt-ui/next.config.js index 3a7371ce2d..e69e90425f 100644 --- a/packages/cpt-ui/next.config.js +++ b/packages/cpt-ui/next.config.js @@ -3,15 +3,23 @@ */ const nextConfig = { output: process.env.NEXT_OUTPUT_MODE || undefined, - basePath: process.env.BASE_PATH || "" - // Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html` - // trailingSlash: true, + basePath: process.env.BASE_PATH || "", - // Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href` - // skipTrailingSlashRedirect: true, + // If we're using a local development server, we want to be able + // to call out to an actual API for testing. + // This rewrites all requests to the /api/ path. + async rewrites() { + if (process.env.LOCAL_DEV) { + return [ + { + source: "/api/:path*", + destination: `${process.env.API_DOMAIN_OVERRIDE}/api/:path*` + } + ] + } - // Optional: Change the output directory `out` -> `dist` - // distDir: 'dist', + return [] + } } module.exports = nextConfig diff --git a/packages/cpt-ui/tsconfig.json b/packages/cpt-ui/tsconfig.json index 028fbf3f8f..095fcbcc42 100644 --- a/packages/cpt-ui/tsconfig.json +++ b/packages/cpt-ui/tsconfig.json @@ -12,6 +12,7 @@ "baseUrl": ".", "paths": { "@/context/*": ["context/*"], + "@/app/*": ["app/*"], "@/constants/*": ["constants/*"], "@/assets/*": ["assets/*"], "@/components/*": ["components/*"] diff --git a/packages/trackerUserInfoLambda/src/userInfoHelpers.ts b/packages/trackerUserInfoLambda/src/userInfoHelpers.ts index 9d55925030..ef69f8ae34 100644 --- a/packages/trackerUserInfoLambda/src/userInfoHelpers.ts +++ b/packages/trackerUserInfoLambda/src/userInfoHelpers.ts @@ -3,6 +3,12 @@ import axios from "axios" import {DynamoDBDocumentClient, UpdateCommand} from "@aws-sdk/lib-dynamodb" import {UserInfoResponse, TrackerUserInfo, RoleDetails} from "./userInfoTypes" +// Role names come in formatted like `"category":"subcategory":"roleName"`. +// Takes only the last one, and strips out the quotes. +export const removeRoleCategories = (roleName: string) => { + return roleName.replace(/"/g, "").split(":").pop() +} + // Fetch user info from the OIDC UserInfo endpoint // The access token is used to identify the user, and fetch their roles. // This populates three lists: @@ -51,7 +57,7 @@ export const fetchUserInfo = async ( logger.debug("Role CPT access?", {hasAccess}) const roleInfo: RoleDetails = { - role_name: role.role_name, + role_name: removeRoleCategories(role.role_name), role_id: role.person_roleid, org_code: role.org_code, org_name: getOrgNameFromOrgCode(data, role.org_code, logger) diff --git a/scripts/run_regression_tests.py b/scripts/run_regression_tests.py index 363d4203df..72016cf631 100644 --- a/scripts/run_regression_tests.py +++ b/scripts/run_regression_tests.py @@ -13,7 +13,7 @@ from requests.auth import HTTPBasicAuth # This should be set to a known good version of regression test repo -REGRESSION_TESTS_REPO_TAG = "v2.0.8" +REGRESSION_TESTS_REPO_TAG = "v2.2.1" GITHUB_API_URL = "https://api.github.com/repos/NHSDigital/electronic-prescription-service-api-regression-tests/actions" GITHUB_RUN_URL = "https://github.com/NHSDigital/electronic-prescription-service-api-regression-tests/actions/runs" diff --git a/sonar-project.properties b/sonar-project.properties index d3b45d3a3e..84a4ba60b6 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -23,4 +23,5 @@ sonar.cpd.exclusions=\ packages/cloudfrontFunctions/tests/*, \ packages/cdk/nagSuppressions.ts, \ packages/auth_demo/**, \ - **/mock* + **/mock*, \ + **/*.test.ts*