diff --git a/express-api/src/controllers/buildings/buildingsController.ts b/express-api/src/controllers/buildings/buildingsController.ts index 07b0e2398..d9db1a5e9 100644 --- a/express-api/src/controllers/buildings/buildingsController.ts +++ b/express-api/src/controllers/buildings/buildingsController.ts @@ -5,6 +5,7 @@ import userServices from '@/services/users/usersServices'; import { SSOUser } from '@bcgov/citz-imb-sso-express'; import { Building } from '@/typeorm/Entities/Building'; import { checkUserAgencyPermission, isAdmin, isAuditor } from '@/utilities/authorizationChecks'; +import { Roles } from '@/constants/roles'; /** * @description Gets all buildings satisfying the filter parameters. @@ -52,12 +53,14 @@ export const getBuilding = async (req: Request, res: Response) => { return res.status(400).send('Building Id is invalid.'); } + // admin and auditors are permitted to see any building + const permittedRoles = [Roles.ADMIN, Roles.AUDITOR]; const kcUser = req.user as unknown as SSOUser; const building = await buildingService.getBuildingById(buildingId); if (!building) { return res.status(404).send('Building matching this ID was not found.'); - } else if (!(await checkUserAgencyPermission(kcUser, [building.AgencyId]))) { + } else if (!(await checkUserAgencyPermission(kcUser, [building.AgencyId], permittedRoles))) { return res.status(403).send('You are not authorized to view this building.'); } return res.status(200).send(building); diff --git a/express-api/src/controllers/parcels/parcelsController.ts b/express-api/src/controllers/parcels/parcelsController.ts index 3d6e09488..cad83f3b0 100644 --- a/express-api/src/controllers/parcels/parcelsController.ts +++ b/express-api/src/controllers/parcels/parcelsController.ts @@ -4,6 +4,7 @@ import { ParcelFilter, ParcelFilterSchema } from '@/services/parcels/parcelSchem import { SSOUser } from '@bcgov/citz-imb-sso-express'; import userServices from '@/services/users/usersServices'; import { Parcel } from '@/typeorm/Entities/Parcel'; +import { Roles } from '@/constants/roles'; import { checkUserAgencyPermission, isAdmin, isAuditor } from '@/utilities/authorizationChecks'; /** @@ -25,11 +26,13 @@ export const getParcel = async (req: Request, res: Response) => { return res.status(400).send('Parcel ID was invalid.'); } + // admin and auditors are permitted to see any parcel + const permittedRoles = [Roles.ADMIN, Roles.AUDITOR]; const kcUser = req.user as unknown as SSOUser; const parcel = await parcelServices.getParcelById(parcelId); if (!parcel) { return res.status(404).send('Parcel matching this internal ID not found.'); - } else if (!(await checkUserAgencyPermission(kcUser, [parcel.AgencyId]))) { + } else if (!(await checkUserAgencyPermission(kcUser, [parcel.AgencyId], permittedRoles))) { return res.status(403).send('You are not authorized to view this parcel.'); } return res.status(200).send(parcel); diff --git a/express-api/src/controllers/projects/projectsController.ts b/express-api/src/controllers/projects/projectsController.ts index 638aeac17..54c3825a8 100644 --- a/express-api/src/controllers/projects/projectsController.ts +++ b/express-api/src/controllers/projects/projectsController.ts @@ -6,6 +6,7 @@ import userServices from '@/services/users/usersServices'; import { isAdmin, isAuditor, checkUserAgencyPermission } from '@/utilities/authorizationChecks'; import { DeepPartial } from 'typeorm'; import { Project } from '@/typeorm/Entities/Project'; +import { Roles } from '@/constants/roles'; import notificationServices from '@/services/notifications/notificationServices'; /** @@ -22,6 +23,8 @@ export const getDisposalProject = async (req: Request, res: Response) => { * "bearerAuth" : [] * }] */ + // admins are permitted to view any project + const permittedRoles = [Roles.ADMIN]; const user = req.user as SSOUser; const projectId = Number(req.params.projectId); if (isNaN(projectId)) { @@ -32,7 +35,7 @@ export const getDisposalProject = async (req: Request, res: Response) => { return res.status(404).send('Project matching this internal ID not found.'); } - if (!(await checkUserAgencyPermission(user, [project.AgencyId]))) { + if (!(await checkUserAgencyPermission(user, [project.AgencyId], permittedRoles))) { return res.status(403).send('You are not authorized to view this project.'); } @@ -98,6 +101,11 @@ export const deleteDisposalProject = async (req: Request, res: Response) => { * "bearerAuth" : [] * }] */ + // Only admins can delete projects + if (!isAdmin(req.user)) { + return res.status(403).send('Projects can only be deleted by Administrator role.'); + } + const projectId = Number(req.params.projectId); if (isNaN(projectId)) { return res.status(400).send('Invalid Project ID'); @@ -123,6 +131,10 @@ export const deleteDisposalProject = async (req: Request, res: Response) => { * @returns {Response} A 200 status with the new project. */ export const addDisposalProject = async (req: Request, res: Response) => { + // Auditors can no add projects + if (isAuditor(req.user)) { + return res.status(403).send('Projects can not be added by user with Auditor role.'); + } // Extract project data from request body // Extract projectData and propertyIds from the request body const { @@ -157,7 +169,7 @@ export const getProjects = async (req: Request, res: Response) => { } const filterResult = filter.data; const kcUser = req.user as unknown as SSOUser; - if (!(isAdmin(kcUser) || isAuditor(kcUser))) { + if (!isAdmin(kcUser)) { // get array of user's agencies const usersAgencies = await userServices.getAgencies(kcUser.preferred_username); filterResult.agencyId = usersAgencies; diff --git a/express-api/src/controllers/properties/propertiesController.ts b/express-api/src/controllers/properties/propertiesController.ts index 8065434bf..9b3e00170 100644 --- a/express-api/src/controllers/properties/propertiesController.ts +++ b/express-api/src/controllers/properties/propertiesController.ts @@ -16,6 +16,7 @@ import { AppDataSource } from '@/appDataSource'; import { ImportResult } from '@/typeorm/Entities/ImportResult'; import { readFile } from 'xlsx'; import logger from '@/utilities/winstonLogger'; +import { Roles } from '@/constants/roles'; /** * @description Search for a single keyword across multiple different fields in both parcels and buildings. @@ -81,10 +82,16 @@ export const getPropertiesForMap = async (req: Request, res: Response) => { // Controlling for agency search visibility const kcUser = req.user; + // admin and suditors can see any property + const permittedRoles = [Roles.ADMIN, Roles.AUDITOR]; // Admins and auditors see all, otherwise... if (!(isAdmin(kcUser) || isAuditor(kcUser))) { const requestedAgencies = filterResult.AgencyIds; - const userHasAgencies = await checkUserAgencyPermission(kcUser, requestedAgencies); + const userHasAgencies = await checkUserAgencyPermission( + kcUser, + requestedAgencies, + permittedRoles, + ); // If not agencies were requested or if the user doesn't have those requested agencies if (!requestedAgencies || !userHasAgencies) { // Then only show that user's agencies instead. diff --git a/express-api/src/controllers/users/usersController.ts b/express-api/src/controllers/users/usersController.ts index dbd079e2e..6eb1bc993 100644 --- a/express-api/src/controllers/users/usersController.ts +++ b/express-api/src/controllers/users/usersController.ts @@ -4,7 +4,7 @@ import { SSOUser } from '@bcgov/citz-imb-sso-express'; import { decodeJWT } from '@/utilities/decodeJWT'; import { UserFiltering, UserFilteringSchema } from '@/controllers/users/usersSchema'; import { z } from 'zod'; -import { isAdmin, isAuditor } from '@/utilities/authorizationChecks'; +import { isAdmin } from '@/utilities/authorizationChecks'; import notificationServices from '@/services/notifications/notificationServices'; import getConfig from '@/constants/config'; import logger from '@/utilities/winstonLogger'; @@ -23,7 +23,7 @@ const filterUsersByAgencies = async (req: Request, res: Response, ssoUser: SSOUs const filterResult = filter.data; let users; - if (isAdmin(ssoUser) || isAuditor(ssoUser)) { + if (isAdmin(ssoUser)) { users = await userServices.getUsers(filterResult as UserFiltering); } else { // Get agencies associated with the requesting user @@ -204,7 +204,7 @@ export const getUserById = async (req: Request, res: Response) => { const user = await userServices.getUserById(uuid.data); if (user) { - if (!isAdmin(ssoUser) && !isAuditor(ssoUser)) { + if (!isAdmin(ssoUser)) { // check if user has the correct agencies const usersAgencies = await userServices.hasAgencies(ssoUser.preferred_username, [ user.AgencyId, diff --git a/express-api/src/services/properties/propertiesServices.ts b/express-api/src/services/properties/propertiesServices.ts index 54df2aaf9..dee5408e3 100644 --- a/express-api/src/services/properties/propertiesServices.ts +++ b/express-api/src/services/properties/propertiesServices.ts @@ -32,6 +32,7 @@ import userServices from '../users/usersServices'; import { Brackets, FindManyOptions, FindOptionsWhere, ILike, In, QueryRunner } from 'typeorm'; import { SSOUser } from '@bcgov/citz-imb-sso-express'; import { PropertyType } from '@/constants/propertyType'; +import { parentPort } from 'worker_threads'; /** * Perform a fuzzy search for properties based on the provided keyword. @@ -165,7 +166,7 @@ const numberOrNull = (value: any) => { * @returns The agency if the user has permission, otherwise throws an error. * @throws Error if the agency code is not supported or if the user does not have permission to add properties for the agency. */ -const getAgencyOrThrowIfMismatched = ( +export const getAgencyOrThrowIfMismatched = ( row: Record, lookups: Lookups, roles: string[], @@ -189,7 +190,7 @@ const getAgencyOrThrowIfMismatched = ( * @param {PropertyClassification[]} classifications - The list of property classifications to search from. * @returns {number} The classification ID. */ -const getClassificationOrThrow = ( +export const getClassificationOrThrow = ( row: Record, classifications: PropertyClassification[], ) => { @@ -213,7 +214,7 @@ const getClassificationOrThrow = ( * @param adminAreas - The array of AdministrativeArea objects to search for a match. * @returns The ID of the administrative area if found, otherwise throws an error. */ -const getAdministrativeAreaOrThrow = ( +export const getAdministrativeAreaOrThrow = ( row: Record, adminAreas: AdministrativeArea[], ) => { @@ -237,7 +238,7 @@ const getAdministrativeAreaOrThrow = ( * @param predominateUses - The list of available building predominate uses. * @returns The ID of the predominate use if found, otherwise throws an error. */ -const getBuildingPredominateUseOrThrow = ( +export const getBuildingPredominateUseOrThrow = ( row: Record, predominateUses: BuildingPredominateUse[], ) => { @@ -263,7 +264,7 @@ const getBuildingPredominateUseOrThrow = ( * @returns The ID of the matched building construction type. * @throws Error if the construction type cannot be determined from the provided data. */ -const getBuildingConstructionTypeOrThrow = ( +export const getBuildingConstructionTypeOrThrow = ( row: Record, constructionTypes: BuildingConstructionType[], ) => { @@ -453,7 +454,7 @@ const makeBuildingUpsertObject = async ( }; }; -type Lookups = { +export type Lookups = { classifications: PropertyClassification[]; constructionTypes: BuildingConstructionType[]; predominateUses: BuildingPredominateUse[]; @@ -752,6 +753,42 @@ const getPropertiesForExport = async (filter: PropertyUnionFilter) => { return properties; }; +/** + * Asynchronously processes a file for property import, initializing a new database connection for the worker thread. + * Reads the file content, imports properties as JSON, and saves the results to the database. + * Handles exceptions and ensures database connection cleanup after processing. + * @param filePath The path to the file to be processed. + * @param resultRowId The ID of the result row in the database. + * @param user The user initiating the import. + * @param roles The roles assigned to the user. + * @returns A list of bulk upload row results after processing the file. + */ +const processFile = async (filePath: string, resultRowId: number, user: User, roles: string[]) => { + await AppDataSource.initialize(); //Since this function is going to be called from a new process, requires a new database connection. + let results: BulkUploadRowResult[] = []; + try { + parentPort.postMessage('Database connection for worker thread has been initialized'); + const file = xlsx.readFile(filePath); //It's better to do the read here rather than the parent process because any arguments passed to this function are copied rather than referenced. + const sheetName = file.SheetNames[0]; + const worksheet = file.Sheets[sheetName]; + + results = await propertyServices.importPropertiesAsJSON(worksheet, user, roles, resultRowId); + return results; // Note that this return still works with finally as long as return is not called from finally block. + } catch (e) { + parentPort.postMessage('Aborting file upload: ' + e.message); + parentPort.postMessage('Aborting stack: ' + e.stack); + } finally { + await AppDataSource.getRepository(ImportResult).save({ + Id: resultRowId, + CompletionPercentage: 1.0, + Results: results, + UpdatedById: user.Id, + UpdatedOn: new Date(), + }); + await AppDataSource.destroy(); //Not sure whether this is necessary but seems like the safe thing to do. + } +}; + const propertyServices = { propertiesFuzzySearch, getPropertiesForMap, @@ -759,6 +796,7 @@ const propertyServices = { getPropertiesUnion, getImportResults, getPropertiesForExport, + processFile, }; export default propertyServices; diff --git a/express-api/src/services/properties/propertyWorker.ts b/express-api/src/services/properties/propertyWorker.ts index a6bb39f5b..f621f2342 100644 --- a/express-api/src/services/properties/propertyWorker.ts +++ b/express-api/src/services/properties/propertyWorker.ts @@ -1,47 +1,8 @@ import { parentPort, workerData } from 'worker_threads'; -import propertyServices, { BulkUploadRowResult } from './propertiesServices'; -import xlsx from 'xlsx'; -import { AppDataSource } from '@/appDataSource'; -import { ImportResult } from '@/typeorm/Entities/ImportResult'; -import { User } from '@/typeorm/Entities/User'; +import propertyServices from './propertiesServices'; -/** - * Asynchronously processes a file for property import, initializing a new database connection for the worker thread. - * Reads the file content, imports properties as JSON, and saves the results to the database. - * Handles exceptions and ensures database connection cleanup after processing. - * @param filePath The path to the file to be processed. - * @param resultRowId The ID of the result row in the database. - * @param user The user initiating the import. - * @param roles The roles assigned to the user. - * @returns A list of bulk upload row results after processing the file. - */ -const processFile = async (filePath: string, resultRowId: number, user: User, roles: string[]) => { - await AppDataSource.initialize(); //Since this function is going to be called from a new process, requires a new database connection. - let results: BulkUploadRowResult[] = []; - try { - parentPort.postMessage('Database connection for worker thread has been initialized'); - const file = xlsx.readFile(filePath); //It's better to do the read here rather than the parent process because any arguments passed to this function are copied rather than referenced. - const sheetName = file.SheetNames[0]; - const worksheet = file.Sheets[sheetName]; - - results = await propertyServices.importPropertiesAsJSON(worksheet, user, roles, resultRowId); - - return results; // Note that this return still works with finally as long as return is not called from finally block. - } catch (e) { - parentPort.postMessage('Aborting file upload: ' + e.message); - } finally { - await AppDataSource.getRepository(ImportResult).save({ - Id: resultRowId, - CompletionPercentage: 1.0, - Results: results, - UpdatedById: user.Id, - UpdatedOn: new Date(), - }); - await AppDataSource.destroy(); //Not sure whether this is necessary but seems like the safe thing to do. - } -}; - -processFile(workerData.filePath, workerData.resultRowId, workerData.user, workerData.roles) +propertyServices + .processFile(workerData.filePath, workerData.resultRowId, workerData.user, workerData.roles) .then((results) => { parentPort.postMessage('File processing succeeded.'); parentPort.postMessage( diff --git a/express-api/src/utilities/authorizationChecks.ts b/express-api/src/utilities/authorizationChecks.ts index a09aada54..e6d26533c 100644 --- a/express-api/src/utilities/authorizationChecks.ts +++ b/express-api/src/utilities/authorizationChecks.ts @@ -67,16 +67,19 @@ export const isUserActive = async (kcUser: SSOUser): Promise => { export const checkUserAgencyPermission = async ( kcUser: SSOUser, agencyIds: number[], + permittedRoles: Roles[], ): Promise => { // Check if undefined, has length of 0, or if the only element is undefined if (!agencyIds || agencyIds.length === 0 || !agencyIds.at(0)) { return false; } - if (!isAdmin(kcUser) && !isAuditor(kcUser)) { + const userRolePermission = kcUser?.hasRoles(permittedRoles, { requireAllRoles: false }); + // if the user is not an admin, nor has a permitted role scope results + if (!isAdmin(kcUser) && !userRolePermission) { // check if current user belongs to any of the specified agencies const userAgencies = await userServices.hasAgencies(kcUser.preferred_username, agencyIds); return userAgencies; } - // Admins and auditors have permission by default + // Admins have permission by default return true; }; diff --git a/express-api/tests/testUtils/factories.ts b/express-api/tests/testUtils/factories.ts index 9ed933146..17d2b5e12 100644 --- a/express-api/tests/testUtils/factories.ts +++ b/express-api/tests/testUtils/factories.ts @@ -428,7 +428,7 @@ export const produceAdminArea = (props?: Partial): Administr }; export const produceClassification = ( - props: Partial, + props?: Partial, ): PropertyClassification => { const classification: PropertyClassification = { Id: faker.number.int(), diff --git a/express-api/tests/unit/controllers/buildings/buildingsController.test.ts b/express-api/tests/unit/controllers/buildings/buildingsController.test.ts index 4ae109ba0..737171e96 100644 --- a/express-api/tests/unit/controllers/buildings/buildingsController.test.ts +++ b/express-api/tests/unit/controllers/buildings/buildingsController.test.ts @@ -50,6 +50,7 @@ describe('UNIT - Buildings', () => { }; mockRequest.params.buildingId = '1'; _hasAgencies.mockImplementationOnce(() => true); + mockRequest.setUser({ hasRoles: () => true }); _getBuildingById.mockImplementationOnce(() => buildingWithAgencyId1); await controllers.getBuilding(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(200); @@ -69,12 +70,9 @@ describe('UNIT - Buildings', () => { }); it('should return 403 when user does not have correct agencies', async () => { - const buildingWithAgencyId1 = { - AgencyId: 1, - }; mockRequest.params.buildingId = '1'; + mockRequest.setUser({ client_roles: [Roles.GENERAL_USER], hasRoles: () => false }); _hasAgencies.mockImplementationOnce(() => false); - _getBuildingById.mockImplementationOnce(() => buildingWithAgencyId1); await controllers.getBuilding(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(403); }); diff --git a/express-api/tests/unit/controllers/parcels/parcelsController.test.ts b/express-api/tests/unit/controllers/parcels/parcelsController.test.ts index 371b672ca..471f93379 100644 --- a/express-api/tests/unit/controllers/parcels/parcelsController.test.ts +++ b/express-api/tests/unit/controllers/parcels/parcelsController.test.ts @@ -45,7 +45,8 @@ describe('UNIT - Parcels', () => { describe('GET /properties/parcels/:parcelId', () => { it('should return 200 with a correct response body', async () => { mockRequest.params.parcelId = '1'; - _hasAgencies.mockImplementationOnce(() => true); + // _hasAgencies.mockImplementationOnce(() => true); + mockRequest.setUser({ client_roles: [Roles.ADMIN] }); await controllers.getParcel(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(200); }); @@ -72,7 +73,7 @@ describe('UNIT - Parcels', () => { }); it('should return with status 403 when user doenst have permission to view parcel', async () => { mockRequest.params.parcelId = '1'; - mockRequest.setUser({ client_roles: [Roles.GENERAL_USER] }); + mockRequest.setUser({ client_roles: [Roles.GENERAL_USER], hasRoles: () => false }); _hasAgencies.mockImplementationOnce(() => false); await controllers.getParcel(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(403); diff --git a/express-api/tests/unit/controllers/projects/projectsController.test.ts b/express-api/tests/unit/controllers/projects/projectsController.test.ts index e0c846da0..29d6ee313 100644 --- a/express-api/tests/unit/controllers/projects/projectsController.test.ts +++ b/express-api/tests/unit/controllers/projects/projectsController.test.ts @@ -233,7 +233,7 @@ describe('UNIT - Testing controllers for users routes.', () => { }); it('should return status 403 when user does not have correct agencies', async () => { mockRequest.params.projectId = '1'; - mockRequest.setUser({ client_roles: [Roles.GENERAL_USER] }); + mockRequest.setUser({ client_roles: [Roles.GENERAL_USER], hasRoles: () => false }); _hasAgencies.mockImplementationOnce(() => false); await controllers.getDisposalProject(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(403); @@ -312,13 +312,14 @@ describe('UNIT - Testing controllers for users routes.', () => { describe('DELETE /projects/disposal/:projectId', () => { it('should return status 200 on successful deletion', async () => { mockRequest.params.projectId = '1'; - mockRequest.setUser({ client_roles: [Roles.ADMIN] }); + mockRequest.setUser({ client_roles: [Roles.ADMIN], hasRoles: () => true }); await controllers.deleteDisposalProject(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(200); }); it('should return status 404 on no resource', async () => { mockRequest.params.projectId = 'abc'; + mockRequest.setUser({ client_roles: [Roles.ADMIN], hasRoles: () => true }); await controllers.deleteDisposalProject(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(400); }); diff --git a/express-api/tests/unit/services/properties/propertyServices.test.ts b/express-api/tests/unit/services/properties/propertyServices.test.ts index a1e6b3555..46f27d311 100644 --- a/express-api/tests/unit/services/properties/propertyServices.test.ts +++ b/express-api/tests/unit/services/properties/propertyServices.test.ts @@ -1,6 +1,10 @@ import { AppDataSource } from '@/appDataSource'; import { Roles } from '@/constants/roles'; -import propertyServices from '@/services/properties/propertiesServices'; +import propertyServices, { + getAgencyOrThrowIfMismatched, + getClassificationOrThrow, + Lookups, +} from '@/services/properties/propertiesServices'; import userServices from '@/services/users/usersServices'; import { AdministrativeArea } from '@/typeorm/Entities/AdministrativeArea'; import { Agency } from '@/typeorm/Entities/Agency'; @@ -102,11 +106,12 @@ jest .spyOn(AppDataSource.getRepository(PropertyUnion), 'createQueryBuilder') .mockImplementation(() => _propertyUnionCreateQueryBuilder); -const _findParcel = jest.fn().mockImplementation(async () => produceParcel); -const _findBuilding = jest.fn().mockImplementation(async () => produceParcel); - -jest.spyOn(AppDataSource.getRepository(Parcel), 'find').mockImplementation(() => _findParcel()); -jest.spyOn(AppDataSource.getRepository(Building), 'find').mockImplementation(() => _findBuilding()); +jest + .spyOn(AppDataSource.getRepository(Parcel), 'find') + .mockImplementation(async () => [produceParcel()]); +jest + .spyOn(AppDataSource.getRepository(Building), 'find') + .mockImplementation(async () => [produceBuilding()]); jest .spyOn(AppDataSource.getRepository(Parcel), 'save') .mockImplementation(async () => produceParcel()); @@ -147,9 +152,9 @@ const _mockCommitTransaction = jest.fn(async () => {}); const _mockEntityManager = { find: async (entityClass: EntityTarget) => { if (entityClass === Parcel) { - return _findParcel(); + return produceParcel(); } else if (entityClass === Building) { - return _findBuilding(); + return produceBuilding(); } else if (entityClass === ParcelEvaluation) { return produceParcelEvaluation(1, { Year: 2023 }); } else if (entityClass === ParcelFiscal) { @@ -246,6 +251,35 @@ describe('UNIT - Property Services', () => { }); }); + describe('getPropertiesforExport', () => { + it('should get a list of properties based on the filter', async () => { + const result = await propertyServices.getPropertiesForExport({ + pid: 'contains,123', + pin: 'contains,456', + administrativeArea: 'contains,aaa', + agency: 'startsWith,aaa', + propertyType: 'contains,Building', + sortKey: 'Agency', + sortOrder: 'DESC', + landArea: 'startsWith,1', + address: 'contains,742 Evergreen Terr.', + classification: 'contains,core', + agencyIds: [1], + quantity: 2, + page: 1, + updatedOn: 'after,' + new Date(), + quickFilter: 'contains,someWord', + }); + expect(Array.isArray(result)).toBe(true); + expect(result.at(0)).toHaveProperty('Id'); + expect(result.at(0)).toHaveProperty('PIN'); + expect(result.at(0)).toHaveProperty('PID'); + expect(result.at(0)).toHaveProperty('Agency'); + expect(result.at(0)).toHaveProperty('Classification'); + expect(result.at(0)).toHaveProperty('AdministrativeArea'); + }); + }); + describe('getPropertiesForMap', () => { it('should return a list of map property objects', async () => { const result = await propertyServices.getPropertiesForMap({ @@ -355,4 +389,88 @@ describe('UNIT - Property Services', () => { expect(Array.isArray(result)).toBe(true); }); }); + + describe('getClassificationOrThrow', () => { + it('should return a classification if found', () => { + const result = getClassificationOrThrow( + { + Status: 'Active', + Classification: 'Surplus', + }, + [produceClassification({ Name: 'Surplus', Id: 1 })], + ); + expect(result).toEqual(1); + }); + + it('should throw an error if that classification is not active or disposed', () => { + expect(() => + getClassificationOrThrow( + { + Status: 'Disabled', + Classification: 'Surplus', + }, + [produceClassification({ Name: 'Surplus', Id: 1 })], + ), + ).toThrow(); + }); + + it('should throw an error if there is no classification with a matching name', () => { + expect(() => + getClassificationOrThrow( + { + Status: 'Active', + Classification: 'Not Surplus', + }, + [produceClassification({ Name: 'Surplus', Id: 1 })], + ), + ).toThrow(); + }); + }); + + describe('getAgencyOrThrowIfMismatched', () => { + it('should return an agency in if it exists', () => { + const agency = produceAgency({ Code: 'WLRS' }); + const result = getAgencyOrThrowIfMismatched( + { + AgencyCode: 'WLRS', + }, + { + agencies: [agency], + } as Lookups, + [Roles.ADMIN], + ); + expect(result.Code).toBe(agency.Code); + }); + + it('should throw an error if agency is not found', () => { + const agency = produceAgency({ Code: 'WLRS' }); + expect(() => + getAgencyOrThrowIfMismatched( + { + AgencyCode: 'TEST', + }, + { + agencies: [agency], + } as Lookups, + [Roles.ADMIN], + ), + ).toThrow(); + }); + + it('should throw an error if the user does not have permissions', () => { + const agency = produceAgency({ Code: 'WLRS', Id: 1 }); + expect(() => + getAgencyOrThrowIfMismatched( + { + AgencyCode: 'WLRS', + }, + { + agencies: [{ ...agency, Id: 999 }], + userAgencies: [], + } as Lookups, + [], + ), + ).toThrow(); + }); + }); }); diff --git a/express-api/tests/unit/services/properties/propertyWorker.test.ts b/express-api/tests/unit/services/properties/propertyWorker.test.ts new file mode 100644 index 000000000..194b1c925 --- /dev/null +++ b/express-api/tests/unit/services/properties/propertyWorker.test.ts @@ -0,0 +1,65 @@ +import { Roles } from '@/constants/roles'; +import propertyServices, { BulkUploadRowResult } from '@/services/properties/propertiesServices'; +import { produceUser } from 'tests/testUtils/factories'; +import xlsx from 'xlsx'; + +// Mock worker items +//const _parentPortSpy = jest.fn().mockImplementation(() => {}); +const workerDataObj = { + filePath: 'testPath', + resultRowId: 1, + user: produceUser(), + roles: [Roles.ADMIN], +}; +jest.mock('worker_threads', () => ({ + parentPort: { + postMessage: () => {}, + }, + workerData: () => workerDataObj, +})); +// Spy on xlsx readFile +const _readFileSpy = jest.spyOn(xlsx, 'readFile').mockImplementation(() => ({ + Sheets: { + sheet1: {}, + }, + SheetNames: ['sheet1'], +})); +// Mock appdatasource +const _saveSpy = jest.fn().mockImplementation(async () => {}); +jest.mock('@/appDataSource', () => ({ + AppDataSource: { + initialize: async () => {}, + getRepository: () => ({ + save: () => _saveSpy, + }), + destroy: async () => {}, + }, +})); +// Spy on property services +const uploadResult: BulkUploadRowResult = { + rowNumber: 0, + action: 'inserted', +}; +const _propServicesSpy = jest + .spyOn(propertyServices, 'importPropertiesAsJSON') + .mockImplementation(async () => [uploadResult]); + +describe('UNIT - propertyWorker.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return a set of results indicating the upload process has started', async () => { + const result = await propertyServices.processFile('filePath', 0, produceUser(), [Roles.ADMIN]); + expect(result.at(0).action).toBe('inserted'); + expect(_readFileSpy).toHaveBeenCalled(); + }); + + it('should follow the catch route if an error is thrown', async () => { + _readFileSpy.mockImplementationOnce(() => { + throw new Error(); + }); + await propertyServices.processFile('filePath', 0, produceUser(), [Roles.ADMIN]); + expect(_readFileSpy).toHaveBeenCalled(); + expect(_propServicesSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/react-app/src/components/projects/ProjectDetail.tsx b/react-app/src/components/projects/ProjectDetail.tsx index 8c526977c..c000971b2 100644 --- a/react-app/src/components/projects/ProjectDetail.tsx +++ b/react-app/src/components/projects/ProjectDetail.tsx @@ -255,10 +255,12 @@ const ProjectDetail = (props: IProjectDetail) => { { title: disposalProperties }, { title: financialInformation }, { title: documentationHistory }, - { title: notificationsHeader }, ]; - // only show Agency Interest for admin or auditor - isAdmin || isAuditor ? sideBarList.splice(3, 0, { title: agencyInterest }) : null; + // only show Agency Interest and notifications for admins + if (isAdmin) { + sideBarList.splice(3, 0, { title: agencyInterest }); + sideBarList.push({ title: notificationsHeader }); + } return ( @@ -323,7 +325,7 @@ const ProjectDetail = (props: IProjectDetail) => { onEdit={() => setOpenFinancialInfoDialog(true)} disableEdit={!isAdmin} /> - {(isAdmin || isAuditor) && ( + {isAdmin && ( { )} - setOpenNotificationDialog(true)} - disableEdit={!data?.parsedBody?.Notifications?.length} - editButtonText="Expand Notifications" - > - {!data?.parsedBody.Notifications?.length ? ( //TODO: Logic will depend on precense of agency responses - - No notifications were sent for this project. - - ) : ( - ({ - agency: lookup.getLookupValueById('Agencies', resp.ToAgencyId)?.Name, - id: resp.Id, - projectNumber: data?.parsedBody.ProjectNumber, - status: getStatusString(resp.Status), - sendOn: resp.SendOn, - to: resp.To, - subject: resp.Subject, - })) - : [] - } - /> - )} - + {isAdmin && ( + setOpenNotificationDialog(true)} + disableEdit={!data?.parsedBody?.Notifications?.length} + editButtonText="Expand Notifications" + > + {!data?.parsedBody.Notifications?.length ? ( //TODO: Logic will depend on precense of agency responses + + No notifications were sent for this project. + + ) : ( + ({ + agency: lookup.getLookupValueById('Agencies', resp.ToAgencyId)?.Name, + id: resp.Id, + projectNumber: data?.parsedBody.ProjectNumber, + status: getStatusString(resp.Status), + sendOn: resp.SendOn, + to: resp.To, + subject: resp.Subject, + })) + : [] + } + /> + )} + + )} ; @@ -488,6 +491,9 @@ export const FilterSearchDataGrid = (props: FilterSearchDataGridProps) => { : `(${props.rowCountProp ?? 0} rows)`; }, [props.tableOperationMode, rowCount, props.rowCountProp]); + const { keycloak } = useContext(AuthContext); + const isAuditor = keycloak.hasRoles([Roles.AUDITOR]); + return ( <> { /> - - - + {!isAuditor && ( + + + + )}