From 66f358e23a8c97eac252ca39963a7c27ccad7af5 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 11:34:10 -0700 Subject: [PATCH 01/14] Refactor user management and implement user versioning. Removed legacy Redux-based user management in favor of a centralized user mechanism using `userSlice`. Added database and API support for user versioning with history tracking to enable auditability. Updated related frontend components and tests to align with the new structure. --- .../V2024.12.10.15.15__create_users_table.sql | 26 ++++ ....22.32__add_user_version_history_table.sql | 24 +++ ...dd_user_version_history_table_backfill.sql | 6 + services/common/src/constants/API.ts | 3 + .../redux/actionCreators/userActionCreator.js | 20 --- .../common/src/redux/actions/userActions.js | 8 - services/common/src/redux/reducers.js | 2 - .../src/redux/reducers/rootReducerShared.ts | 21 ++- .../common/src/redux/reducers/userReducer.js | 26 ---- .../src/redux/selectors/userSelectors.js | 22 --- .../common/src/redux/slices/userSlice.spec.ts | 90 ++++++++++++ services/common/src/redux/slices/userSlice.ts | 61 ++++++++ .../core-api/app/api/users/models/user.py | 79 ++++++++++ services/core-api/app/api/users/namespace.py | 4 +- .../core-api/app/api/users/resources/user.py | 25 ---- .../app/api/users/resources/user_resource.py | 43 ++++++ .../core-api/app/api/users/response_models.py | 11 ++ .../app/api/utils/include/user_info.py | 10 +- services/core-api/tests/factories.py | 15 ++ .../core-api/tests/users/models/test_user.py | 86 +++++++++++ .../resources/test_user_profile_resource.py | 37 +++++ services/core-web/src/components/Home.js | 138 ------------------ services/core-web/src/components/Home.tsx | 110 ++++++++++++++ .../actionCreators/userActionCreator.spec.js | 43 ------ .../src/tests/reducers/userReducer.spec.js | 25 ---- 25 files changed, 619 insertions(+), 316 deletions(-) create mode 100644 migrations/sql/V2024.12.10.15.15__create_users_table.sql create mode 100644 migrations/sql/V2024.12.11.22.32__add_user_version_history_table.sql create mode 100644 migrations/sql/V2024.12.11.22.33__add_user_version_history_table_backfill.sql delete mode 100644 services/common/src/redux/actionCreators/userActionCreator.js delete mode 100644 services/common/src/redux/actions/userActions.js delete mode 100644 services/common/src/redux/reducers/userReducer.js delete mode 100644 services/common/src/redux/selectors/userSelectors.js create mode 100644 services/common/src/redux/slices/userSlice.spec.ts create mode 100644 services/common/src/redux/slices/userSlice.ts create mode 100644 services/core-api/app/api/users/models/user.py delete mode 100644 services/core-api/app/api/users/resources/user.py create mode 100644 services/core-api/app/api/users/resources/user_resource.py create mode 100644 services/core-api/tests/users/models/test_user.py create mode 100644 services/core-api/tests/users/resources/test_user_profile_resource.py delete mode 100644 services/core-web/src/components/Home.js create mode 100644 services/core-web/src/components/Home.tsx delete mode 100644 services/core-web/src/tests/actionCreators/userActionCreator.spec.js delete mode 100644 services/core-web/src/tests/reducers/userReducer.spec.js diff --git a/migrations/sql/V2024.12.10.15.15__create_users_table.sql b/migrations/sql/V2024.12.10.15.15__create_users_table.sql new file mode 100644 index 0000000000..4c3dec709d --- /dev/null +++ b/migrations/sql/V2024.12.10.15.15__create_users_table.sql @@ -0,0 +1,26 @@ +CREATE TABLE "user" +( + sub VARCHAR PRIMARY KEY, + email VARCHAR NOT NULL, + given_name VARCHAR NOT NULL, + family_name VARCHAR NOT NULL, + display_name VARCHAR NOT NULL, + idir_username VARCHAR NOT NULL, + identity_provider VARCHAR NOT NULL, + idir_user_guid VARCHAR NOT NULL, + last_logged_in TIMESTAMPTZ, + create_user VARCHAR(255) NOT NULL, + create_timestamp timestamp with time zone DEFAULT now() NOT NULL, + update_user VARCHAR(255) NOT NULL, + update_timestamp timestamp with time zone DEFAULT now() NOT NULL, + deleted_ind BOOLEAN DEFAULT false +); + +ALTER TABLE "user" + OWNER TO mds; + +-- +-- Name: TABLE user; Type: COMMENT; Schema: public; Owner: mds +-- + +COMMENT ON TABLE "user" IS 'User Profile data sourced from keycloak'; \ No newline at end of file diff --git a/migrations/sql/V2024.12.11.22.32__add_user_version_history_table.sql b/migrations/sql/V2024.12.11.22.32__add_user_version_history_table.sql new file mode 100644 index 0000000000..1c0f870edf --- /dev/null +++ b/migrations/sql/V2024.12.11.22.32__add_user_version_history_table.sql @@ -0,0 +1,24 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the corresponding history table definition for the {table} table +CREATE TABLE user_version ( + create_user VARCHAR(60), + create_timestamp TIMESTAMP WITHOUT TIME ZONE, + update_user VARCHAR(60), + update_timestamp TIMESTAMP WITHOUT TIME ZONE, + deleted_ind BOOLEAN default FALSE, + sub VARCHAR NOT NULL, + email VARCHAR, + given_name VARCHAR, + family_name VARCHAR, + display_name VARCHAR, + idir_username VARCHAR, + identity_provider VARCHAR, + idir_user_guid VARCHAR, + transaction_id BIGINT NOT NULL, + end_transaction_id BIGINT, + operation_type SMALLINT NOT NULL, + PRIMARY KEY (sub, transaction_id) +); +CREATE INDEX ix_user_version_operation_type ON user_version (operation_type); +CREATE INDEX ix_user_version_end_transaction_id ON user_version (end_transaction_id); +CREATE INDEX ix_user_version_transaction_id ON user_version (transaction_id); diff --git a/migrations/sql/V2024.12.11.22.33__add_user_version_history_table_backfill.sql b/migrations/sql/V2024.12.11.22.33__add_user_version_history_table_backfill.sql new file mode 100644 index 0000000000..7641a3298f --- /dev/null +++ b/migrations/sql/V2024.12.11.22.33__add_user_version_history_table_backfill.sql @@ -0,0 +1,6 @@ +-- This file was generated by the generate_history_table_ddl command +-- The file contains the data migration to backfill history records for the {table} table +with transaction AS (insert into transaction(id) values(DEFAULT) RETURNING id) +insert into user_version (transaction_id, operation_type, end_transaction_id, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "sub", "email", "given_name", "family_name", "display_name", "idir_username", "identity_provider", "idir_user_guid") +select t.id, '0', null, "create_user", "create_timestamp", "update_user", "update_timestamp", "deleted_ind", "sub", "email", "given_name", "family_name", "display_name", "idir_username", "identity_provider", "idir_user_guid" +from "user",transaction t; diff --git a/services/common/src/constants/API.ts b/services/common/src/constants/API.ts index ac24fbbb56..687f524945 100644 --- a/services/common/src/constants/API.ts +++ b/services/common/src/constants/API.ts @@ -387,3 +387,6 @@ export const REGIONS_LIST = "/regions"; // App Help export const APP_HELP = (helpKey: string, params?: { system?: string; help_guid?: string }) => `/help/${helpKey}?${queryString.stringify(params)}`; + +// User +export const USER_PROFILE = () => "/users/profile"; diff --git a/services/common/src/redux/actionCreators/userActionCreator.js b/services/common/src/redux/actionCreators/userActionCreator.js deleted file mode 100644 index fec0c5348f..0000000000 --- a/services/common/src/redux/actionCreators/userActionCreator.js +++ /dev/null @@ -1,20 +0,0 @@ -import { ENVIRONMENT } from "@mds/common/constants/environment"; -import { request, success, error } from "../actions/genericActions"; -import { NetworkReducerTypes } from "@mds/common/constants/networkReducerTypes"; -import * as userActions from "../actions/userActions"; -import * as API from "@mds/common/constants/API"; -import { createRequestHeader } from "../utils/RequestHeaders"; -import CustomAxios from "../customAxios"; - -// This file is anticipated to have multiple exports -// eslint-disable-next-line import/prefer-default-export -export const fetchCoreUsers = () => (dispatch) => { - dispatch(request(NetworkReducerTypes.GET_CORE_USERS)); - return CustomAxios() - .get(ENVIRONMENT.apiUrl + API.CORE_USER, createRequestHeader()) - .then((response) => { - dispatch(success(NetworkReducerTypes.GET_CORE_USERS)); - dispatch(userActions.storeCoreUserList(response.data)); - }) - .catch(() => dispatch(error(NetworkReducerTypes.GET_CORE_USERS))); -}; diff --git a/services/common/src/redux/actions/userActions.js b/services/common/src/redux/actions/userActions.js deleted file mode 100644 index 3611e28d53..0000000000 --- a/services/common/src/redux/actions/userActions.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as actionTypes from "@mds/common/constants/actionTypes"; - -// This file is anticipated to have multiple exports -// eslint-disable-next-line import/prefer-default-export -export const storeCoreUserList = (payload) => ({ - type: actionTypes.STORE_CORE_USERS, - payload, -}); diff --git a/services/common/src/redux/reducers.js b/services/common/src/redux/reducers.js index c13cca3a57..249829fc5f 100644 --- a/services/common/src/redux/reducers.js +++ b/services/common/src/redux/reducers.js @@ -21,7 +21,6 @@ import searchReducerObject from "./reducers/searchReducer"; import securitiesReducerObject from "./reducers/securitiesReducer"; import staticContentReducerObject from "./reducers/staticContentReducer"; import tailingsReducerObject from "./reducers/tailingsReducer"; -import userReducerObject from "./reducers/userReducer"; import varianceReducerObject from "./reducers/varianceReducer"; import workInformationReducerObject from "./reducers/workInformationReducer"; import verifiableCredentialReducerObject from "./reducers/verifiableCredentialReducer"; @@ -40,7 +39,6 @@ export const permitReducer = permitReducerObject; export const reportReducer = reportReducerObject; export const searchReducer = searchReducerObject; export const staticContentReducer = staticContentReducerObject; -export const userReducer = userReducerObject; export const varianceReducer = varianceReducerObject; export const securitiesReducer = securitiesReducerObject; export const orgbookReducer = orgbookReducerObject; diff --git a/services/common/src/redux/reducers/rootReducerShared.ts b/services/common/src/redux/reducers/rootReducerShared.ts index da492376e8..28aa92dd14 100644 --- a/services/common/src/redux/reducers/rootReducerShared.ts +++ b/services/common/src/redux/reducers/rootReducerShared.ts @@ -26,7 +26,6 @@ import { securitiesReducer, staticContentReducer, tailingsReducer, - userReducer, varianceReducer, verifiableCredentialReducer, workInformationReducer, @@ -37,13 +36,17 @@ import regionsReducer from "@mds/common/redux/slices/regionsSlice"; import complianceCodeReducer, { complianceCodeReducerType } from "../slices/complianceCodesSlice"; import spatialDataReducer, { spatialDataReducerType } from "../slices/spatialDataSlice"; import permitServiceReducer, { permitServiceReducerType } from "../slices/permitServiceSlice"; -import searchConditionCategoriesReducer, { searchConditionCategoriesType } from "../slices/permitConditionCategorySlice"; +import searchConditionCategoriesReducer, { + searchConditionCategoriesType, +} from "../slices/permitConditionCategorySlice"; import helpReducer, { helpReducerType } from "../slices/helpSlice"; const networkReducers = Object.fromEntries(Object.entries(NetworkReducerTypes).map(([key, value]) => [NetworkReducerTypes[key], createReducer(networkReducer, value)] )); +import userReducer, { userReducerType } from "@mds/common/redux/slices/userSlice"; + export const sharedReducer = { ...activityReducer, ...authenticationReducer, @@ -67,10 +70,21 @@ export const sharedReducer = { ...securitiesReducer, ...staticContentReducer, ...tailingsReducer, - ...userReducer, ...varianceReducer, ...verifiableCredentialReducer, ...workInformationReducer, + [reducerTypes.ADD_DOCUMENT_TO_VARIANCE]: createReducer( + networkReducer, + reducerTypes.ADD_DOCUMENT_TO_VARIANCE, + ), + [reducerTypes.GET_VARIANCE_STATUS_OPTIONS]: createReducer( + networkReducer, + reducerTypes.GET_VARIANCE_STATUS_OPTIONS, + ), + [reducerTypes.GET_COMPLIANCE_CODES]: createReducer( + networkReducer, + reducerTypes.GET_COMPLIANCE_CODES, + ), form: formReducer, loadingBar: loadingBarReducer, reportSubmission: reportSubmissionReducer, @@ -81,5 +95,6 @@ export const sharedReducer = { [permitServiceReducerType]: permitServiceReducer, [helpReducerType]: helpReducer, [searchConditionCategoriesType]: searchConditionCategoriesReducer, + [userReducerType]: userReducer, ...networkReducers }; diff --git a/services/common/src/redux/reducers/userReducer.js b/services/common/src/redux/reducers/userReducer.js deleted file mode 100644 index e6ba7d8f13..0000000000 --- a/services/common/src/redux/reducers/userReducer.js +++ /dev/null @@ -1,26 +0,0 @@ -import * as actionTypes from "@mds/common/constants/actionTypes"; -import { USERS } from "@mds/common/constants/reducerTypes"; - -const initialState = { - coreUsers: [], -}; - -export const userReducer = (state = initialState, action) => { - switch (action.type) { - case actionTypes.STORE_CORE_USERS: - return { - ...state, - coreUsers: action.payload.results, - }; - default: - return state; - } -}; - -const userReducerObject = { - [USERS]: userReducer, -}; - -export const getCoreUsers = (state) => state[USERS].coreUsers; - -export default userReducerObject; diff --git a/services/common/src/redux/selectors/userSelectors.js b/services/common/src/redux/selectors/userSelectors.js deleted file mode 100644 index 0cd08bbcc0..0000000000 --- a/services/common/src/redux/selectors/userSelectors.js +++ /dev/null @@ -1,22 +0,0 @@ -import { createSelector } from "reselect"; -import * as userReducer from "../reducers/userReducer"; -import { createLabelHash } from "../utils/helpers"; - -export const { getCoreUsers } = userReducer; - -export const getDropdownCoreUsers = createSelector( - [getCoreUsers], - (users) => - users.map((user) => { - // the username is prepended with "IDIR\", remove before displaying on UI - const formattedLabel = user.idir_user_detail.username - ? user.idir_user_detail.username.replace("IDIR\\", "") - : ""; - return { value: user.core_user_guid, label: formattedLabel }; - }) -); - -export const getCoreUsersHash = createSelector( - [getDropdownCoreUsers], - createLabelHash -); diff --git a/services/common/src/redux/slices/userSlice.spec.ts b/services/common/src/redux/slices/userSlice.spec.ts new file mode 100644 index 0000000000..fb45f6b4e4 --- /dev/null +++ b/services/common/src/redux/slices/userSlice.spec.ts @@ -0,0 +1,90 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { userReducer, fetchUser, getUser } from "./userSlice"; // Adjust the import path as necessary +import { ENVIRONMENT, USER_PROFILE } from "@mds/common/constants"; +import CustomAxios from "@mds/common/redux/customAxios"; + +const showLoadingMock = jest + .fn() + .mockReturnValue({ type: "SHOW_LOADING", payload: { show: true } }); +const hideLoadingMock = jest + .fn() + .mockReturnValue({ type: "HIDE_LOADING", payload: { show: false } }); + +jest.mock("@mds/common/redux/customAxios"); +jest.mock("react-redux-loading-bar", () => ({ + showLoading: () => showLoadingMock, + hideLoading: () => hideLoadingMock, +})); + +describe("userSlice", () => { + let store; + + beforeEach(() => { + store = configureStore({ + reducer: { + user: userReducer, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("fetchUser", () => { + const mockResponse = { + data: { + sub: "mock-sub", + display_name: "Mock User", + email: "mock@example.com", + family_name: "MockFamily", + given_name: "MockGiven", + last_logged_in: "2023-10-01T12:00:00.000Z", + }, + }; + + it("should fetch user data successfully", async () => { + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(mockResponse), + })); + + await store.dispatch(fetchUser()); + const state = store.getState().user; + + // Verify loading state management + expect(showLoadingMock).toHaveBeenCalledTimes(1); + expect(hideLoadingMock).toHaveBeenCalledTimes(1); + + // Verify state update + expect(getUser({ user: state })).toEqual(mockResponse.data); + expect(CustomAxios).toHaveBeenCalledWith({ errorToastMessage: "default" }); + }); + + it("should handle API error", async () => { + const error = new Error("API Error"); + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: jest.fn().mockRejectedValue(error), + })); + + await store.dispatch(fetchUser()); + const state = store.getState().user; + + // Check user state remains null on error + expect(getUser({ user: state })).toBeNull(); + }); + + it("should construct the correct endpoint URL", async () => { + const getMock = jest.fn().mockResolvedValue(mockResponse); + (CustomAxios as jest.Mock).mockImplementation(() => ({ + get: getMock, + })); + + await store.dispatch(fetchUser()); + + expect(getMock).toHaveBeenCalledWith( + `${ENVIRONMENT.apiUrl}${USER_PROFILE()}`, + expect.any(Object) + ); + }); + }); +}); diff --git a/services/common/src/redux/slices/userSlice.ts b/services/common/src/redux/slices/userSlice.ts new file mode 100644 index 0000000000..15b861ca95 --- /dev/null +++ b/services/common/src/redux/slices/userSlice.ts @@ -0,0 +1,61 @@ +import { createAppSlice, rejectHandler } from "@mds/common/redux/createAppSlice"; +import { createRequestHeader } from "@mds/common/redux/utils/RequestHeaders"; +import { hideLoading, showLoading } from "react-redux-loading-bar"; +import CustomAxios from "@mds/common/redux/customAxios"; +import { ENVIRONMENT, USER_PROFILE } from "@mds/common/constants"; + +export const userReducerType = "user"; + +interface UserState { + user: User; +} + +interface User { + sub: string; + display_name: string; + email: string; + family_name: string; + given_name: string; + last_logged_in: string; +} + +const initialState: UserState = { + user: null, +}; + +const userSlice = createAppSlice({ + name: userReducerType, + initialState, + reducers: (create) => ({ + fetchUser: create.asyncThunk( + async (_: undefined, thunkApi) => { + const headers = createRequestHeader(); + thunkApi.dispatch(showLoading()); + + const response = await CustomAxios({ + errorToastMessage: "default", + }).get(`${ENVIRONMENT.apiUrl}${USER_PROFILE()}`, headers); + + thunkApi.dispatch(hideLoading()); + return response.data; + }, + { + fulfilled: (state: UserState, action) => { + state.user = action.payload; + }, + rejected: (state: UserState, action) => { + rejectHandler(action); + }, + } + ), + }), + selectors: { + getUser: (state) => state.user, + }, +}); + +export const { getUser } = userSlice.selectors; +export const { fetchUser } = userSlice.actions; +export const userReducer = userSlice.reducer; + +export default userReducer; diff --git a/services/core-api/app/api/users/models/user.py b/services/core-api/app/api/users/models/user.py new file mode 100644 index 0000000000..427c133d9d --- /dev/null +++ b/services/core-api/app/api/users/models/user.py @@ -0,0 +1,79 @@ +from datetime import datetime +from pytz import utc +from app.api.utils.models_mixins import SoftDeleteMixin, Base, AuditMixin +from app.extensions import db + + +class User(SoftDeleteMixin, AuditMixin, Base): + __tablename__ = "user" + __versioned__ = { + 'exclude': ['last_logged_in'] + } + + sub = db.Column(db.String(), primary_key=True) + email = db.Column(db.String(), nullable=False) + given_name = db.Column(db.String(), nullable=False) + family_name = db.Column(db.String(), nullable=False) + display_name = db.Column(db.String(), nullable=False) + idir_username = db.Column(db.String(), nullable=False) + identity_provider = db.Column(db.String(), nullable=False) + idir_user_guid = db.Column(db.String(), nullable=False) + last_logged_in = db.Column(db.DateTime(), nullable=False) + + def __repr__(self): + return f'{self.__class__.__name__} {self.sub}' + + @classmethod + def find_by_sub(cls, sub): + return cls.query.filter_by(sub=sub).filter_by(deleted_ind=False).first() + + @classmethod + def create(cls, **kwargs): + kwargs['create_user'] = 'system' + kwargs['create_timestamp'] = datetime.now(tz=utc) + kwargs['update_user'] = 'system' + kwargs['update_timestamp'] = datetime.now(tz=utc) + + user = cls(**kwargs) + db.session.add(user) + db.session.commit() + return user + + @classmethod + def create_or_update_user(cls, **kwargs): + sub = kwargs.get("sub") + existing_user = cls.find_by_sub(sub) + + if existing_user: + # `last_logged_in` should always be updated + existing_user.last_logged_in = datetime.now(tz=utc) + + # Update other fields only if there are changes + for key, value in kwargs.items(): + if key not in ["sub", "last_logged_in"] and hasattr(existing_user, key): + current_value = getattr(existing_user, key) + if current_value != value: + setattr(existing_user, key, value) + + db.session.commit() + result = existing_user + + else: + # Create a new user if one does not already exist + new_user = cls.create(**kwargs) + result = new_user + + return result + + def update(self, **kwargs): + """ + Generic method for updating a User's attributes and saving them. + """ + # Add/Update attributes of the User instance + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + # Commit changes to the database + db.session.commit() + return self diff --git a/services/core-api/app/api/users/namespace.py b/services/core-api/app/api/users/namespace.py index d36376b055..89adad1b32 100644 --- a/services/core-api/app/api/users/namespace.py +++ b/services/core-api/app/api/users/namespace.py @@ -1,4 +1,4 @@ -from app.api.users.resources.user import UserResource +from app.api.users.resources.user_resource import UserResource from flask_restx import Namespace from app.api.users.core.resources.core_user import CoreUserListResource, CoreUserResource @@ -14,4 +14,4 @@ api.add_resource(MinespaceUserMineResource, '/minespace//mines/') api.add_resource(CoreUserListResource, '/core') api.add_resource(CoreUserResource, '/core/') -api.add_resource(UserResource, '/me') +api.add_resource(UserResource, '/profile') diff --git a/services/core-api/app/api/users/resources/user.py b/services/core-api/app/api/users/resources/user.py deleted file mode 100644 index 877de7162f..0000000000 --- a/services/core-api/app/api/users/resources/user.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid - -from flask import request, current_app -from app.api.utils.include.user_info import User -from flask_restx import Resource, reqparse -from werkzeug.exceptions import BadRequest, NotFound, InternalServerError - -from app.extensions import api, db -from app.api.utils.access_decorators import requires_role_mine_admin -from app.api.utils.resources_mixins import UserMixin - -from app.api.users.minespace.models.minespace_user import MinespaceUser -from app.api.users.minespace.models.minespace_user_mine import MinespaceUserMine -from app.api.users.response_models import MINESPACE_USER_MODEL -from app.api.mines.mine.models.mine import Mine - - -class UserResource(Resource, UserMixin): - def get(self): - user = User() - - user_info = user.get_user_raw_info() - user_info['preferred_username'] = user.get_user_username() - - return user_info diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py new file mode 100644 index 0000000000..4edb716395 --- /dev/null +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from flask_restx import Resource +from pytz import utc +from sqlalchemy.exc import IntegrityError + +from app.api.users.models.user import User +from app.api.users.response_models import USER_MODEL +from app.api.utils.include.user_info import User as UserUtils +from app.api.utils.resources_mixins import UserMixin +from app.extensions import api + + +class UserResource(Resource, UserMixin): + @api.doc(description='Retrive a list of alerts for a mine') + @api.marshal_with(USER_MODEL, code=200) + def get(self): + user_util = UserUtils() + + user_info = user_util.get_user_raw_info() + + try: + # Extract token information + user_data = { + "sub": user_info.get("sub"), + "email": user_info.get("email"), + "given_name": user_info.get("given_name"), + "family_name": user_info.get("family_name"), + "display_name": user_info.get("display_name"), + "idir_username": user_info.get("idir_username"), + "identity_provider": user_info.get("identity_provider"), + "idir_user_guid": user_info.get("idir_user_guid"), + "last_logged_in": datetime.now(tz=utc), + } + + user = User.create_or_update_user(**user_data) + + return user + + except IntegrityError: + return {"message": "Failed to update user due to a database error."}, 500 + except Exception as e: + return {"message": f"An error occurred: {str(e)}"}, 500 \ No newline at end of file diff --git a/services/core-api/app/api/users/response_models.py b/services/core-api/app/api/users/response_models.py index 8b2d9f70fb..c89fc15356 100644 --- a/services/core-api/app/api/users/response_models.py +++ b/services/core-api/app/api/users/response_models.py @@ -8,3 +8,14 @@ 'email_or_username': fields.String, 'mines': fields.List(fields.String), }) + +USER_MODEL = api.model( + 'User', { + 'sub': fields.String, + 'email': fields.String, + 'given_name': fields.String, + 'family_name': fields.String, + 'display_name': fields.String, + 'last_logged_in': fields.DateTime, + } +) diff --git a/services/core-api/app/api/utils/include/user_info.py b/services/core-api/app/api/utils/include/user_info.py index 219aa61380..998e78fa12 100644 --- a/services/core-api/app/api/utils/include/user_info.py +++ b/services/core-api/app/api/utils/include/user_info.py @@ -9,8 +9,14 @@ "username": "mds", "preferred_username": "mds", "email": "test-email", - "given_name": "test-given-name", - "client_roles": [] + "client_roles": [], + "sub": 'bce4ffa4b74741c79afa82287bfffbc8@idir', + "given_name": 'Test', + "family_name": 'Testerson', + "display_name": 'Testerson, Test: EMLI:EX', + "idir_username": "TTESTERSON", + "identity_provider": "idir", + "idir_user_guid": "BCE4FFA4B63641C79AFA82287BFFFBC8", } diff --git a/services/core-api/tests/factories.py b/services/core-api/tests/factories.py index ecd06c99ff..6ad49d5c82 100644 --- a/services/core-api/tests/factories.py +++ b/services/core-api/tests/factories.py @@ -14,6 +14,7 @@ from app.api.mines.reports.models.mine_report_permit_requirement import MineReportPermitRequirement, OfficeDestination from app.api.projects.project_link.models.project_link import ProjectLink from app.api.projects.project_summary.models.project_summary_ministry_comment import ProjectSummaryMinistryComment +from app.api.users.models.user import User from app.extensions import db from tests.status_code_gen import * from app.api.mines.documents.models.mine_document import MineDocument @@ -783,6 +784,20 @@ class Meta: last_logon = TODAY idir_user_detail = factory.RelatedFactory('tests.factories.IdirUserDetailFactory', 'core_user') +class UserFactory(BaseFactory): + class Meta: + model = User + + sub = factory.Faker('uuid4') + email = factory.Faker('email') + given_name = factory.Faker('first_name') + family_name = factory.Faker('last_name') + display_name = factory.LazyAttribute(lambda obj: f"{obj.given_name} {obj.family_name} EMLI:EX") + idir_username = factory.Faker('user_name') + identity_provider = factory.Faker('random_element', elements=['idir', 'bceid']) + idir_user_guid = factory.Faker('uuid4') + last_logged_in = factory.LazyFunction(lambda: datetime.now(tz=utc)) + class IdirUserDetailFactory(BaseFactory): diff --git a/services/core-api/tests/users/models/test_user.py b/services/core-api/tests/users/models/test_user.py new file mode 100644 index 0000000000..aa59e47cde --- /dev/null +++ b/services/core-api/tests/users/models/test_user.py @@ -0,0 +1,86 @@ +from datetime import datetime +from pytz import utc +from app.api.users.models.user import User +from tests.factories import UserFactory + + +def test_user_find_by_sub(db_session): + user = UserFactory() + + # Find the user by its `sub` + found_user = User.find_by_sub(user.sub) + + # Assertions to ensure the user can be found correctly + assert found_user is not None + assert found_user.sub == user.sub + assert found_user.email == user.email + + +def test_user_model_find_all(db_session): + # Define batch size and create multiple User instances + batch_size = 3 + users = UserFactory.create_batch(size=batch_size) + + # Get all the users from the database + all_users = User.query.filter_by(deleted_ind=False).all() # Assuming `SoftDeleteMixin` is in use + assert len(all_users) == batch_size # Ensure the batch size matches the created instances + + +def test_user_create_or_update_user_create_new_user(db_session): + # Create a user via the `create_or_update_user` method + user_data = { + "sub": "unique-sub-id", + "email": "test@example.com", + "given_name": "Test", + "family_name": "User", + "display_name": "Test User", + "idir_username": "testuser", + "identity_provider": "idir", + "idir_user_guid": "unique-idir-guid", + "last_logged_in": datetime.now(tz=utc), + } + + new_user = User.create_or_update_user(**user_data) + + # Validate that the user was created correctly + assert new_user is not None + assert new_user.sub == user_data["sub"] + assert new_user.email == user_data["email"] + + +def test_user_create_or_update_user_update_existing_user(db_session): + # Create an initial user + user = UserFactory() + + # Update the existing user's data + updated_data = { + "sub": "update-test-sub", # Same `sub` to trigger an update + "email": "updated@example.com", + "given_name": "Updated", + "family_name": "Name", + "display_name": "Updated Name", + } + + updated_user = User.create_or_update_user(**updated_data) + + # Validate that the user's information was updated + assert updated_user is not None + assert updated_user.email == "updated@example.com" + assert updated_user.given_name == "Updated" + assert updated_user.family_name == "Name" + assert updated_user.display_name == "Updated Name" + + +def test_user_soft_delete(db_session): + # Create a user + user = UserFactory() + + # Delete the user + user.delete() + + # Validate that the user is marked as deleted + assert user.deleted_ind is True + + # Ensure the user is not returned in a default query + active_users = User.query.filter_by(deleted_ind=False).all() + assert user not in active_users \ No newline at end of file diff --git a/services/core-api/tests/users/resources/test_user_profile_resource.py b/services/core-api/tests/users/resources/test_user_profile_resource.py new file mode 100644 index 0000000000..3fad58a3ea --- /dev/null +++ b/services/core-api/tests/users/resources/test_user_profile_resource.py @@ -0,0 +1,37 @@ +import json +from datetime import datetime +from pytz import utc + +from unittest.mock import patch +from app.api.users.models.user import User +from app.api.utils.include.user_info import User as UserUtils + + +def test_user_resource_get(test_client, auth_headers): + # Setup test user info + test_user_info = { + "sub": "bce4ffa4b74741c79afa82287bfffbc8@idir", + "email": "test-email", + "given_name": "Test", + "family_name": "Testerson", + "display_name": "Testerson, Test: EMLI:EX", + "idir_username": "TTESTERSON", + "idir_user_guid": "BCE4FFA4B63641C79AFA82287BFFFBC8", + "last_logged_in": datetime.now(tz=utc), + } + + # Mock UserUtils and User.create_or_update_user + with patch.object(UserUtils, 'get_user_raw_info', return_value=test_user_info), \ + patch.object(User, 'create_or_update_user', return_value=test_user_info): + # Make GET request + get_resp = test_client.get('/users/profile', headers=auth_headers['full_auth_header']) + assert get_resp.status_code == 200 + + # Parse response + get_data = json.loads(get_resp.data.decode()) + # Validate response matches test_user_info + assert get_data["email"] == test_user_info["email"] + assert get_data["sub"] == test_user_info["sub"] + assert get_data["given_name"] == test_user_info["given_name"] + assert get_data["family_name"] == test_user_info["family_name"] + assert get_data["display_name"] == test_user_info["display_name"] diff --git a/services/core-web/src/components/Home.js b/services/core-web/src/components/Home.js deleted file mode 100644 index 59f803fb84..0000000000 --- a/services/core-web/src/components/Home.js +++ /dev/null @@ -1,138 +0,0 @@ -import React, { Component } from "react"; -import { bindActionCreators } from "redux"; -import { connect } from "react-redux"; -import { Layout, BackTop, Button } from "antd"; -import { ArrowUpOutlined } from "@ant-design/icons"; -import PropTypes from "prop-types"; -import MediaQuery from "react-responsive"; -import LoadingBar from "react-redux-loading-bar"; -import { getStaticContentLoadingIsComplete } from "@mds/common/redux/selectors/staticContentSelectors"; -import { - loadBulkStaticContent, - fetchInspectors, - fetchProjectLeads, -} from "@mds/common/redux/actionCreators/staticContentActionCreator"; -import { detectIE, detectTestEnvironment, detectDevelopmentEnvironment } from "@mds/common/utils"; -import DashboardRoutes from "@/routes/DashboardRoutes"; -import { AuthenticationGuard } from "@/HOC/AuthenticationGuard"; -import WarningBanner, { WARNING_TYPES } from "@/components/common/WarningBanner"; -import * as Styles from "@/constants/styles"; -import NavBar from "./navigation/NavBar"; -import Loading from "./common/Loading"; - -/** - * @class Home contains the navigation and wraps the Dashboard routes. Home should not contain any redux logic/state. - * Home is wrapped in AuthenticationGuard which checks keycloak authorization. - */ - -const propTypes = { - staticContentLoadingIsComplete: PropTypes.bool.isRequired, - location: PropTypes.shape({ pathname: PropTypes.string }).isRequired, - loadBulkStaticContent: PropTypes.func.isRequired, - fetchInspectors: PropTypes.func.isRequired, - fetchProjectLeads: PropTypes.func.isRequired, -}; - -export class Home extends Component { - state = { - isIE: false, - isTest: false, - isDev: false, - isMobile: true, - activeNavButton: "", - isMenuOpen: false, - }; - - componentDidMount() { - this.setState({ - isIE: detectIE(), - isTest: detectTestEnvironment(), - isDev: detectDevelopmentEnvironment(), - }); - this.handleActiveButton(this.props.location.pathname); - this.loadStaticContent(); - } - - componentWillReceiveProps(nextProps) { - if (this.props.location !== nextProps.location) { - this.handleActiveButton(nextProps.location.pathname); - // close Menu when link is clicked - this.setState({ isMenuOpen: false }); - } - } - - handleActiveButton = (path) => { - this.setState({ activeNavButton: path }); - }; - - handleIEClose = () => { - this.setState({ isIE: false }); - }; - - handleMobileWarningClose = () => { - this.setState({ isMobile: false }); - }; - - toggleHamburgerMenu = () => { - this.setState((prevState) => ({ isMenuOpen: !prevState.isMenuOpen })); - }; - - loadStaticContent = () => { - this.props.loadBulkStaticContent(); - this.props.fetchInspectors(); - this.props.fetchProjectLeads(); - }; - - render() { - if (!this.props.staticContentLoadingIsComplete) { - return ; - } - return ( - -
- - - {this.state.isTest && } - {this.state.isIE && } - - {this.state.isMobile && !this.state.isDev && ( - - )} - - - - - - - -
- - ); - } -} - -Home.propTypes = propTypes; - -const mapStateToProps = (state) => ({ - staticContentLoadingIsComplete: getStaticContentLoadingIsComplete(state), -}); - -const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - loadBulkStaticContent, - fetchInspectors, - fetchProjectLeads, - }, - dispatch - ); - -export default connect(mapStateToProps, mapDispatchToProps)(AuthenticationGuard(Home)); diff --git a/services/core-web/src/components/Home.tsx b/services/core-web/src/components/Home.tsx new file mode 100644 index 0000000000..1d7fa09288 --- /dev/null +++ b/services/core-web/src/components/Home.tsx @@ -0,0 +1,110 @@ +import React, { FC, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { BackTop, Button, Layout } from "antd"; +import { ArrowUpOutlined } from "@ant-design/icons"; +import MediaQuery from "react-responsive"; +import LoadingBar from "react-redux-loading-bar"; +import { getStaticContentLoadingIsComplete } from "@mds/common/redux/selectors/staticContentSelectors"; +import { + fetchInspectors, + fetchProjectLeads, + loadBulkStaticContent, +} from "@mds/common/redux/actionCreators/staticContentActionCreator"; +import { detectDevelopmentEnvironment, detectIE, detectTestEnvironment } from "@mds/common/utils"; +import DashboardRoutes from "@/routes/DashboardRoutes"; +import { AuthenticationGuard } from "@/HOC/AuthenticationGuard"; +import WarningBanner, { WARNING_TYPES } from "@/components/common/WarningBanner"; +import NavBar from "./navigation/NavBar"; +import Loading from "./common/Loading"; +import { useLocation } from "react-router-dom"; +import { fetchUser, getUser } from "@mds/common/redux/slices/userSlice"; + +/** + * @func Home contains the navigation and wraps the Dashboard routes. Home should not contain any redux logic/state. + * Home is wrapped in AuthenticationGuard which checks keycloak authorization. + */ + +export const Home: FC = () => { + const location = useLocation(); + const dispatch = useDispatch(); + + const staticContentLoadingIsComplete = useSelector(getStaticContentLoadingIsComplete); + const user = useSelector(getUser); + + const [isIE, setIsIE] = useState(false); + const [isTest, setIsTest] = useState(false); + const [isDev, setIsDev] = useState(false); + const [isMobile, setIsMobile] = useState(true); + const [activeNavButton, setActiveNavButton] = useState(""); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + useEffect(() => { + setIsIE(detectIE()); + setIsTest(detectTestEnvironment()); + setIsDev(detectDevelopmentEnvironment()); + loadStaticContent(); + }, []); + + useEffect(() => { + handleActiveButton(location.pathname); + setIsMenuOpen(false); + }, [location]); + + const handleActiveButton = (path) => { + setActiveNavButton(path); + }; + + const handleIEClose = () => { + setIsIE(false); + }; + + const handleMobileWarningClose = () => { + setIsMobile(false); + }; + + const toggleHamburgerMenu = () => { + setIsMenuOpen(!isMenuOpen); + }; + + const loadStaticContent = () => { + dispatch(loadBulkStaticContent()); + dispatch(fetchInspectors()); + dispatch(fetchProjectLeads()); + dispatch(fetchUser()); + }; + + if (!staticContentLoadingIsComplete || !user) { + return ; + } + return ( + +
+ + + {isTest && } + {isIE && } + + {isMobile && !isDev && ( + + )} + + + + + + + +
+ + ); +}; + +export default AuthenticationGuard(Home); diff --git a/services/core-web/src/tests/actionCreators/userActionCreator.spec.js b/services/core-web/src/tests/actionCreators/userActionCreator.spec.js deleted file mode 100644 index 0e9da98282..0000000000 --- a/services/core-web/src/tests/actionCreators/userActionCreator.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import MockAdapter from "axios-mock-adapter"; -import axios from "axios"; -import { fetchCoreUsers } from "@mds/common/redux/actionCreators/userActionCreator"; -import * as genericActions from "@mds/common/redux/actions/genericActions"; -import { ENVIRONMENT } from "@mds/common"; -import * as API from "@mds/common/constants/API"; -import * as MOCK from "@/tests/mocks/dataMocks"; - -const dispatch = jest.fn(); -const requestSpy = jest.spyOn(genericActions, "request"); -const successSpy = jest.spyOn(genericActions, "success"); -const errorSpy = jest.spyOn(genericActions, "error"); -const mockAxios = new MockAdapter(axios); - -beforeEach(() => { - mockAxios.reset(); - dispatch.mockClear(); - requestSpy.mockClear(); - successSpy.mockClear(); - errorSpy.mockClear(); -}); - -describe("`fetchCoreUsers` action creator", () => { - const url = ENVIRONMENT.apiUrl + API.CORE_USER; - it("Request successful, dispatches `success` with correct response", () => { - const mockResponse = { data: { success: true } }; - mockAxios.onGet(url).reply(200, mockResponse); - return fetchCoreUsers()(dispatch).then(() => { - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(successSpy).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(3); - }); - }); - - it("Request failure, dispatches `error` with correct response", () => { - mockAxios.onGet(url, MOCK.createMockHeader()).reply(418, MOCK.ERROR); - return fetchCoreUsers()(dispatch).then(() => { - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(dispatch).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/services/core-web/src/tests/reducers/userReducer.spec.js b/services/core-web/src/tests/reducers/userReducer.spec.js deleted file mode 100644 index 3708a7d90b..0000000000 --- a/services/core-web/src/tests/reducers/userReducer.spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import { userReducer } from "@mds/common/redux/reducers/userReducer"; -import { storeCoreUserList } from "@mds/common/redux/actions/userActions"; -import * as MOCK from "@/tests/mocks/dataMocks"; - -const baseExpectedValue = { - coreUsers: [], -}; - -// Creates deep copy of javascript object instead of setting a reference -const getBaseExpectedValue = () => JSON.parse(JSON.stringify(baseExpectedValue)); - -describe("userReducer", () => { - it("receives undefined", () => { - const expectedValue = getBaseExpectedValue(); - const result = userReducer(undefined, {}); - expect(result).toEqual(expectedValue); - }); - - it("receives STORE_CORE_USERS", () => { - const expectedValue = getBaseExpectedValue(); - expectedValue.coreUsers = MOCK.INSPECTORS.results; - const result = userReducer(undefined, storeCoreUserList(MOCK.INSPECTORS)); - expect(result).toEqual(expectedValue); - }); -}); From f3a5ebe073c450994407f37138dbe57edac8df54 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 11:40:06 -0700 Subject: [PATCH 02/14] move IUser interface into interfaces directory --- services/common/src/interfaces/user/index.ts | 1 + .../common/src/interfaces/user/user.interface.ts | 8 ++++++++ services/common/src/redux/slices/userSlice.ts | 12 ++---------- 3 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 services/common/src/interfaces/user/user.interface.ts diff --git a/services/common/src/interfaces/user/index.ts b/services/common/src/interfaces/user/index.ts index 65af424501..8dfa92ae50 100644 --- a/services/common/src/interfaces/user/index.ts +++ b/services/common/src/interfaces/user/index.ts @@ -1 +1,2 @@ export * from "./userInfo.interface"; +export * from "./user.interface"; diff --git a/services/common/src/interfaces/user/user.interface.ts b/services/common/src/interfaces/user/user.interface.ts new file mode 100644 index 0000000000..abbcb8a6d0 --- /dev/null +++ b/services/common/src/interfaces/user/user.interface.ts @@ -0,0 +1,8 @@ +export interface IUser { + sub: string; + display_name: string; + email: string; + family_name: string; + given_name: string; + last_logged_in: string; +} diff --git a/services/common/src/redux/slices/userSlice.ts b/services/common/src/redux/slices/userSlice.ts index 15b861ca95..f3b506bf66 100644 --- a/services/common/src/redux/slices/userSlice.ts +++ b/services/common/src/redux/slices/userSlice.ts @@ -3,20 +3,12 @@ import { createRequestHeader } from "@mds/common/redux/utils/RequestHeaders"; import { hideLoading, showLoading } from "react-redux-loading-bar"; import CustomAxios from "@mds/common/redux/customAxios"; import { ENVIRONMENT, USER_PROFILE } from "@mds/common/constants"; +import { IUser } from "@mds/common/interfaces"; export const userReducerType = "user"; interface UserState { - user: User; -} - -interface User { - sub: string; - display_name: string; - email: string; - family_name: string; - given_name: string; - last_logged_in: string; + user: IUser; } const initialState: UserState = { From cd26feb621f300573288ab7692e28beb27180916 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 11:45:18 -0700 Subject: [PATCH 03/14] remove comment --- services/core-api/app/api/users/models/user.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/services/core-api/app/api/users/models/user.py b/services/core-api/app/api/users/models/user.py index 427c133d9d..e5483d1c88 100644 --- a/services/core-api/app/api/users/models/user.py +++ b/services/core-api/app/api/users/models/user.py @@ -66,9 +66,6 @@ def create_or_update_user(cls, **kwargs): return result def update(self, **kwargs): - """ - Generic method for updating a User's attributes and saving them. - """ # Add/Update attributes of the User instance for key, value in kwargs.items(): if hasattr(self, key): From 70b96e8d79f2e1ba3c07ddef0e27607d8c2cb57d Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 11:52:55 -0700 Subject: [PATCH 04/14] fixed rebase issue with network reducers --- .../common/src/redux/reducers/rootReducerShared.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/services/common/src/redux/reducers/rootReducerShared.ts b/services/common/src/redux/reducers/rootReducerShared.ts index 28aa92dd14..96a78bc871 100644 --- a/services/common/src/redux/reducers/rootReducerShared.ts +++ b/services/common/src/redux/reducers/rootReducerShared.ts @@ -73,18 +73,6 @@ export const sharedReducer = { ...varianceReducer, ...verifiableCredentialReducer, ...workInformationReducer, - [reducerTypes.ADD_DOCUMENT_TO_VARIANCE]: createReducer( - networkReducer, - reducerTypes.ADD_DOCUMENT_TO_VARIANCE, - ), - [reducerTypes.GET_VARIANCE_STATUS_OPTIONS]: createReducer( - networkReducer, - reducerTypes.GET_VARIANCE_STATUS_OPTIONS, - ), - [reducerTypes.GET_COMPLIANCE_CODES]: createReducer( - networkReducer, - reducerTypes.GET_COMPLIANCE_CODES, - ), form: formReducer, loadingBar: loadingBarReducer, reportSubmission: reportSubmissionReducer, From 431645a2372c714166bc0fc93120af3850a6b287 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 12:32:07 -0700 Subject: [PATCH 05/14] update tests --- services/common/src/tests/mocks/dataMocks.tsx | 10 +++- .../src/tests/components/Home.spec.js | 24 ---------- .../src/tests/components/Home.spec.tsx | 32 +++++++++++++ .../__snapshots__/Home.spec.js.snap | 46 ------------------- .../__snapshots__/Home.spec.tsx.snap | 37 +++++++++++++++ .../DashboardRoutes.spec.js.snap | 2 +- .../routes/__snapshots__/Routes.spec.js.snap | 2 +- 7 files changed, 80 insertions(+), 73 deletions(-) delete mode 100755 services/core-web/src/tests/components/Home.spec.js create mode 100755 services/core-web/src/tests/components/Home.spec.tsx delete mode 100644 services/core-web/src/tests/components/__snapshots__/Home.spec.js.snap create mode 100644 services/core-web/src/tests/components/__snapshots__/Home.spec.tsx.snap diff --git a/services/common/src/tests/mocks/dataMocks.tsx b/services/common/src/tests/mocks/dataMocks.tsx index 8c86e82d24..e0f0432279 100644 --- a/services/common/src/tests/mocks/dataMocks.tsx +++ b/services/common/src/tests/mocks/dataMocks.tsx @@ -21,7 +21,6 @@ import { VC_CONNECTION_STATES, VC_CRED_ISSUE_STATES, } from "@mds/common/constants"; -import { PermitExtraction } from "@mds/common/redux/slices/permitServiceSlice"; export const createMockHeader = () => ({ headers: { @@ -8967,3 +8966,12 @@ export const HELP_GUIDE_MS = { }, ], }; + +export const USER = { + sub: '1234', + displayName: 'Testerson, Test EMLI:EX', + email: 'test@test.ca', + family_name: 'Testerson', + given_name: 'Test', + last_logged_in: '2022-08-08T20:59:01.482461+00:00', +} diff --git a/services/core-web/src/tests/components/Home.spec.js b/services/core-web/src/tests/components/Home.spec.js deleted file mode 100755 index 47aa2095d0..0000000000 --- a/services/core-web/src/tests/components/Home.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import { shallow } from "enzyme"; -import { Home } from "@/components/Home"; - -const props = {}; - -const setupReducerProps = () => { - props.location = { pathname: " " }; - props.loadBulkStaticContent = jest.fn(); - props.fetchInspectors = jest.fn(); - props.fetchProjectLeads = jest.fn(); - props.staticContentLoadingIsComplete = true; -}; - -beforeEach(() => { - setupReducerProps(); -}); - -describe("Home", () => { - it("renders properly", () => { - const component = shallow(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/services/core-web/src/tests/components/Home.spec.tsx b/services/core-web/src/tests/components/Home.spec.tsx new file mode 100755 index 0000000000..abed639714 --- /dev/null +++ b/services/core-web/src/tests/components/Home.spec.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Home } from "@/components/Home"; +import { STATIC_CONTENT } from "@mds/common"; +import * as MOCK from "@mds/common/tests/mocks/dataMocks"; +import { userReducerType } from "@mds/common/redux/slices/userSlice"; +import { render } from "@testing-library/react"; +import { ReduxWrapper } from "@mds/common/tests/utils/ReduxWrapper"; + +const props = {}; + +jest.mock("react-router-dom", () => ({ + ...jest.requireActual("react-router-dom"), + useLocation: jest.fn().mockReturnValue({ + pathname: "/home", + }), +})); + +const initialState = { + [userReducerType]: { user: MOCK.USER }, + [STATIC_CONTENT]: MOCK.BULK_STATIC_CONTENT_RESPONSE, +}; + +describe("Home", () => { + it("renders properly", () => { + const { container } = render( + + + + ); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/services/core-web/src/tests/components/__snapshots__/Home.spec.js.snap b/services/core-web/src/tests/components/__snapshots__/Home.spec.js.snap deleted file mode 100644 index 8b4854283a..0000000000 --- a/services/core-web/src/tests/components/__snapshots__/Home.spec.js.snap +++ /dev/null @@ -1,46 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Home renders properly 1`] = ` - -
- - - - - - - - - - - -
- -`; diff --git a/services/core-web/src/tests/components/__snapshots__/Home.spec.tsx.snap b/services/core-web/src/tests/components/__snapshots__/Home.spec.tsx.snap new file mode 100644 index 0000000000..9937821420 --- /dev/null +++ b/services/core-web/src/tests/components/__snapshots__/Home.spec.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Home renders properly 1`] = ` +
+
+
+ + + +
+
+
+`; diff --git a/services/core-web/src/tests/routes/__snapshots__/DashboardRoutes.spec.js.snap b/services/core-web/src/tests/routes/__snapshots__/DashboardRoutes.spec.js.snap index a8122574b5..ef0062429f 100644 --- a/services/core-web/src/tests/routes/__snapshots__/DashboardRoutes.spec.js.snap +++ b/services/core-web/src/tests/routes/__snapshots__/DashboardRoutes.spec.js.snap @@ -91,7 +91,7 @@ exports[`DashboardRoutes renders properly 1`] = ` "$$typeof": Symbol(react.memo), "WrappedComponent": [Function], "compare": null, - "displayName": "Connect(Connect(authenticationGuard))", + "displayName": "Connect(authenticationGuard)", "type": [Function], } } diff --git a/services/core-web/src/tests/routes/__snapshots__/Routes.spec.js.snap b/services/core-web/src/tests/routes/__snapshots__/Routes.spec.js.snap index 188930dc2f..6c78185d13 100644 --- a/services/core-web/src/tests/routes/__snapshots__/Routes.spec.js.snap +++ b/services/core-web/src/tests/routes/__snapshots__/Routes.spec.js.snap @@ -27,7 +27,7 @@ exports[`Routes renders properly 1`] = ` "$$typeof": Symbol(react.memo), "WrappedComponent": [Function], "compare": null, - "displayName": "Connect(Connect(authenticationGuard))", + "displayName": "Connect(authenticationGuard)", "type": [Function], } } From 612297b21f025ee429dd275eaa5f5587947da7ee Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 12:49:16 -0700 Subject: [PATCH 06/14] update tests --- .../src/tests/selectors/userSelectors.spec.js | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 services/core-web/src/tests/selectors/userSelectors.spec.js diff --git a/services/core-web/src/tests/selectors/userSelectors.spec.js b/services/core-web/src/tests/selectors/userSelectors.spec.js deleted file mode 100644 index 46827ae4b1..0000000000 --- a/services/core-web/src/tests/selectors/userSelectors.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { - getCoreUsers, - getDropdownCoreUsers, - getCoreUsersHash, -} from "@mds/common/redux/selectors/userSelectors"; -import { userReducer } from "@mds/common/redux/reducers/userReducer"; -import { storeCoreUserList } from "@mds/common/redux/actions/userActions"; -import { USERS } from "@mds/common/constants/reducerTypes"; -import * as Mock from "@/tests/mocks/dataMocks"; - -const mockState = { - coreUsers: Mock.INSPECTORS.results, -}; - -describe("userSelectors", () => { - const { coreUsers } = mockState; - - it("`getCoreUsers` calls `userReducer.getCoreUsers`", () => { - const storeAction = storeCoreUserList(Mock.INSPECTORS); - const storeState = userReducer({}, storeAction); - const localMockState = { - [USERS]: storeState, - }; - expect(getCoreUsers(localMockState)).toEqual(coreUsers); - }); - - it("`getDropdownCoreUsers` calls `userReducer.getCoreUsers`", () => { - const storeAction = storeCoreUserList(Mock.INSPECTORS); - const storeState = userReducer({}, storeAction); - const localMockState = { - [USERS]: storeState, - }; - expect(getDropdownCoreUsers(localMockState)).toEqual(Mock.INSPECTORS_DROPDOWN); - }); - - it("`getCoreUsersHash` calls `userReducer.getCoreUsers`", () => { - const storeAction = storeCoreUserList(Mock.INSPECTORS); - const storeState = userReducer({}, storeAction); - const localMockState = { - [USERS]: storeState, - }; - expect(getCoreUsersHash(localMockState)).toEqual(Mock.INSPECTORS_HASH); - }); -}); From 0813e0164412e8be890f601285f2a83092ac14a6 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Thu, 12 Dec 2024 13:46:19 -0700 Subject: [PATCH 07/14] add role requirement to user profile resource --- services/core-api/app/api/users/resources/user_resource.py | 4 +++- services/core-api/tests/auth/test_expected_auth.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py index 4edb716395..2e292e595a 100644 --- a/services/core-api/app/api/users/resources/user_resource.py +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -6,13 +6,15 @@ from app.api.users.models.user import User from app.api.users.response_models import USER_MODEL +from app.api.utils.access_decorators import requires_role_view_all from app.api.utils.include.user_info import User as UserUtils from app.api.utils.resources_mixins import UserMixin from app.extensions import api class UserResource(Resource, UserMixin): - @api.doc(description='Retrive a list of alerts for a mine') + @api.doc(description='Update and retrieve the user from the token') + @requires_role_view_all @api.marshal_with(USER_MODEL, code=200) def get(self): user_util = UserUtils() diff --git a/services/core-api/tests/auth/test_expected_auth.py b/services/core-api/tests/auth/test_expected_auth.py index 248f722c46..04bdc4e233 100644 --- a/services/core-api/tests/auth/test_expected_auth.py +++ b/services/core-api/tests/auth/test_expected_auth.py @@ -1,6 +1,7 @@ import pytest from app.api.mines.reports.resources.mine_report_permit_requirement import MineReportPermitRequirementResource +from app.api.users.resources.user_resource import UserResource from app.api.utils.access_decorators import VIEW_ALL, MINE_EDIT, MINE_ADMIN, MINESPACE_PROPONENT, EDIT_PARTY, \ EDIT_PERMIT, EDIT_STANDARD_PERMIT_CONDITIONS, EDIT_DO, EDIT_VARIANCE, EDIT_REPORT, EDIT_SUBMISSIONS, EDIT_SECURITIES, \ GIS, EDIT_PROJECT_SUMMARIES, EDIT_INCIDENTS, EDIT_TSF, EDIT_INFORMATION_REQUIREMENTS_TABLE, EDIT_REQUIREMENTS, \ @@ -121,6 +122,7 @@ (MinespaceUserResource, 'get', [MINE_ADMIN]), (MinespaceUserResource, 'delete', [MINE_ADMIN]), (MinespaceUserMineListResource, 'post', [MINE_ADMIN]), (MinespaceUserMineResource, 'delete', [MINE_ADMIN]), + (UserResource, 'get', [VIEW_ALL]), (NOWActivityTypeResource, 'get', [VIEW_ALL]), (NOWApplicationImportResource, 'post', [EDIT_PERMIT]), (NOWApplicationListResource, 'get', [VIEW_ALL, GIS]), From b2de1c92f7326351c7a84235813748aa900ffc41 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 11:05:22 -0700 Subject: [PATCH 08/14] added error log to user resource --- services/core-api/app/api/users/resources/user_resource.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py index 2e292e595a..30a6bc4138 100644 --- a/services/core-api/app/api/users/resources/user_resource.py +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -1,5 +1,6 @@ from datetime import datetime +from flask import current_app from flask_restx import Resource from pytz import utc from sqlalchemy.exc import IntegrityError @@ -42,4 +43,5 @@ def get(self): except IntegrityError: return {"message": "Failed to update user due to a database error."}, 500 except Exception as e: + current_app.logger.error(f'Error: {str(e)}') return {"message": f"An error occurred: {str(e)}"}, 500 \ No newline at end of file From bc1ad04f8ccd86006fd91db0e24397ceea90fee6 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 11:19:52 -0700 Subject: [PATCH 09/14] added error log to user resource --- services/core-api/app/api/users/resources/user_resource.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py index 30a6bc4138..0be2b407d0 100644 --- a/services/core-api/app/api/users/resources/user_resource.py +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -40,8 +40,6 @@ def get(self): return user - except IntegrityError: - return {"message": "Failed to update user due to a database error."}, 500 except Exception as e: current_app.logger.error(f'Error: {str(e)}') return {"message": f"An error occurred: {str(e)}"}, 500 \ No newline at end of file From df9faa751f2665f025fa9af120f34752e12ca6e7 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 11:43:31 -0700 Subject: [PATCH 10/14] added error log to user resource --- .../app/api/users/resources/user_resource.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/services/core-api/app/api/users/resources/user_resource.py b/services/core-api/app/api/users/resources/user_resource.py index 0be2b407d0..d6df5cd4bf 100644 --- a/services/core-api/app/api/users/resources/user_resource.py +++ b/services/core-api/app/api/users/resources/user_resource.py @@ -3,7 +3,6 @@ from flask import current_app from flask_restx import Resource from pytz import utc -from sqlalchemy.exc import IntegrityError from app.api.users.models.user import User from app.api.users.response_models import USER_MODEL @@ -26,13 +25,13 @@ def get(self): # Extract token information user_data = { "sub": user_info.get("sub"), - "email": user_info.get("email"), - "given_name": user_info.get("given_name"), - "family_name": user_info.get("family_name"), - "display_name": user_info.get("display_name"), - "idir_username": user_info.get("idir_username"), - "identity_provider": user_info.get("identity_provider"), - "idir_user_guid": user_info.get("idir_user_guid"), + "email": user_info.get("email", ""), + "given_name": user_info.get("given_name", ""), + "family_name": user_info.get("family_name", ""), + "display_name": user_info.get("display_name", ""), + "idir_username": user_info.get("idir_username", ""), + "identity_provider": user_info.get("identity_provider", ""), + "idir_user_guid": user_info.get("idir_user_guid", ""), "last_logged_in": datetime.now(tz=utc), } From 1f2dfd261ed2b8364e49f653646c5679dc9f5434 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 12:03:41 -0700 Subject: [PATCH 11/14] remove get_core_users network reducer type --- services/common/src/constants/networkReducerTypes.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/services/common/src/constants/networkReducerTypes.ts b/services/common/src/constants/networkReducerTypes.ts index 6fa906e702..f17f4bfa43 100644 --- a/services/common/src/constants/networkReducerTypes.ts +++ b/services/common/src/constants/networkReducerTypes.ts @@ -143,9 +143,6 @@ export enum NetworkReducerTypes { CREATE_PROJECT_LINKS = "CREATE_PROJECT_LINKS", DELETE_PROJECT_LINK = "DELETE_PROJECT_LINK", - // Core Users - GET_CORE_USERS = "GET_CORE_USERS", - // Incidents CREATE_MINE_INCIDENT = "CREATE_MINE_INCIDENT", GET_INCIDENTS = "GET_INCIDENTS", From 28d503e9e6c7f02026b6cea899c0aba3867a80d4 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 12:17:14 -0700 Subject: [PATCH 12/14] update help test assert --- services/core-api/tests/help/test_help_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/core-api/tests/help/test_help_resource.py b/services/core-api/tests/help/test_help_resource.py index 146d2506ec..48f91f0b5f 100644 --- a/services/core-api/tests/help/test_help_resource.py +++ b/services/core-api/tests/help/test_help_resource.py @@ -74,7 +74,7 @@ def test_get_put_help_core(test_client, db_session, auth_headers): '/help/default', json=record, headers=auth_headers['full_auth_header']) put_data = json.loads(put_resp.data.decode()) - assert put_data['update_user'] == 'mds' + assert put_data['update_user'] == 'ttesterson' assert put_data['content'] == record['content'] assert put_data['help_guid'] == record['help_guid'] From fd6551f0459bec6fb63adb04bc425ca512c169b2 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 12:59:45 -0700 Subject: [PATCH 13/14] update help test assert --- services/core-api/app/api/utils/include/user_info.py | 2 +- services/core-api/tests/help/test_help_resource.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/core-api/app/api/utils/include/user_info.py b/services/core-api/app/api/utils/include/user_info.py index 998e78fa12..1987af5eda 100644 --- a/services/core-api/app/api/utils/include/user_info.py +++ b/services/core-api/app/api/utils/include/user_info.py @@ -14,7 +14,7 @@ "given_name": 'Test', "family_name": 'Testerson', "display_name": 'Testerson, Test: EMLI:EX', - "idir_username": "TTESTERSON", + "idir_username": "mds", "identity_provider": "idir", "idir_user_guid": "BCE4FFA4B63641C79AFA82287BFFFBC8", } diff --git a/services/core-api/tests/help/test_help_resource.py b/services/core-api/tests/help/test_help_resource.py index 48f91f0b5f..146d2506ec 100644 --- a/services/core-api/tests/help/test_help_resource.py +++ b/services/core-api/tests/help/test_help_resource.py @@ -74,7 +74,7 @@ def test_get_put_help_core(test_client, db_session, auth_headers): '/help/default', json=record, headers=auth_headers['full_auth_header']) put_data = json.loads(put_resp.data.decode()) - assert put_data['update_user'] == 'ttesterson' + assert put_data['update_user'] == 'mds' assert put_data['content'] == record['content'] assert put_data['help_guid'] == record['help_guid'] From 4d99c6027ab511c11f631d698255f463ae419070 Mon Sep 17 00:00:00 2001 From: "Mat.Busby" Date: Fri, 13 Dec 2024 15:04:00 -0700 Subject: [PATCH 14/14] update help test assert --- services/core-api/tests/users/models/test_user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/core-api/tests/users/models/test_user.py b/services/core-api/tests/users/models/test_user.py index aa59e47cde..28d370315e 100644 --- a/services/core-api/tests/users/models/test_user.py +++ b/services/core-api/tests/users/models/test_user.py @@ -54,10 +54,11 @@ def test_user_create_or_update_user_update_existing_user(db_session): # Update the existing user's data updated_data = { - "sub": "update-test-sub", # Same `sub` to trigger an update + "sub": user.sub, "email": "updated@example.com", "given_name": "Updated", "family_name": "Name", + "idir_username": "testuser", "display_name": "Updated Name", }