diff --git a/package.json b/package.json index 6d88349e..b7fe7939 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "formik": "2.4.2" }, "dependencies": { + "@google-cloud/logging": "^9.6.0", "@google-cloud/profiler": "^4.1.1", "@testing-library/dom": "^8.3.0", "@testing-library/jest-dom": "^5.16.4", @@ -33,14 +34,15 @@ "@testing-library/user-event": "^13.5.0", "axios": "^1.7.4", "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", + "blaise-design-system-react-components": "git+https://github.com/ONSdigital/blaise-design-system-react-components#0.14.1", + "blaise-login-react": "git+https://github.com/ONSdigital/blaise-login-react#1.1.1", "dotenv": "^10.0.0", "ejs": "^3.1.10", "express": "^4.19.2", "formik": "2.4.2", "history": "^4.9.0", "jest-cucumber": "^3.0.0", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "^1.4.2", "number-to-words": "^1.2.4", diff --git a/server/BlaiseAPI/index.ts b/server/BlaiseAPI/index.ts deleted file mode 100644 index 207294a2..00000000 --- a/server/BlaiseAPI/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import express, { Request, Response, Router } from "express"; -import { CustomConfig } from "../interfaces/server"; -import { Auth } from "blaise-login-react/blaise-login-react-server"; -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()); - }); - - router.get("/api/users", auth.Middleware, async function (req: Request, res: Response) { - res.status(200).json(await blaiseApiClient.getUsers()); - }); - - 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)) { - password = password.join(""); - } - - if (!req.params.user || !password) { - return res.status(400).json(); - } - - blaiseApiClient.changePassword(req.params.user, password).then(() => { - return res.status(204).json(null); - }).catch((error: unknown) => { - console.error(error); - return res.status(500).json(error); - }); - }); - - router.delete("/api/users", auth.Middleware, async function (req: Request, res: Response) { - let { user } = req.headers; - - if (Array.isArray(user)) { - user = user.join(""); - } - - if (!user) { - return res.status(400).json(); - } - return res.status(204).json(await blaiseApiClient.deleteUser(user)); - }); - - router.post("/api/users", auth.Middleware, async function (req: Request, res: Response) { - const data = req.body; - if(!data.role){ - return res.status(400).json(); - } - const roleServerParksOverride = config.RoleToServerParksMap[data.role]; - if (roleServerParksOverride != null) { - data.serverParks = roleServerParksOverride; - data.defaultServerPark = roleServerParksOverride[0]; - } else { - const defaultServerPark = config.RoleToServerParksMap["DEFAULT"]; - data.serverParks = defaultServerPark; - data.defaultServerPark = defaultServerPark[0]; - } - return res.status(200).json(await blaiseApiClient.createUser(data)); - }); - - return router; -} diff --git a/server/index.ts b/server/index.ts index eaef685f..e591782d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,12 +1,11 @@ import GetNodeServer from "./server"; -import pino from "pino"; import { loadConfigFromEnv } from "./Config"; import BlaiseApiClient from "blaise-api-node-client"; import { Auth } from "blaise-login-react/blaise-login-react-server"; import dotenv from "dotenv"; +import createLogger from "./logger/pinoLogger"; const port: string = process.env.PORT || "5002"; -const logger = pino(); if (process.env.NODE_ENV !== "production") { dotenv.config(); @@ -14,7 +13,8 @@ if (process.env.NODE_ENV !== "production") { const config = loadConfigFromEnv(); const blaiseApiClient = new BlaiseApiClient(config.BlaiseApiUrl); const auth = new Auth(config); -const server = GetNodeServer(config, blaiseApiClient, auth); +const pinoLogger = createLogger(); +const server = GetNodeServer(config, blaiseApiClient, auth, pinoLogger); server.listen(port); -logger.info("App is listening on port " + port); +pinoLogger.logger.info("BAM nodejs server is listening on port " + port); diff --git a/server/interfaces/logger.ts b/server/interfaces/logger.ts new file mode 100644 index 00000000..9b46185c --- /dev/null +++ b/server/interfaces/logger.ts @@ -0,0 +1,6 @@ +export interface AuditLog { + id: string; + timestamp: string; + message: string; + severity: string; +} \ No newline at end of file diff --git a/server/interfaces/server.ts b/server/interfaces/server.ts index 9a82c4e5..061cbb87 100644 --- a/server/interfaces/server.ts +++ b/server/interfaces/server.ts @@ -1,5 +1,4 @@ import { AuthConfig } from "blaise-login-react/blaise-login-react-server"; -import { string } from "prop-types"; export interface CustomConfig extends AuthConfig { BlaiseApiUrl: string diff --git a/server/logger/cloudLogging.ts b/server/logger/cloudLogging.ts new file mode 100644 index 00000000..d4ef9961 --- /dev/null +++ b/server/logger/cloudLogging.ts @@ -0,0 +1,59 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Logging } from "@google-cloud/logging"; +import { IncomingMessage } from "http"; +import { AuditLog } from "../interfaces/logger"; + +export function formatLogMessage(text: string): string { + const message = text.replace(/[^\x20-\x7E\r\n]+/g, ""); + const logFormat = "AUDIT_LOG: message"; + return logFormat.replace("message", message); +} + +export default class AuditLogger { + projectId: string; + logger: Logging; + logName: string; + + constructor(projectId: string) { + this.projectId = projectId; + this.logger = new Logging({ projectId: this.projectId }); + this.logName = `projects/${this.projectId}/logs/stdout`; + } + + info(logger: IncomingMessage["log"], message: string): void { + const log = formatLogMessage(message); + logger.info(log); + } + + error(logger: IncomingMessage["log"], message: string): void { + const log = formatLogMessage(message); + logger.error(log); + } + + async getLogs(): Promise { + const auditLogs: AuditLog[] = []; + const log = this.logger.log(this.logName); + const [entries] = await log.getEntries({ filter: "jsonPayload.message=~\"^AUDIT_LOG: \"", maxResults: 50 }); + for (const entry of entries) { + let id = ""; + let timestamp = ""; + let severity = "INFO"; + if (entry.metadata.insertId != null) { + id = entry.metadata.insertId; + } + if (entry.metadata.timestamp != null) { + timestamp = entry.metadata.timestamp.toString(); + } + if (entry.metadata.severity != null) { + severity = entry.metadata.severity.toString(); + } + auditLogs.push({ + id: id, + timestamp: timestamp, + message: entry.data.message.replace(/^AUDIT_LOG: /, ""), + severity: severity + }); + } + return auditLogs; + } +} diff --git a/server/pino/index.ts b/server/logger/pinoLogger.ts similarity index 100% rename from server/pino/index.ts rename to server/logger/pinoLogger.ts diff --git a/server/routes/blaiseApi.ts b/server/routes/blaiseApi.ts new file mode 100644 index 00000000..b7695e69 --- /dev/null +++ b/server/routes/blaiseApi.ts @@ -0,0 +1,148 @@ +import express, { Request, Response, Router } from "express"; +import { CustomConfig } from "../interfaces/server"; +import { Auth } from "blaise-login-react/blaise-login-react-server"; +import BlaiseApiClient from "blaise-api-node-client"; +import AuditLogger from "../logger/cloudLogging"; + +export default function blaiseApi(config: CustomConfig, auth: Auth, blaiseApiClient: BlaiseApiClient, auditLogger: AuditLogger): Router { + const router = express.Router(); + + router.get("/api/roles", auth.Middleware, async function (req: Request, res: Response) { + res.status(200).json(await blaiseApiClient.getUserRoles()); + }); + + router.get("/api/users", auth.Middleware, async function (req: Request, res: Response) { + res.status(200).json(await blaiseApiClient.getUsers()); + }); + + router.patch("/api/users/:user/rolesAndPermissions", auth.Middleware, async function (req: Request, res: Response) { + const currentUser = auth.GetUser(auth.GetToken(req)); + const { role } = req.body; + const user = req.params.user; + let newServerParks: string[]; + let newDefaultServerPark: string; + + if (!user || !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 = `${currentUser.name || "Unknown user"} has successfully updated user role and permissions to ${role} for ${user}`; + auditLogger.info(req.log, successMessage); + return res.status(200).json({ + message: "Successfully updated user role and permissions to " + role + " for " + user + }); + } catch (error) { + const errorMessage = `Error whilst trying to update user role and permissions to ${role} for ${req.params.user}, with error message: ${error}`; + auditLogger.info(req.log, `${currentUser.name || "Unknown user"} has failed to update user role and permissions to ${role} for ${user}`); + auditLogger.error(req.log, errorMessage); + return res.status(500).json({ + message: "Failed to update user role and permissions to " + role + " for " + user + }); + } + }); + + 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}`; + return res.status(500).json({ + message: errorMessage, + error: error + }); + } + }); + + router.get("/api/change-password/:user", auth.Middleware, async function (req: Request, res: Response) { + const currentUser = auth.GetUser(auth.GetToken(req)); + let { password } = req.headers; + + if (Array.isArray(password)) { + password = password.join(""); + } + + if (!req.params.user || !password) { + return res.status(400).json("No user or password provided"); + } + + blaiseApiClient.changePassword(req.params.user, password).then(() => { + auditLogger.info(req.log, `${currentUser.name || "Unknown"} has successfully changed the password for ${req.params.user}`); + return res.status(204).json(null); + }).catch((error: unknown) => { + auditLogger.info(req.log, `${currentUser.name || "Unknown"} has failed to change the password for ${req.params.user}`); + auditLogger.error(req.log, `Error whilst trying to change password for ${req.params.user}: ${error}`); + return res.status(500).json(error); + }); + }); + + router.delete("/api/users", auth.Middleware, async function (req: Request, res: Response) { + try { + const currentUser = auth.GetUser(auth.GetToken(req)); + let { user } = req.headers; + + if (Array.isArray(user)) { + user = user.join(""); + } + + if (!user) { + auditLogger.error(req.log, "No user provided for deletion"); + return res.status(400).json(); + } + auditLogger.info(req.log, `${currentUser.name || "Unknown"} has successfully deleted user called ${user}`); + return res.status(204).json(await blaiseApiClient.deleteUser(user)); + } catch (error) { + auditLogger.error(req.log, `Error whilst trying to delete user, ${req.headers.user}, with error message: ${error}`); + return res.status(500).json(error); + } + }); + + router.post("/api/users", auth.Middleware, async function (req: Request, res: Response) { + try { + const currentUser = auth.GetUser(auth.GetToken(req)); + const data = req.body; + + if(!data.role) { + return res.status(400).json({ message: "No role provided for user creation" }); + } + + const roleServerParksOverride = config.RoleToServerParksMap[data.role]; + if (roleServerParksOverride != null) { + data.serverParks = roleServerParksOverride; + data.defaultServerPark = roleServerParksOverride[0]; + } else { + const defaultServerPark = config.RoleToServerParksMap["DEFAULT"]; + data.serverParks = defaultServerPark; + data.defaultServerPark = defaultServerPark[0]; + } + auditLogger.info(req.log, `${currentUser.name || "Unknown"} has successfully created user, ${data.name}, with an assigned role of ${data.role}`); + return res.status(200).json(await blaiseApiClient.createUser(data)); + } catch (error) { + auditLogger.error(req.log, `Error whilst trying to create new user, ${req.body.name}, with error message: ${error}`); + return res.status(500).json(error); + } + }); + + return router; +} diff --git a/server/server.ts b/server/server.ts index 2fca2ba6..a82ef9d1 100644 --- a/server/server.ts +++ b/server/server.ts @@ -2,50 +2,43 @@ import express, { Request, Response } from "express"; import axios from "axios"; import path from "path"; import ejs from "ejs"; -import createLogger from "./pino"; -import BlaiseAPIRouter from "./BlaiseAPI"; import multer from "multer"; import * as profiler from "@google-cloud/profiler"; import { newLoginHandler, Auth } from "blaise-login-react/blaise-login-react-server"; -import pino from "pino"; import { CustomConfig } from "./interfaces/server"; import BlaiseApi from "blaise-api-node-client"; import { Express } from "express"; import fs from "fs"; +import AuditLogger from "./logger/cloudLogging"; +import blaiseApi from "./routes/blaiseApi"; +import { HttpLogger } from "pino-http"; -export default function GetNodeServer(config: CustomConfig, blaiseApi: BlaiseApi, auth: Auth): Express +export default function GetNodeServer(config: CustomConfig, blaiseApiClient: BlaiseApi, auth: Auth, logger: HttpLogger): Express { - const pinoLogger = pino(); - profiler.start({ logLevel: 4 }).catch((err: unknown) => { - pinoLogger.error(`Failed to start profiler: ${err}`); - }); - + const auditLogger = new AuditLogger(config.ProjectId); + const pinoLogger = logger; const upload = multer(); - const server = express(); + // where ever the react built package is + const buildFolder = "../build"; server.use(upload.any()); - + server.use(pinoLogger); axios.defaults.timeout = 10000; - const logger = createLogger(); - server.use(logger); + const loginRouter = newLoginHandler(auth, blaiseApiClient); + const blaiseApiRouter = blaiseApi(config, auth, blaiseApiClient, auditLogger); - // where ever the react built package is - const buildFolder = "../build"; - - const loginHandler = newLoginHandler(auth, blaiseApi); - - // Health Check endpoint + profiler.start({ logLevel: 4 }).catch((err: unknown) => { + pinoLogger.logger.info(`Failed to start GCP profiler: ${err}`); + }); + // GCP health check server.get("/bam-ui/:version/health", async function (req: Request, res: Response) { - pinoLogger.info("Heath Check endpoint called"); + auditLogger.info(req.log, "Heath Check endpoint called"); res.status(200).json({ healthy: true }); }); - - server.use("/", loginHandler); - - // All Endpoints calling the Blaise API - server.use("/", BlaiseAPIRouter(config, auth, blaiseApi)); + server.use("/", loginRouter); + server.use("/", blaiseApiRouter); // treat the index.html as a template and substitute the values at runtime server.set("views", path.join(__dirname, "/views")); @@ -66,8 +59,10 @@ export default function GetNodeServer(config: CustomConfig, blaiseApi: BlaiseApi server.use(function (err, _req, res, _next) { if (err && err.stack) { + auditLogger.error(res, err.stack); console.error(err.stack); } else { + auditLogger.error(res, "An undefined error occurred"); console.error("An undefined error occurred"); } res.render("../views/500.html", {}); diff --git a/server/tests/index.test.ts b/server/tests/index.test.ts index 351f70d4..169dc612 100644 --- a/server/tests/index.test.ts +++ b/server/tests/index.test.ts @@ -4,40 +4,66 @@ import supertest from "supertest"; import GetNodeServer from "../server"; -import { loadConfigFromEnv } from "../Config"; -import BlaiseApiClient, { NewUser, User, UserRole } from "blaise-api-node-client"; +import BlaiseApiClient from "blaise-api-node-client"; import { Auth } from "blaise-login-react/blaise-login-react-server"; -import { IMock, Mock, It, Times } from "typemoq"; +import { IMock, Mock } from "typemoq"; +import pino from "pino"; +import { HttpLogger } from "pino-http"; +import { loadConfigFromEnv } from "../Config"; +// eslint-disable-next-line import/no-extraneous-dependencies +import createLogger from "../logger/pinoLogger"; // Temporary fix for Jest open handle issue (gcp profiler TCPWRAP error) jest.mock("@google-cloud/profiler", () => ({ start: jest.fn().mockReturnValue(Promise.resolve()) })); +const mockSecret = "super-secret-key"; +process.env = Object.assign(process.env, { + SESSION_SECRET: mockSecret, + PROJECT_ID: "mockProjectId", + BLAISE_API_URL: "http://blaise-api", + SERVER_PARK: "mockServerPark", + SESSION_TIMEOUT: "12h" +}); const config = loadConfigFromEnv(); -const blaiseApiMock: IMock = Mock.ofType(BlaiseApiClient); -Auth.prototype.ValidateToken = jest.fn().mockReturnValue(true); const auth = new Auth(config); -const server = GetNodeServer(config, blaiseApiMock.object, auth); + +const logger: pino.Logger = pino(); +logger.child = jest.fn(() => logger); +const logInfo = jest.spyOn(logger, "info"); +const httpLogger: HttpLogger = createLogger({ logger: logger }); + +const blaiseApiMock: IMock = Mock.ofType(BlaiseApiClient); + +const server = GetNodeServer(config, blaiseApiMock.object, auth, httpLogger); const sut = supertest(server); -describe("Test Heath Endpoint", () => { +describe("GCP health check", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it("should return a 200 status and json message", async () => { const response = await sut.get("/bam-ui/version/health"); + const log = logInfo.mock.calls[0][0]; + expect(log).toEqual("AUDIT_LOG: Heath Check endpoint called"); expect(response.statusCode).toEqual(200); expect(response.body).toStrictEqual({ healthy: true }); }); }); -describe("app engine start", () => { +describe("GCP app engine start", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it("should return a 200 status and json message", async () => { process.env = Object.assign({ - PROJECT_ID: "mock", - BLAISE_API_URL: "http://mock", - SERVER_PARK: "mock", + PROJECT_ID: "mockProjectId", + BLAISE_API_URL: "http://blaise-api", + SERVER_PARK: "mockServerPark", SESSION_TIMEOUT: "12h" }); @@ -45,251 +71,4 @@ describe("app engine start", () => { expect(response.statusCode).toEqual(200); }); -}); - -import role_to_serverparks_map from "../role-to-serverparks-map.json"; -import { size } from "lodash"; -import { Exception } from "typemoq/Error/Exception"; -import { Errback } from "express"; -describe("Test /api/users POST endpoint", () => { - it("should call Blaise API createUser endpoint with correct serverParks for each role EXISTING in server/role-to-serverparks-map.json AND return http status OK_200", async () => { - let currentRoleNo = 0; - const totalRoleCount = size(role_to_serverparks_map); - for(const roleName in role_to_serverparks_map) - { - blaiseApiMock.reset(); - console.log("Running for role %i of %i: %s", ++currentRoleNo, totalRoleCount, roleName); - - const spmap = role_to_serverparks_map[roleName]; - const newUser : NewUser = { - name: "name1", - password: "password1", - role: roleName, - serverParks: spmap, - defaultServerPark: spmap[0] - }; - blaiseApiMock.setup((api) => api.createUser(It.isAny())).returns(async () => newUser); - - const response = await sut.post("/api/users") - .field("role", roleName); - - expect(response.statusCode).toEqual(200); - blaiseApiMock.verify(a => a.createUser(It.is( - x=> x.defaultServerPark == newUser.defaultServerPark - && x.role == newUser.role - && Array.isArray(x.serverParks) && x.serverParks.every(item => typeof item === "string") - && x.serverParks.every((val, idx) => val === newUser.serverParks[idx]) - )), Times.exactly(1)); - expect(response.body).toStrictEqual(newUser); - } - }); - - it("should call Blaise API createUser endpoint with DEFAULT serverParks for a role MISSING in server/role-to-serverparks-map.json AND return http status OK_200)", async () => { - const roleName = "this role is missing in server/role-to-serverparks-map.json file"; - const spmap = role_to_serverparks_map.DEFAULT; - const newUser : NewUser = { - name: "name1", - password: "password1", - role: roleName, - serverParks: spmap, - defaultServerPark: spmap[0] - }; - blaiseApiMock.setup((api) => api.createUser(It.isAny())).returns(async () => newUser); - - const response = await sut.post("/api/users") - .field("role", roleName); - - expect(response.statusCode).toEqual(200); - blaiseApiMock.verify(a => a.createUser(It.is( - x=> x.defaultServerPark == newUser.defaultServerPark - && x.role == newUser.role - && Array.isArray(x.serverParks) && x.serverParks.every(item => typeof item === "string") - && x.serverParks.every((val, idx) => val === newUser.serverParks[idx]) - )), Times.exactly(1)); - expect(response.body).toStrictEqual(newUser); - }); - - it("should return http status BAD_REQUEST_400 if role is empty OR hasn't been specified in the request", async () => { - let response = await sut.post("/api/users") - .field("role", ""); - expect(response.statusCode).toEqual(400); - - response = await sut.post("/api/users"); - expect(response.statusCode).toEqual(400); - }); -}); - -describe("Test /api/users DELETE endpoint", () => { - beforeEach(() => { - blaiseApiMock.reset(); - }); - - afterAll(() => { - blaiseApiMock.reset(); - }); - - it("should call Blaise API deleteUser endpoint for VALID user header field AND return http status NO_CONTENT_204", async () => { - blaiseApiMock.setup((api) => api.deleteUser(It.isAny())).returns(_ => Promise.resolve(null)); - const username = "user-123"; - const response = await sut.delete("/api/users") - .set("user", username); - - expect(response.statusCode).toEqual(204); - blaiseApiMock.verify(a => a.deleteUser(It.isValue(username)), Times.once()); - }); - - it("should call Blaise API deleteUser endpoint for INVALID or MISSING user header field AND return http status BAD_REQUEST_400", async () => { - let response = await sut.delete("/api/users") - .set("user", ""); - expect(response.statusCode).toEqual(400); - - response = await sut.delete("/api/users"); - expect(response.statusCode).toEqual(400); - }); -}); - -describe("Test /api/users GET endpoint", () => { - it("should call Blaise API getUsers endpoint AND return http status OK_200", async () => { - const newUser1 : NewUser = { - name: "name1", - password: "password1", - role: "role1", - serverParks: ["sp1", "sp2"], - defaultServerPark: "sp1" - }; - const newUser2 = newUser1; - newUser2.name = "name2"; - const newUser3 = newUser2; - newUser3.name = "name3"; - const userArray : NewUser [] = [newUser1, newUser2, newUser3]; - blaiseApiMock.setup((api) => api.getUsers()).returns(_ => Promise.resolve(userArray)); - - const response = await sut.get("/api/users"); - - expect(response.statusCode).toEqual(200); - blaiseApiMock.verify(a => a.getUsers(), Times.once()); - }); -}); - -describe("Test /api/roles GET endpoint", () => { - it("should call Blaise API getUserRoles endpoint AND return http status OK_200", async () => { - const userRole1 : UserRole = { - name: "name1", - description: "desc1", - permissions: ["perm1", "perm2"] - }; - const userRole2 = userRole1; - userRole2.name = "name2"; - const userRole3 = userRole2; - userRole3.name = "name3"; - const userRoleArray : UserRole [] = [userRole1, userRole2, userRole3]; - blaiseApiMock.setup((api) => api.getUserRoles()).returns(_ => Promise.resolve(userRoleArray)); - - const response = await sut.get("/api/roles"); - - expect(response.statusCode).toEqual(200); - blaiseApiMock.verify(a => a.getUserRoles(), Times.once()); - }); -}); - -describe("Test /api/change-password/:user GET endpoint", () => { - beforeEach(() => { - blaiseApiMock.reset(); - }); - - afterAll(() => { - blaiseApiMock.reset(); - }); - - it("should call Blaise API changePassword endpoint for VALID request AND return http status NO_CONTENT_204", async () => { - const username = "user1"; - 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) - .set("password", password); - - expect(response.statusCode).toEqual(204); - blaiseApiMock.verify(a => a.changePassword(It.isValue(username), It.isValue(password)), Times.once()); - }); - - it("should NOT call Blaise API changePassword endpoint for INVALID request AND return http status BAD_REQUEST_400", async () => { - const username = "user1"; - const password = ""; - - const response = await sut.get("/api/change-password/"+username) - .set("password", password); - - expect(response.statusCode).toEqual(400); - blaiseApiMock.verify(a => a.changePassword(It.isAnyString(), It.isAnyString()), Times.never()); - }); - - it("should return error message if external Blaise API changePassword endpoint rejects request with error message AND should return http status INTERNAL_SERVER_ERROR_500", async () => { - const username = "user1"; - const password = "password-1234"; - const errorMessage = "Error occured when calling changePassword on Blaise API Rest Service"; - blaiseApiMock.setup((a) => a.changePassword(It.isAnyString(), It.isAnyString())) - .returns(_ => Promise.reject(errorMessage)); - - const response = await sut.get("/api/change-password/"+username) - .set("password", password); - - expect(response.statusCode).toEqual(500); - blaiseApiMock.verify(a => a.changePassword(It.isAnyString(), It.isAnyString()), Times.once()); - 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); - }); -}); +}); \ No newline at end of file diff --git a/server/tests/logger/cloudLogging.test.ts b/server/tests/logger/cloudLogging.test.ts new file mode 100644 index 00000000..dc7f7763 --- /dev/null +++ b/server/tests/logger/cloudLogging.test.ts @@ -0,0 +1,26 @@ +import { formatLogMessage } from "../../logger/cloudLogging"; + +describe("formatLogMessage utility function ensures complex log messages are sanitized but still readable", () => { + it("should preserve newlines and carriage returns", () => { + const inputMessage = "Error: Something went wrong\nDetails: Invalid input\r\nPlease try again."; + const expectedOutput = "AUDIT_LOG: Error: Something went wrong\nDetails: Invalid input\r\nPlease try again."; + + const formattedMessage = formatLogMessage(inputMessage); + + console.log("Expected log format:", JSON.stringify(expectedOutput)); + console.log("Received log format:", JSON.stringify(formattedMessage)); + + expect(formattedMessage).toBe(expectedOutput); + }); + + it("should remove non-printable characters", () => { + const message = "Error: Something went wrong\x01\x02\n at FunctionName (file.js:10:15)\x03\x04\n at AnotherFunction (file.js:20:25)\r\n at YetAnotherFunction (file.js:30:35)"; + const expectedOutput = "AUDIT_LOG: Error: Something went wrong\n at FunctionName (file.js:10:15)\n at AnotherFunction (file.js:20:25)\r\n at YetAnotherFunction (file.js:30:35)"; + const formattedMessage = formatLogMessage(message); + + console.log("Expected log format:", JSON.stringify(expectedOutput)); + console.log("Received log format:", JSON.stringify(formattedMessage)); + + expect(formattedMessage).toBe(expectedOutput); + }); +}); \ No newline at end of file diff --git a/server/tests/routes/blaiseApi.test.ts b/server/tests/routes/blaiseApi.test.ts new file mode 100644 index 00000000..96430ab8 --- /dev/null +++ b/server/tests/routes/blaiseApi.test.ts @@ -0,0 +1,384 @@ +/** + * @jest-environment node + */ + +import supertest from "supertest"; +import GetNodeServer from "../../server"; +import BlaiseApiClient, { NewUser, User, UserRole } from "blaise-api-node-client"; +import { Auth } from "blaise-login-react/blaise-login-react-server"; +import { IMock, Mock, It, Times } from "typemoq"; +import role_to_serverparks_map from "../../role-to-serverparks-map.json"; +import { size } from "lodash"; +import createLogger from "../../logger/pinoLogger"; +import pino from "pino"; +import { HttpLogger } from "pino-http"; +import { loadConfigFromEnv } from "../../Config"; +// eslint-disable-next-line import/no-extraneous-dependencies +import jwt from "jsonwebtoken"; + +// Temporary fix for Jest open handle issue (gcp profiler TCPWRAP error) +jest.mock("@google-cloud/profiler", () => ({ + start: jest.fn().mockReturnValue(Promise.resolve()) +})); + +const mockSecret = "super-secret-key"; +process.env = Object.assign(process.env, { + SESSION_SECRET: mockSecret, + PROJECT_ID: "mockProjectId", + BLAISE_API_URL: "http://blaise-api", + SERVER_PARK: "mockServerPark", + SESSION_TIMEOUT: "12h" +}); +const config = loadConfigFromEnv(); +const auth = new Auth(config); +const mockUser: User = { name: "testUser", role: "DST", defaultServerPark: "park1", serverParks: ["park1", "park2"] }; +const mockAuthToken = jwt.sign({ "user": mockUser }, mockSecret); + +const logger: pino.Logger = pino(); +logger.child = jest.fn(() => logger); +const logInfo = jest.spyOn(logger, "info"); +const logError = jest.spyOn(logger, "error"); +const httpLogger: HttpLogger = createLogger({ logger: logger }); + +const blaiseApiMock: IMock = Mock.ofType(BlaiseApiClient); + +const server = GetNodeServer(config, blaiseApiMock.object, auth, httpLogger); +const sut = supertest(server); + +// Blaise API endpoints +describe("POST /api/users endpoint", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call Blaise API createUser endpoint with correct serverParks for each role EXISTING in server/role-to-serverparks-map.json AND return http status OK_200", async () => { + let currentRoleNo = 0; + const totalRoleCount = size(role_to_serverparks_map); + for (const roleName in role_to_serverparks_map) + { + logInfo.mockReset(); + blaiseApiMock.reset(); + console.log("Running for role %i of %i: %s", ++currentRoleNo, totalRoleCount, roleName); + + const spmap = role_to_serverparks_map[roleName]; + const newUser : NewUser = { + name: "name1", + password: "password1", + role: roleName, + serverParks: spmap, + defaultServerPark: spmap[0] + }; + blaiseApiMock.setup((api) => api.createUser(It.isAny())).returns(async () => newUser); + + const response = await sut.post("/api/users") + .set("Authorization", `${mockAuthToken}`) + .field("name", newUser.name) + .field("role", roleName); + + expect(logInfo.mock.calls[0][0]).toEqual(`AUDIT_LOG: ${mockUser.name} has successfully created user, ${newUser.name}, with an assigned role of ${roleName}`); + expect(response.statusCode).toEqual(200); + blaiseApiMock.verify(a => a.createUser(It.is( + x=> x.defaultServerPark == newUser.defaultServerPark + && x.role == newUser.role + && Array.isArray(x.serverParks) && x.serverParks.every(item => typeof item === "string") + && x.serverParks.every((val, idx) => val === newUser.serverParks[idx]) + )), Times.exactly(1)); + expect(response.body).toStrictEqual(newUser); + } + }); + + it("should call Blaise API createUser endpoint with DEFAULT serverParks for a role MISSING in server/role-to-serverparks-map.json AND return http status OK_200)", async () => { + const roleName = "this role is missing in server/role-to-serverparks-map.json file"; + const spmap = role_to_serverparks_map.DEFAULT; + const newUser : NewUser = { + name: "name1", + password: "password1", + role: roleName, + serverParks: spmap, + defaultServerPark: spmap[0] + }; + blaiseApiMock.setup((api) => api.createUser(It.isAny())).returns(async () => newUser); + + const response = await sut.post("/api/users") + .field("name", newUser.name) + .field("role", roleName) + .set("Authorization", `${mockAuthToken}`); + + const log = logInfo.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: ${mockUser.name} has successfully created user, ${newUser.name}, with an assigned role of ${roleName}`); + expect(response.statusCode).toEqual(200); + blaiseApiMock.verify(a => a.createUser(It.is( + x=> x.defaultServerPark == newUser.defaultServerPark + && x.role == newUser.role + && Array.isArray(x.serverParks) && x.serverParks.every(item => typeof item === "string") + && x.serverParks.every((val, idx) => val === newUser.serverParks[idx]) + )), Times.exactly(1)); + expect(response.body).toStrictEqual(newUser); + }); + + it("should return http status BAD_REQUEST_400 if role is empty OR hasn't been specified in the request", async () => { + let response = await sut.post("/api/users") + .field("role", "") + .set("Authorization", `${mockAuthToken}`); + + expect(response.statusCode).toEqual(400); + expect(response.body.message).toEqual("No role provided for user creation"); + + response = await sut.post("/api/users") + .set("Authorization", `${mockAuthToken}`); + expect(response.statusCode).toEqual(400); + expect(response.body.message).toEqual("No role provided for user creation"); + }); + + it("should return http status INTERNAL_SERVER_ERROR_500 if Blaise API client throws an error", async () => { + const roleName = "IPS Manager"; + const spmap = role_to_serverparks_map.DEFAULT; + const newUser : NewUser = { + name: "name1", + password: "password1", + role: roleName, + serverParks: spmap, + defaultServerPark: spmap[0] + }; + const errorMessage = "Blaise API client error"; + blaiseApiMock.setup((api) => api.createUser(It.isAny())).throws(new Error(errorMessage)); + + const response = await sut.post("/api/users") + .set("Authorization", `${mockAuthToken}`) + .field("name", newUser.name) + .field("role", roleName); + + const log = logError.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: Error whilst trying to create new user, ${newUser.name}, with error message: Error: ${errorMessage}`); + expect(response.statusCode).toEqual(500); + }); +}); + +describe("DELETE /api/users endpoint", () => { + beforeEach(() => { + blaiseApiMock.reset(); + jest.clearAllMocks(); + }); + + afterAll(() => { + blaiseApiMock.reset(); + }); + + it("should call Blaise API deleteUser endpoint for VALID user header field AND return http status NO_CONTENT_204", async () => { + blaiseApiMock.setup((api) => api.deleteUser(It.isAny())).returns(_ => Promise.resolve(null)); + const username = "user-123"; + const response = await sut.delete("/api/users") + .set("Authorization", `${mockAuthToken}`) + .set("user", username); + + const log = logInfo.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: ${mockUser.name} has successfully deleted user called ${username}`); + expect(response.statusCode).toEqual(204); + blaiseApiMock.verify(a => a.deleteUser(It.isValue(username)), Times.once()); + }); + + it("should return error message if external Blaise API deleteUser endpoint rejects request with error message AND should return http status INTERNAL_SERVER_ERROR_500", async () => { + const username = "user-123"; + const errorMessage = "Error occured when calling deleteUser on Blaise API Rest Service"; + blaiseApiMock.setup((a) => a.deleteUser(It.isAnyString())) + .returns(_ => Promise.reject(errorMessage)); + + const response = await sut.delete("/api/users") + .set("Authorization", `${mockAuthToken}`) + .set("user", username); + + const log = logError.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: Error whilst trying to delete user, ${username}, with error message: ${errorMessage}`); + expect(response.statusCode).toEqual(500); + blaiseApiMock.verify(a => a.deleteUser(It.isValue(username)), Times.once()); + expect(response.body).toStrictEqual(errorMessage); + }); + + it("should call Blaise API deleteUser endpoint for INVALID or MISSING user header field AND return http status BAD_REQUEST_400", async () => { + let response = await sut.delete("/api/users") + .set("Authorization", `${mockAuthToken}`) + .set("user", ""); + + const log = logError.mock.calls[0][0]; + expect(log).toEqual("AUDIT_LOG: No user provided for deletion"); + expect(response.statusCode).toEqual(400); + + response = await sut.delete("/api/users") + .set("Authorization", `${mockAuthToken}`); + expect(response.statusCode).toEqual(400); + }); +}); + +describe("GET /api/users endpoint", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call Blaise API getUsers endpoint AND return http status OK_200", async () => { + const newUser1 : NewUser = { + name: "name1", + password: "password1", + role: "role1", + serverParks: ["sp1", "sp2"], + defaultServerPark: "sp1" + }; + const newUser2 = newUser1; + newUser2.name = "name2"; + const newUser3 = newUser2; + newUser3.name = "name3"; + const userArray : NewUser [] = [newUser1, newUser2, newUser3]; + blaiseApiMock.setup((api) => api.getUsers()).returns(_ => Promise.resolve(userArray)); + + const response = await sut.get("/api/users") + .set("Authorization", `${mockAuthToken}`); + + expect(response.statusCode).toEqual(200); + blaiseApiMock.verify(a => a.getUsers(), Times.once()); + }); +}); + +describe("GET /api/roles endpoint", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should call Blaise API getUserRoles endpoint AND return http status OK_200", async () => { + const userRole1 : UserRole = { + name: "name1", + description: "desc1", + permissions: ["perm1", "perm2"] + }; + const userRole2 = userRole1; + userRole2.name = "name2"; + const userRole3 = userRole2; + userRole3.name = "name3"; + const userRoleArray : UserRole [] = [userRole1, userRole2, userRole3]; + blaiseApiMock.setup((api) => api.getUserRoles()).returns(_ => Promise.resolve(userRoleArray)); + + const response = await sut.get("/api/roles") + .set("Authorization", `${mockAuthToken}`); + + expect(response.statusCode).toEqual(200); + blaiseApiMock.verify(a => a.getUserRoles(), Times.once()); + }); +}); + +describe("GET /api/change-password/:user endpoint", () => { + beforeEach(() => { + blaiseApiMock.reset(); + jest.clearAllMocks(); + }); + + afterAll(() => { + blaiseApiMock.reset(); + }); + + it("should call Blaise API changePassword endpoint for VALID request AND return http status NO_CONTENT_204", async () => { + const username = "user1"; + 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) + .set("Authorization", `${mockAuthToken}`) + .set("password", password); + + const log = logInfo.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: ${mockUser.name} has successfully changed the password for ${username}`); + expect(response.statusCode).toEqual(204); + blaiseApiMock.verify(a => a.changePassword(It.isValue(username), It.isValue(password)), Times.once()); + }); + + it("should NOT call Blaise API changePassword endpoint for INVALID request AND return http status BAD_REQUEST_400", async () => { + const username = "user1"; + const password = ""; + + const response = await sut.get("/api/change-password/"+ username) + .set("Authorization", `${mockAuthToken}`) + .set("password", password); + + expect(response.statusCode).toEqual(400); + blaiseApiMock.verify(a => a.changePassword(It.isAnyString(), It.isAnyString()), Times.never()); + }); + + it("should return error message if external Blaise API changePassword endpoint rejects request with error message AND should return http status INTERNAL_SERVER_ERROR_500", async () => { + const username = "user1"; + const password = "password-1234"; + const errorMessage = "Error occured when calling changePassword on Blaise API Rest Service"; + blaiseApiMock.setup((a) => a.changePassword(It.isAnyString(), It.isAnyString())) + .returns(_ => Promise.reject(errorMessage)); + + const response = await sut.get("/api/change-password/"+ username) + .set("Authorization", `${mockAuthToken}`) + .set("password", password); + + const log = logError.mock.calls[0][0]; + expect(log).toEqual(`AUDIT_LOG: Error whilst trying to change password for ${username}: ${errorMessage}`); + expect(response.statusCode).toEqual(500); + blaiseApiMock.verify(a => a.changePassword(It.isAnyString(), It.isAnyString()), Times.once()); + expect(response.body).toStrictEqual(errorMessage); + }); +}); + +describe("PATCH /api/users/:user/rolesAndPermissions endpoint", () => { + beforeEach(() => { + blaiseApiMock.reset(); + jest.clearAllMocks(); + }); + + 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`) + .set("Authorization", `${mockAuthToken}`) + .send({ role }); + + const log = logInfo.mock.calls[0][0]; + expect(log).toEqual("AUDIT_LOG: testUser has successfully updated user role and permissions to IPS Manager for testUser"); + 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`) + .set("Authorization", `${mockAuthToken}`) + .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`) + .set("Authorization", `${mockAuthToken}`) + .send({ role }); + + const infoLog = logInfo.mock.calls[0][0]; + const errorLog = logError.mock.calls[0][0]; + expect(infoLog).toEqual(`AUDIT_LOG: ${mockUser.name} has failed to update user role and permissions to ${role} for ${user}`); + expect(errorLog).toEqual(`AUDIT_LOG: Error whilst trying to update user role and permissions to admin for ${mockUser.name}, with error message: Error: ${errorMessage}`); + expect(response.statusCode).toEqual(500); + expect(response.body.message).toContain("Failed to update user role and permissions to admin for testUser"); + }); +}); \ No newline at end of file diff --git a/src/ClientConfig.ts b/src/ClientConfig.ts index 9157e5f2..792561d5 100644 --- a/src/ClientConfig.ts +++ b/src/ClientConfig.ts @@ -1,4 +1,5 @@ export interface ClientConfig { + DefaultServerPark: string; RoleToServerParksMap: { [key: string]: string[] } ; } import role_to_serverparks_map_json from "./role-to-serverparks-map.json"; @@ -6,6 +7,7 @@ import role_to_serverparks_map_json from "./role-to-serverparks-map.json"; export function loadConfigFromEnv(): ClientConfig { const roleToServerParksMap: { [key: string]: string[] } = role_to_serverparks_map_json; return { + DefaultServerPark: roleToServerParksMap["DEFAULT"][0], RoleToServerParksMap: roleToServerParksMap }; } \ No newline at end of file diff --git a/src/pages/users/UserProfileEdits/ChangePassword.test.tsx b/src/pages/users/UserProfileEdits/ChangePassword.test.tsx index 02c04f68..9efe36b0 100644 --- a/src/pages/users/UserProfileEdits/ChangePassword.test.tsx +++ b/src/pages/users/UserProfileEdits/ChangePassword.test.tsx @@ -16,7 +16,7 @@ jest.mock("react-router-dom", () => ({ jest.mock("blaise-login-react/blaise-login-react-client", () => ({ AuthManager: jest.fn().mockImplementation(() => ({ authHeader: () => ({ - Authorization: "Bearer " + process.env.MOCK_AUTH_TOKEN + Authorization: process.env.MOCK_AUTH_TOKEN }) })) })); @@ -42,7 +42,10 @@ beforeEach(() => { (useParams as jest.Mock).mockReturnValue({ user: mockUserDetails.name }); }); -afterEach(() => cleanup()); +afterEach(() => { + jest.clearAllMocks(); + cleanup(); +}); describe("ChangePassword Component", () => { it("matches the snapshot", async () => { @@ -55,6 +58,26 @@ describe("ChangePassword Component", () => { expect(asFragment()).toMatchSnapshot(); }); + it("displays error message when passwords are empty", async () => { + const { findByText, getByLabelText, getByText } = render( + + + + ); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + const saveButton = getByText("Save"); + + act(() => { + userEvent.type(newPasswordInput, ""); + userEvent.type(confirmPasswordInput, ""); + userEvent.click(saveButton); + }); + + expect(await findByText(/Passwords cannot be blank/i)).toBeVisible(); + }); + it("displays error message when passwords do not match", async () => { const { findByText, getByLabelText, getByText } = render( @@ -68,13 +91,39 @@ describe("ChangePassword Component", () => { act(() => { userEvent.type(newPasswordInput, "password123"); - userEvent.type(confirmPasswordInput, "password321"); + userEvent.type(confirmPasswordInput, "password321333"); userEvent.click(saveButton); }); expect(await findByText(/Passwords do not match/i)).toBeVisible(); }); + it("calls fetch with correct parameters upon form submission with matching passwords that remove any trailing whitespaces", async () => { + const { getByLabelText, getByText } = render( + + + + ); + + const newPasswordInput = getByLabelText("New password"); + const confirmPasswordInput = getByLabelText("Confirm password"); + const saveButton = getByText("Save"); + + // Wait for state update + act(() => { + userEvent.type(newPasswordInput, "password123 "); + userEvent.type(confirmPasswordInput, "password123 "); + userEvent.click(saveButton); + }); + + expect(fetch).toHaveBeenCalledWith("/api/change-password/testUser", { + headers: { + Authorization: process.env.MOCK_AUTH_TOKEN, + password: "password123" + } + }); + }); + it("calls fetch with correct parameters upon form submission with matching passwords", async () => { const { getByLabelText, getByText, findByText } = render( @@ -87,19 +136,17 @@ describe("ChangePassword Component", () => { const saveButton = getByText("Save"); // Wait for state update - act(async () => { + act(() => { userEvent.type(newPasswordInput, "password123"); userEvent.type(confirmPasswordInput, "password123"); userEvent.click(saveButton); }); - // Improvement: Figure out why the fetch function is not being called - // expect(fetch).toHaveBeenCalledTimes(1); - // expect(fetch).toHaveBeenCalledWith("/api/change-password/testUser", { - // "headers": { - // "Authorization": "Bearer " + process.env.MOCK_AUTH_TOKEN, - // "password": "password123" - // } - // }); + expect(fetch).toHaveBeenCalledWith("/api/change-password/testUser", { + headers: { + Authorization: process.env.MOCK_AUTH_TOKEN, + password: "password123" + } + }); }); }); \ No newline at end of file diff --git a/src/pages/users/UserProfileEdits/ChangePassword.tsx b/src/pages/users/UserProfileEdits/ChangePassword.tsx index d56ba4e2..1a4a4b74 100644 --- a/src/pages/users/UserProfileEdits/ChangePassword.tsx +++ b/src/pages/users/UserProfileEdits/ChangePassword.tsx @@ -18,22 +18,27 @@ export default function ChangePassword(): ReactElement { const [redirect, setRedirect] = useState(false); const changePassword = () => { - if (password === "") { + const sanitisedPassword = password.trim(); + const sanitisedConfirmPassword = confirmPassword.trim(); + + if (sanitisedPassword === "" || sanitisedConfirmPassword === "") { setMessage("Passwords cannot be blank"); return; } - if (password !== confirmPassword) { + if (sanitisedPassword !== sanitisedConfirmPassword) { setMessage("Passwords do not match"); return; } setButtonLoading(true); const authManager = new AuthManager(); - fetch("/api/change-password/" + viewedUsername, { - "headers": Object.assign({}, { - "password": password - }, authManager.authHeader()) - }) + + fetch("/api/change-password/" + viewedUsername, + { + "headers": Object.assign({}, { + "password": sanitisedPassword + }, authManager.authHeader()) + }) .then((r: Response) => { if (r.status === 204) { setButtonLoading(false); @@ -67,7 +72,7 @@ export default function ChangePassword(): ReactElement { currentUser, updatedPanel: { visible: true, - message: "Password for user " + viewedUsername + " changed at " + (new Date()).toLocaleTimeString("en-UK") + " " + (new Date()).toLocaleDateString("en-UK"), + message: "Password successfully changed for user called " + viewedUsername, status: "success" } }} @@ -82,10 +87,12 @@ export default function ChangePassword(): ReactElement {
changePassword()}> setPassword(e.target.value)} /> setConfirmPassword(e.target.value)} /> @@ -128,7 +128,7 @@ exports[`ChangePassword Component matches the snapshot 1`] = ` @@ -138,7 +138,7 @@ exports[`ChangePassword Component matches the snapshot 1`] = ` > @@ -163,7 +163,7 @@ exports[`ChangePassword Component matches the snapshot 1`] = ` diff --git a/src/pages/users/UserUpload/NewUser.tsx b/src/pages/users/UserUpload/NewUser.tsx index 1b560270..f35c354b 100644 --- a/src/pages/users/UserUpload/NewUser.tsx +++ b/src/pages/users/UserUpload/NewUser.tsx @@ -2,8 +2,7 @@ import React, { ChangeEvent, ReactElement, useEffect, useState } from "react"; import { Navigate } from "react-router-dom"; import { ONSPanel, ONSButton } from "blaise-design-system-react-components"; import { addNewUser, getAllRoles } from "../../../api/http"; -import { UserRole } from "blaise-api-node-client"; -import { NewUser } from "blaise-api-node-client"; +import { UserRole, NewUser } from "blaise-api-node-client"; import FormTextInput from "../../../Components/form/TextInput"; import Form from "../../../Components/form"; import { passwordMatchedValidator, requiredValidator } from "../../../Components/form/FormValidators"; @@ -19,17 +18,17 @@ function NewUserComponent(): ReactElement { const [message, setMessage] = useState(""); const [redirect, setRedirect] = useState(false); const [roleList, setRoleList] = useState([]); - const cconfig = loadConfigFromEnv(); + const config = loadConfigFromEnv(); async function createNewUser(formData: UserForm) { setUsername(formData.username); if (formData.username && formData.password) { const newUser: NewUser = { - name: formData.username, - password: formData.password, + name: formData.username.trim(), + password: formData.password.trim(), role: role, - defaultServerPark: "gusty", - serverParks: ["gusty"] + defaultServerPark: config.DefaultServerPark, + serverParks: config.RoleToServerParksMap[role] }; setButtonLoading(true); const created = await addNewUser(newUser); diff --git a/yarn.lock b/yarn.lock index 8dcbdeed..45186ef0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3171,6 +3171,27 @@ stream-events "^1.0.5" uuid "^8.0.0" +"@google-cloud/logging@^9.6.0": + version "9.9.0" + resolved "https://registry.yarnpkg.com/@google-cloud/logging/-/logging-9.9.0.tgz#d13fd6ae359e02123809b2bd640a0a8f71793434" + integrity sha512-rJQ0i9COI1WbtWuGyXL8edF4OA3XHcvgAq5I2DZmjKFvmCLCcspFIWZtztxgd0UgmrrshQVZ0R76oBHjTGggkg== + dependencies: + "@google-cloud/common" "^3.4.1" + "@google-cloud/paginator" "^3.0.0" + "@google-cloud/projectify" "^2.0.0" + "@google-cloud/promisify" "^2.0.0" + arrify "^2.0.1" + dot-prop "^6.0.0" + eventid "^2.0.0" + extend "^3.0.2" + gcp-metadata "^4.0.0" + google-auth-library "^7.0.0" + google-gax "^2.24.1" + on-finished "^2.3.0" + pumpify "^2.0.1" + stream-events "^1.0.5" + uuid "^8.0.0" + "@google-cloud/paginator@^3.0.0": version "3.0.7" resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-3.0.7.tgz#fb6f8e24ec841f99defaebf62c75c2e744dd419b" @@ -4371,6 +4392,20 @@ "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" +"@testing-library/dom@^10.4.0": + version "10.4.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" + integrity sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + "@testing-library/dom@^8.3.0": version "8.20.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f" @@ -5675,7 +5710,7 @@ aria-query@5.1.3: dependencies: deep-equal "^2.0.5" -aria-query@^5.0.0, aria-query@^5.3.0: +aria-query@5.3.0, aria-query@^5.0.0, aria-query@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== @@ -6273,10 +6308,11 @@ bl@^4.0.3, bl@^4.1.0: regenerator-runtime "^0.14.1" typescript "~4.2.2" -"blaise-design-system-react-components@git+https://github.com/ONSdigital/blaise-design-system-react-components#0.14.0": +"blaise-design-system-react-components@git+https://github.com/ONSdigital/blaise-design-system-react-components#0.14.1": version "1.0.0" - resolved "git+https://github.com/ONSdigital/blaise-design-system-react-components#30ea96f77c024cb0cec27e05e5c1c1c10dd4eca0" + resolved "git+https://github.com/ONSdigital/blaise-design-system-react-components#488cbea69e734b101c654eacaf999afdae709d97" dependencies: + "@testing-library/dom" "^10.4.0" formik "^2.2.9" react "^18.2.0" react-csv "^2.2.2" @@ -6296,9 +6332,9 @@ bl@^4.0.3, bl@^4.1.0: path-parse "^1.0.7" typescript "~4.2.2" -"blaise-login-react@git+https://github.com/ONSdigital/blaise-login-react#1.1.0": +"blaise-login-react@git+https://github.com/ONSdigital/blaise-login-react#1.1.1": version "0.0.0" - resolved "git+https://github.com/ONSdigital/blaise-login-react#39f4182a62b1faaf01039fb8c94adc296ac78766" + resolved "git+https://github.com/ONSdigital/blaise-login-react#f7fc532e1938ccb45831816311c85336510383ae" bluebird@^3.5.5: version "3.7.2" @@ -11767,6 +11803,22 @@ jsonwebtoken@^8.5.1: ms "^2.1.1" semver "^5.6.0" +jsonwebtoken@^9.0.2: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.3.3" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz#76b3e6e6cece5c69d49a5792c3d01bd1a0cdc7ea"