diff --git a/.eslintrc.json b/.eslintrc.json index 43648cf8..9dbe92b3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,119 +1,88 @@ { - "ignorePatterns": [ - "node_modules/*", - "dist/*", - "build/*" - ], - "env": { - "browser": true, - "es2021": true, - "node": true + "ignorePatterns": ["node_modules/*", "dist/*", "build/*"], + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:@typescript-eslint/recommended" + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": ["react", "@typescript-eslint", "import"], + "rules": { + "object-curly-spacing": ["error", "always"], + "no-multiple-empty-lines": [ + "error", + { + "max": 1 + } ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": 12, - "sourceType": "module" - }, - "plugins": [ - "react", - "@typescript-eslint", - "import" + "no-trailing-spaces": "error", + "no-mixed-spaces-and-tabs": "error", + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double"], + "semi": ["error", "always"], + "no-undef": "off", + "comma-dangle": ["error", "never"], + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": "off", + "react/react-in-jsx-scope": "off", + "react/require-default-props": "off", + "no-spaced-func": "off", + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true + } ], - "rules": { - "object-curly-spacing": ["error", "always"], - "no-multiple-empty-lines": [ - "error", - { - "max": 1 - } - ], - "no-trailing-spaces": "error", - "no-mixed-spaces-and-tabs": "error", - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "double" - ], - "semi": [ - "error", - "always" - ], - "no-undef": "off", - "comma-dangle": [ - "error", - "never" - ], - "@typescript-eslint/no-explicit-any": "error", - "@typescript-eslint/no-unused-vars": "off", - "react/react-in-jsx-scope": "off", - "react/require-default-props": "off", - "no-spaced-func": "off", - "import/no-extraneous-dependencies": [ - "error", - { - "devDependencies": true - } - ], - "max-len": [ - "error", - { - "code": 200 - } - ], - "@typescript-eslint/no-empty-function": "off", - "indent": [ - "error", - 4 - ], - "space-in-parens": [ - "error", - "never" - ], - "no-multi-spaces": "error", - "comma-spacing": [ - "error", - { - "before": false, - "after": true - } - ], - "template-curly-spacing": [ - "error", - "never" - ] - }, - "overrides": [ - { - "files": [ - "*.js" - ], - "rules": { - "@typescript-eslint/no-var-requires": "off" - } - } + "max-len": [ + "error", + { + "code": 200 + } ], - "settings": { - "react": { - "createClass": "createReactClass", // Regex for Component Factory to use, - // default to "createReactClass" - "pragma": "React", // Pragma to use, default to "React" - "fragment": "Fragment", // Fragment to use (may be a property of ), default to "Fragment" - "version": "detect", // React version. "detect" automatically picks the version you have installed. - // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. - // default to latest and warns if missing - // It will default to "detect" in the future - "flowVersion": "0.53" // Flow version - } + "@typescript-eslint/no-empty-function": "off", + "indent": ["error", 4], + "space-in-parens": ["error", "never"], + "no-multi-spaces": "error", + "comma-spacing": [ + "error", + { + "before": false, + "after": true + } + ], + "template-curly-spacing": ["error", "never"] + }, + "overrides": [ + { + "files": ["*.js"], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ], + "settings": { + "react": { + "createClass": "createReactClass", // Regex for Component Factory to use, + // default to "createReactClass" + "pragma": "React", // Pragma to use, default to "React" + "fragment": "Fragment", // Fragment to use (may be a property of ), default to "Fragment" + "version": "detect", // React version. "detect" automatically picks the version you have installed. + // You can also use `16.0`, `16.3`, etc, if you want to override the detected value. + // default to latest and warns if missing + // It will default to "detect" in the future + "flowVersion": "0.53" // Flow version } -} \ No newline at end of file + } +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..c0220955 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest: Run all", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "type": "node", + "request": "launch", + "name": "Jest: Run Current Test File", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand", "${relativeFile}"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index a0a4fc13..2bf031d2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,8 @@ process.env = Object.assign(process.env, { BLAISE_API_URL: "http://mock", PROJECT_ID: "mock-project", - SERVER_PARK: "mock-server-park" + SERVER_PARK: "mock-server-park", + MOCK_AUTH_TOKEN: "mock-token" }); module.exports = { diff --git a/package.json b/package.json index 5d9789a3..7470df23 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start-server": "tsc --project tsconfig.server.json && node ./dist/index.js | pino-pretty -c -t", "start-react": "react-scripts start", "build-react": "react-scripts --openssl-legacy-provider build", - "test": "yarn build-react && tsc --project tsconfig.server.json && jest --coverage --watchAll=false", + "rebuild-test": "yarn build-react && tsc --project tsconfig.server.json && jest --coverage --watchAll=false", + "test": "jest --coverage --watchAll=false", "gcp-build": "yarn build-react && tsc --project tsconfig.server.json", "lint-fix": "node_modules/.bin/eslint . --fix", "lint": "yarn eslint .", @@ -30,13 +31,8 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^14.2.1", "@testing-library/user-event": "^13.5.0", - "@types/express": "^4.17.8", - "@types/jest": "26.0.20", - "@types/node": "^15.12.2", - "@types/react": "^18.2.51", - "@types/react-dom": "^18.2.18", "axios": "^1.6.1", - "blaise-api-node-client": "https://github.com/ONSdigital/blaise-api-node-client#v1.1.0", + "blaise-api-node-client": "git+https://github.com/ONSdigital/blaise-api-node-client", "blaise-design-system-react-components": "git+https://github.com/ONSdigital/blaise-design-system-react-components#0.14.0", "blaise-login-react": "git+https://github.com/ONSdigital/blaise-login-react#1.1.0", "dotenv": "^10.0.0", @@ -65,11 +61,16 @@ "@babel/preset-react": "^7.14.5", "@babel/preset-typescript": "^7.14.5", "@types/ejs": "^3.0.5", + "@types/express": "^4.17.8", + "@types/jest": "26.0.20", "@types/lodash": "^4.14.168", "@types/multer": "^1.4.5", + "@types/node": "^15.12.2", "@types/number-to-words": "^1.2.0", "@types/pino-http": "^5.4.0", "@types/pino-pretty": "^4.7.1", + "@types/react": "^18.2.51", + "@types/react-dom": "^18.2.18", "@types/react-router-dom": "^5.3.3", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.20.0", @@ -78,6 +79,8 @@ "concurrently": "^7.0.0", "cross-env": "^7.0.2", "eslint": "^8.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", "pino-pretty": "^4.7.1", diff --git a/server/BlaiseAPI/index.ts b/server/BlaiseAPI/index.ts index 8b5e6048..207294a2 100644 --- a/server/BlaiseAPI/index.ts +++ b/server/BlaiseAPI/index.ts @@ -5,7 +5,6 @@ import BlaiseApiClient from "blaise-api-node-client"; export default function BlaiseAPIRouter(config: CustomConfig, auth: Auth, blaiseApiClient: BlaiseApiClient): Router { const router = express.Router(); - router.get("/api/roles", auth.Middleware, async function (req: Request, res: Response) { res.status(200).json(await blaiseApiClient.getUserRoles()); }); @@ -14,7 +13,66 @@ export default function BlaiseAPIRouter(config: CustomConfig, auth: Auth, blaise res.status(200).json(await blaiseApiClient.getUsers()); }); - router.get("/api/change_password/:user", auth.Middleware, async function (req: Request, res: Response) { + router.patch("/api/users/:user/rolesAndPermissions", auth.Middleware, async function (req: Request, res: Response) { + const { role } = req.body; + const user = req.params.user; + let newServerParks = [""]; + let newDefaultServerPark = ""; + + if (!req.params.user || !req.body.role) { + return res.status(400).json("No user or role provided"); + } + + const roleServerParksOverride = config.RoleToServerParksMap[role]; + if (roleServerParksOverride != null) { + newServerParks = roleServerParksOverride; + newDefaultServerPark = roleServerParksOverride[0]; + } else { + const defaultServerPark = config.RoleToServerParksMap["DEFAULT"]; + newServerParks = defaultServerPark; + newDefaultServerPark = defaultServerPark[0]; + } + + try { + await blaiseApiClient.changeUserRole(user, role); + await blaiseApiClient.changeUserServerParks(user, newServerParks, newDefaultServerPark); + const successMessage = `Successfully updated user role and permissions to ${role} for ${user}`; + console.log(successMessage + ` at ${(new Date()).toLocaleTimeString("en-UK")} ${(new Date()).toLocaleDateString("en-UK")}`); + return res.status(200).json({ + message: successMessage + " today at " + (new Date()).toLocaleTimeString("en-UK") + }); + } catch (error) { + const errorMessage = `Error whilst trying to update user role and permissions to ${role} for ${req.params.user}: ${error}`; + console.error(errorMessage); + return res.status(500).json({ + message: errorMessage + }); + } + }); + + router.get("/api/users/:user", auth.Middleware, async function (req: Request, res: Response) { + if (!req.params.user) { + return res.status(400).json("No user provided"); + } + + try { + const user = await blaiseApiClient.getUser(req.params.user); + const successMessage = `Successfully fetched user details for ${req.params.user}`; + return res.status(200).json({ + message: successMessage, + data: user + }); + } catch (error) { + const errorMessage = `Error whilst trying to retrieve user ${req.params.user}: ${error}`; + console.error(errorMessage); + return res.status(500).json({ + message: errorMessage, + error: error + }); + } + }); + + router.get("/api/change-password/:user", auth.Middleware, async function (req: Request, res: Response) { let { password } = req.headers; if (Array.isArray(password)) { diff --git a/server/server.ts b/server/server.ts index 102a4978..2fca2ba6 100644 --- a/server/server.ts +++ b/server/server.ts @@ -59,14 +59,19 @@ export default function GetNodeServer(config: CustomConfig, blaiseApi: BlaiseApi if (!fs.existsSync(indexFilePath)) { indexFilePath = path.join(__dirname, "../public/index.html"); } + server.get("*", function (_req: Request, res: Response) { res.render(indexFilePath); }); - server.use(function (err: Error, _req: Request, res: Response) { - console.error(err.stack); + server.use(function (err, _req, res, _next) { + if (err && err.stack) { + console.error(err.stack); + } else { + console.error("An undefined error occurred"); + } res.render("../views/500.html", {}); }); return server; -} \ No newline at end of file +} diff --git a/server/tests/index.test.ts b/server/tests/index.test.ts index 0e98ab21..351f70d4 100644 --- a/server/tests/index.test.ts +++ b/server/tests/index.test.ts @@ -192,7 +192,7 @@ describe("Test /api/roles GET endpoint", () => { }); }); -describe("Test /api/change_password/:user GET endpoint", () => { +describe("Test /api/change-password/:user GET endpoint", () => { beforeEach(() => { blaiseApiMock.reset(); }); @@ -206,7 +206,7 @@ describe("Test /api/change_password/:user GET endpoint", () => { const password = "password-1234"; blaiseApiMock.setup((api) => api.changePassword(It.isAnyString(), It.isAnyString())).returns(_ => Promise.resolve(null)); - const response = await sut.get("/api/change_password/"+username) + const response = await sut.get("/api/change-password/"+username) .set("password", password); expect(response.statusCode).toEqual(204); @@ -217,7 +217,7 @@ describe("Test /api/change_password/:user GET endpoint", () => { const username = "user1"; const password = ""; - const response = await sut.get("/api/change_password/"+username) + const response = await sut.get("/api/change-password/"+username) .set("password", password); expect(response.statusCode).toEqual(400); @@ -231,7 +231,7 @@ describe("Test /api/change_password/:user GET endpoint", () => { blaiseApiMock.setup((a) => a.changePassword(It.isAnyString(), It.isAnyString())) .returns(_ => Promise.reject(errorMessage)); - const response = await sut.get("/api/change_password/"+username) + const response = await sut.get("/api/change-password/"+username) .set("password", password); expect(response.statusCode).toEqual(500); @@ -239,3 +239,57 @@ describe("Test /api/change_password/:user GET endpoint", () => { expect(response.body).toStrictEqual(errorMessage); }); }); + +describe("PATCH /api/users/:user/rolesAndPermissions endpoint", () => { + beforeEach(() => { + blaiseApiMock.reset(); + }); + + afterAll(() => { + blaiseApiMock.reset(); + }); + + it("should update user role and permissions successfully and return http status 200", async () => { + const user = "testUser"; + const role = "IPS Manager"; + const serverParks = ["gusty", "cma"]; + const defaultServerPark = "gusty"; + blaiseApiMock.setup(api => api.changeUserRole(It.isValue(user), It.isValue(role))) + .returns(async () => null); + blaiseApiMock.setup(api => api.changeUserServerParks(It.isValue(user), It.isValue(serverParks), It.isValue(defaultServerPark))) + .returns(async () => null); + + const response = await sut.patch(`/api/users/${user}/rolesAndPermissions`) + .send({ role }); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toContain(`Successfully updated user role and permissions to ${role} for ${user}`); + blaiseApiMock.verify(api => api.changeUserRole(It.isValue(user), It.isValue(role)), Times.once()); + blaiseApiMock.verify(api => api.changeUserServerParks(It.isValue(user), It.isValue(serverParks), It.isValue(defaultServerPark)), Times.once()); + }); + + it("should return http status BAD_REQUEST_400 if role or user is not provided", async () => { + const user = "testUser"; + const role = ""; + + const response = await sut.patch(`/api/users/${user}/rolesAndPermissions`) + .send({ role }); + + expect(response.statusCode).toEqual(400); + expect(response.body).toEqual("No user or role provided"); + }); + + it("should return http status INTERNAL_SERVER_ERROR_500 if Blaise API client throws an error", async () => { + const user = "testUser"; + const role = "admin"; + const errorMessage = "Blaise API client error"; + blaiseApiMock.setup(api => api.changeUserRole(It.isAny(), It.isAny())) + .returns(async () => { throw new Error(errorMessage); }); + + const response = await sut.patch(`/api/users/${user}/rolesAndPermissions`) + .send({ role }); + + expect(response.statusCode).toEqual(500); + expect(response.body.message).toContain(errorMessage); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index fafa11a5..4545c985 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,18 @@ import React, { ReactElement } from "react"; import { Routes, Route, useLocation, Link } from "react-router-dom"; import Users from "./pages/users/Users"; -import NewUserComponent from "./pages/users/NewUser"; -import ChangePassword from "./pages/users/ChangePassword"; -import DeleteUser from "./pages/users/DeleteUser"; +import NewUserComponent from "./pages/users/UserUpload/NewUser"; +import ChangePassword from "./pages/users/UserProfileEdits/ChangePassword"; +import DeleteUser from "./pages/users/UserProfileEdits/DeleteUser"; import { NotProductionWarning, Footer, Header, BetaBanner, ErrorBoundary, DefaultErrorBoundary } from "blaise-design-system-react-components"; import Roles from "./pages/roles/Roles"; -import BulkUserUpload from "./pages/users/BulkUserUpload/BulkUserUpload"; +import BulkUserUpload from "./pages/users/UserUpload/BulkUserUpload"; import Home from "./pages/Home"; import { User } from "blaise-api-node-client"; import { Authenticate } from "blaise-login-react/blaise-login-react-client"; +import UserProfile from "./pages/users/UserProfileEdits/UserProfile"; +import ChangeRole from "./pages/users/UserProfileEdits/ChangeRole"; +import PageNotFound from "./Components/PageNotFound"; const divStyle = { minHeight: "calc(67vh)" @@ -25,25 +28,42 @@ function App(): ReactElement {
- } /> - } /> + + + + } /> } /> - } /> - - + + - }/> + } /> + + + + } /> + } /> + } /> + }/> + + + + }/> + }/> + + }/>
diff --git a/src/Components/Breadcrumbs.tsx b/src/Components/Breadcrumbs.tsx index 25d92a11..2ac5ece8 100644 --- a/src/Components/Breadcrumbs.tsx +++ b/src/Components/Breadcrumbs.tsx @@ -7,10 +7,10 @@ function Breadcrumbs({ BreadcrumbList }: BreadcrumbProps): ReactElement {