diff --git a/express-api/src/controllers/users/usersController.ts b/express-api/src/controllers/users/usersController.ts index b8bbc82f8..c6f7f512d 100644 --- a/express-api/src/controllers/users/usersController.ts +++ b/express-api/src/controllers/users/usersController.ts @@ -20,6 +20,7 @@ export const submitUserAccessRequest = async (req: Request, res: Response) => { Number(req.body.AgencyId), req.body.Position, req.body.Note, + req.body.Email, ); const config = getConfig(); const user = await userServices.getUser(req.user.preferred_username); diff --git a/express-api/src/services/users/usersServices.ts b/express-api/src/services/users/usersServices.ts index 142328993..15ef574a7 100644 --- a/express-api/src/services/users/usersServices.ts +++ b/express-api/src/services/users/usersServices.ts @@ -6,6 +6,7 @@ import { Agency } from '@/typeorm/Entities/Agency'; import { randomUUID, UUID } from 'crypto'; import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode'; import { UserFiltering } from '@/controllers/users/usersSchema'; +import { validateEmail } from '@/utilities/helperFunctions'; interface NormalizedKeycloakUser { first_name: string; @@ -43,10 +44,11 @@ const normalizeKeycloakUser = (kcUser: SSOUser): NormalizedKeycloakUser => { /** * Adds a user from Keycloak to the system with 'OnHold' status. - * @param ssoUser The Keycloak user to be added - * @param agencyId The ID of the agency the user belongs to - * @param position The position of the user - * @param note Additional notes about the user + * @param {SSOUser} ssoUser The Keycloak user to be added + * @param {number} agencyId The ID of the agency the user belongs to + * @param {string} position The position of the user + * @param {string} note Additional notes about the user + * @param {string} email the users email * @returns The inserted user */ const addKeycloakUserOnHold = async ( @@ -54,10 +56,14 @@ const addKeycloakUserOnHold = async ( agencyId: number, position: string, note: string, + email: string, ) => { if (agencyId == null) { throw new Error('Null argument.'); } + if (!validateEmail(email)) { + throw new Error('Invalid email.'); + } //Iterating through agencies and roles no longer necessary here? const normalizedKc = normalizeKeycloakUser(ssoUser); const systemUser = await AppDataSource.getRepository(User).findOne({ @@ -68,7 +74,7 @@ const addKeycloakUserOnHold = async ( Id: id, FirstName: normalizedKc.first_name, LastName: normalizedKc.last_name, - Email: normalizedKc.email, + Email: email, DisplayName: normalizedKc.display_name, KeycloakUserId: normalizedKc.guid, Username: normalizedKc.username, @@ -207,6 +213,9 @@ const addUser = async (user: User) => { if (resource) { throw new ErrorWithCode('Resource already exists.', 409); } + if (!validateEmail(user.Email)) { + throw new Error('Invalid email.'); + } const retUser = await AppDataSource.getRepository(User).save(user); return retUser; }; @@ -222,6 +231,9 @@ const updateUser = async (user: DeepPartial) => { if (!resource) { throw new ErrorWithCode('Resource does not exist.', 404); } + if (user.Email && !validateEmail(user.Email)) { + throw new Error('Invalid email.'); + } await AppDataSource.getRepository(User).update(user.Id, { ...user, DisplayName: `${user.LastName}, ${user.FirstName}`, diff --git a/express-api/src/utilities/helperFunctions.ts b/express-api/src/utilities/helperFunctions.ts index d9abe7773..e2c22bbbc 100644 --- a/express-api/src/utilities/helperFunctions.ts +++ b/express-api/src/utilities/helperFunctions.ts @@ -1,4 +1,5 @@ import { Equal, FindOptionsWhere, IsNull, Not, Raw } from 'typeorm'; +import { z } from 'zod'; /** * Special case for PID/PIN matching, as general text comparison is not sufficient. @@ -244,3 +245,6 @@ export const toPostgresTimestamp = (date: Date) => { export const getDaysBetween = (earlierDate: Date, laterDate: Date): number => { return Math.trunc((laterDate.getTime() - earlierDate.getTime()) / (1000 * 60 * 60 * 24)); }; + +export const validateEmail = (email: string): boolean => + z.string().email().safeParse(email).success; diff --git a/express-api/tests/unit/services/users/usersServices.test.ts b/express-api/tests/unit/services/users/usersServices.test.ts index 0f81f83b1..95c6c61a0 100644 --- a/express-api/tests/unit/services/users/usersServices.test.ts +++ b/express-api/tests/unit/services/users/usersServices.test.ts @@ -74,7 +74,7 @@ jest .spyOn(AppDataSource.getRepository(Agency), 'findOneOrFail') .mockImplementation(async () => produceAgency()); -jest +const _agenciesFind = jest .spyOn(AppDataSource.getRepository(Agency), 'find') .mockImplementation(async () => [produceAgency()]); @@ -104,22 +104,77 @@ describe('UNIT - User services', () => { }); }); - describe('addAccessRequest', () => { + describe('getUserById', () => { + it('should return a user assuming one is found', async () => { + const user = produceUser(); + _usersFindOne.mockImplementationOnce(async () => user); + const result = await userServices.getUserById('123'); + expect(result).toEqual(user); + }); + + it('should return null when no user is found', async () => { + _usersFindOne.mockImplementationOnce(async () => null); + const result = await userServices.getUserById('123'); + expect(result).toEqual(null); + }); + }); + + describe('addKeycloakUserOnHold', () => { it('should add and return an access request', async () => { const agencyId = faker.number.int(); - //const roleId = faker.string.uuid(); - const req = await userServices.addKeycloakUserOnHold(ssoUser, agencyId, '', ''); + const req = await userServices.addKeycloakUserOnHold( + ssoUser, + agencyId, + '', + '', + 'email@email.com', + ); expect(_usersInsert).toHaveBeenCalledTimes(1); }); + + it('should throw an error if the agency is null', () => { + expect( + async () => + await userServices.addKeycloakUserOnHold(ssoUser, null, '', '', 'email@email.com'), + ).rejects.toThrow('Null argument.'); + }); + + it('should throw an error if the email is invalid', () => { + const agencyId = faker.number.int(); + expect( + async () => + await userServices.addKeycloakUserOnHold(ssoUser, agencyId, '', '', 'email.com'), + ).rejects.toThrow('Invalid email.'); + }); }); describe('getAgencies', () => { it('should return an array of agency ids', async () => { const agencies = await userServices.getAgencies('test'); - expect(AppDataSource.getRepository(User).findOneOrFail).toHaveBeenCalledTimes(1); expect(AppDataSource.getRepository(Agency).find).toHaveBeenCalledTimes(1); expect(Array.isArray(agencies)).toBe(true); }); + + it('should return an empty array if the user is not found', async () => { + _usersFindOneBy.mockImplementationOnce(async () => null); + const agencies = await userServices.getAgencies('test'); + expect(Array.isArray(agencies)).toBe(true); + expect(agencies).toHaveLength(0); + }); + }); + + describe('hasAgencies', () => { + it('should return true if the user has the corresponding agencies', async () => { + _agenciesFind.mockImplementationOnce(async () => [produceAgency({ Id: 1 })]); + const result = await userServices.hasAgencies('test', [1]); + expect(result).toEqual(true); + }); + + it('should return false if the user does not have the corresponding agencies', async () => { + _agenciesFind.mockImplementationOnce(async () => [produceAgency({ Id: 2 })]); + const result = await userServices.hasAgencies('test', [1]); + expect(result).toEqual(false); + }); }); describe('normalizeKeycloakUser', () => { @@ -168,7 +223,15 @@ describe('UNIT - User services', () => { it('should throw an error if the user already exists', async () => { const user = produceUser(); _usersFindOne.mockResolvedValueOnce(user); - expect(async () => await userServices.addUser(user)).rejects.toThrow(); + expect(async () => await userServices.addUser(user)).rejects.toThrow( + 'Resource already exists.', + ); + }); + + it('should throw an error if the email is invalid', async () => { + const user = produceUser({ Email: 'blah' }); + _usersFindOne.mockResolvedValueOnce(null); + expect(async () => await userServices.addUser(user)).rejects.toThrow('Invalid email.'); }); }); describe('updateUser', () => { @@ -187,7 +250,14 @@ describe('UNIT - User services', () => { it('should throw an error if the user does not exist', () => { const user = produceUser(); _usersFindOne.mockResolvedValueOnce(undefined); - expect(async () => await userServices.updateUser(user)).rejects.toThrow(); + expect(async () => await userServices.updateUser(user)).rejects.toThrow( + 'Resource does not exist.', + ); + }); + + it('should throw an error if the email is invalid does not exist', () => { + const user = produceUser({ Email: 'blah' }); + expect(async () => await userServices.updateUser(user)).rejects.toThrow('Invalid email.'); }); }); describe('deleteUser', () => { diff --git a/express-api/tests/unit/utilities/helperFunctions.test.ts b/express-api/tests/unit/utilities/helperFunctions.test.ts index 7aba1a50a..d861c9964 100644 --- a/express-api/tests/unit/utilities/helperFunctions.test.ts +++ b/express-api/tests/unit/utilities/helperFunctions.test.ts @@ -5,6 +5,7 @@ import { ILikeWrapper, TimestampComparisonWrapper, toPostgresTimestamp, + validateEmail, } from '@/utilities/helperFunctions'; import { EqualOperator, FindOperator } from 'typeorm'; @@ -180,4 +181,16 @@ describe('UNIT - helperFunctions', () => { expect(result.test).toBeUndefined(); }); }); + + describe('validateEmail', () => { + it('should return true when a valid email is given', () => { + const result = validateEmail('test@gmail.com'); + expect(result).toEqual(true); + }); + + it('should return false when a invalid email is given', () => { + const result = validateEmail('test@gmaom'); + expect(result).toEqual(false); + }); + }); }); diff --git a/react-app/package.json b/react-app/package.json index d2e5a781a..0bd656d79 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -10,7 +10,7 @@ "lint:fix": "npm run lint -- --fix", "format": "prettier --write \"./src/**/*.{js,jsx,ts,tsx,json,css,scss}\"", "check": "prettier --check \"./src/**/*.{js,jsx,ts,tsx,css,scss}\"", - "test": "jest", + "test": "jest --passWithNoTests", "snapshots": "jest --updateSnapshot" }, "dependencies": { @@ -22,7 +22,7 @@ "@mdi/react": "1.6.1", "@mui/icons-material": "6.1.0", "@mui/lab": "5.0.0-alpha.170", - "@mui/material": "6.1.0", + "@mui/material": "6.1.2", "@mui/x-data-grid": "7.18.0", "@mui/x-date-pickers": "7.18.0", "@turf/turf": "7.1.0", @@ -37,7 +37,8 @@ "react-router-dom": "6.26.0", "supercluster": "8.0.1", "typescript-eslint": "8.7.0", - "use-supercluster": "1.2.0" + "use-supercluster": "1.2.0", + "zod": "3.23.8" }, "devDependencies": { "@babel/preset-env": "7.25.2", diff --git a/react-app/src/App.test.tsx b/react-app/src/App.test.tsx deleted file mode 100644 index 163365991..000000000 --- a/react-app/src/App.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// import { create } from 'react-test-renderer'; -// import { render, screen } from '@testing-library/react'; -// import App from './App'; -import React from 'react'; -import '@testing-library/jest-dom'; -// import { BrowserRouter } from 'react-router-dom'; - -// Mock child components -jest.mock('@/pages/Home', () => { - const Home = (props) => ( -
- Mocked Header -
- ); - return { default: Home }; -}); - -// FIXME: See note about moduleNameMapper in jest.config.ts -describe('App.tsx', () => { - it('FIXME', () => { - expect(true).toBeTruthy(); - }); - // it('should match the existing snapshot', () => { - // const tree = create( - // - // - // , - // ).toJSON(); - // expect(tree).toMatchSnapshot(); - // }); - - // it('should contain the header component', async () => { - // render( - // - // - // , - // ); - // expect(await screen.findByText('Mocked Header')).toBeInTheDocument(); - // }); -}); diff --git a/react-app/src/components/form/AutocompleteField.test.tsx b/react-app/src/components/form/AutocompleteField.test.tsx deleted file mode 100644 index a7f48cb9f..000000000 --- a/react-app/src/components/form/AutocompleteField.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import '@testing-library/jest-dom'; -import AutocompleteFormField from './AutocompleteFormField'; - -jest.mock('react-hook-form', () => ({ - ...jest.requireActual('react-hook-form'), - Controller: () => <>, - useForm: () => ({ - control: () => ({}), - handleSubmit: () => jest.fn(), - }), - useFormContext: () => ({ - control: () => ({}), - }), -})); - -describe('', () => { - it('should render', () => { - render( - , - ); - }); -}); diff --git a/react-app/src/components/layout/Footer.test.tsx b/react-app/src/components/layout/Footer.test.tsx deleted file mode 100644 index c09778ab8..000000000 --- a/react-app/src/components/layout/Footer.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { create } from 'react-test-renderer'; -import React from 'react'; -import Footer from '@/components/layout/Footer'; - -jest.mock('@mui/material', () => ({ - ...jest.requireActual('@mui/material'), - useTheme: () => ({ - palette: { - gray: { - main: '#FFFFFF', - }, - }, - }), -})); - -describe('Footer.tsx', () => { - it('should match the existing snapshot', () => { - const tree = create(