From 98241b03ee3eba89ebcc1351dc710fddbf4e8553 Mon Sep 17 00:00:00 2001 From: Ludek Novy <13610612+ludeknovy@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:16:53 +0200 Subject: [PATCH] Feature: project auto provisioning (#267) --- migrations/1694620030174_global-settings.js | 15 ++++++ .../1694620617193_global-settings-data.js | 13 +++++ .../get-global-settings-controller.spec.ts | 31 +++++++++++ .../get-global-settings-controller.ts | 12 +++++ .../update-global-settings-controller.spec.ts | 36 +++++++++++++ .../update-global-settings-controller.ts | 12 +++++ .../project-exists-middleware.spec.ts | 51 ++++++++++++++++++- .../middleware/project-exists-middleware.ts | 20 +++++++- src/server/queries/global-settings.ts | 12 +++++ src/server/queries/projects.ts | 6 +-- src/server/router.ts | 4 ++ src/server/routes/global-settings.ts | 28 ++++++++++ src/server/routes/item.ts | 4 +- .../schema-validator/global-settings.ts | 6 +++ src/tests/integration/api-tokens.spec.ts | 4 +- src/tests/integration/global-settings.spec.ts | 33 ++++++++++++ src/tests/integration/helper/routes.ts | 1 + 17 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 migrations/1694620030174_global-settings.js create mode 100644 migrations/1694620617193_global-settings-data.js create mode 100644 src/server/controllers/global-settings/get-global-settings-controller.spec.ts create mode 100644 src/server/controllers/global-settings/get-global-settings-controller.ts create mode 100644 src/server/controllers/global-settings/update-global-settings-controller.spec.ts create mode 100644 src/server/controllers/global-settings/update-global-settings-controller.ts create mode 100644 src/server/queries/global-settings.ts create mode 100644 src/server/routes/global-settings.ts create mode 100644 src/server/schema-validator/global-settings.ts create mode 100644 src/tests/integration/global-settings.spec.ts diff --git a/migrations/1694620030174_global-settings.js b/migrations/1694620030174_global-settings.js new file mode 100644 index 00000000..62ce39f2 --- /dev/null +++ b/migrations/1694620030174_global-settings.js @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +exports.up = pgm => { + pgm.createTable({ schema: "jtl", name: "global_settings" }, { + id: { + type: "serial", + }, + project_auto_provisioning: { + type: "boolean", + "default": false, + notNull: true, + }, + }) + +} + diff --git a/migrations/1694620617193_global-settings-data.js b/migrations/1694620617193_global-settings-data.js new file mode 100644 index 00000000..da5839b9 --- /dev/null +++ b/migrations/1694620617193_global-settings-data.js @@ -0,0 +1,13 @@ +exports.up = async pgm => { + try { + + await pgm.db.query({ + text: `INSERT INTO jtl.global_settings (project_auto_provisioning) + values ($1)`, + values: [false], + }) + } catch(error) { + console.log(error) + } +} + diff --git a/src/server/controllers/global-settings/get-global-settings-controller.spec.ts b/src/server/controllers/global-settings/get-global-settings-controller.spec.ts new file mode 100644 index 00000000..aec84a9c --- /dev/null +++ b/src/server/controllers/global-settings/get-global-settings-controller.spec.ts @@ -0,0 +1,31 @@ +import { getGlobalSettingsController } from "./get-global-settings-controller" +import { Response } from "express" +import { AllowedRoles } from "../../middleware/authorization-middleware" +import { IGetUserAuthInfoRequest } from "../../middleware/request.model" +import { db } from "../../../db/db" + +jest.mock("../../../db/db") +const mockResponse = () => { + const res: Partial = {} + res.json = jest.fn().mockReturnValue(res) + res.status = jest.fn().mockReturnValue(res) + return res +} +describe("getGlobalSettingsController", () => { + it("should return global settings", async () => { + const response = mockResponse() + const querySpy = jest.spyOn(require("../../queries/global-settings"), "getGlobalSettings") + const request = { + user: { + role: AllowedRoles.Admin, + }, + } + db.one = jest.fn().mockReturnValueOnce({ project_auto_provisioning: true }) + await getGlobalSettingsController( + request as unknown as IGetUserAuthInfoRequest, + response as unknown as Response) + expect(querySpy).toHaveBeenCalledTimes(1) + expect(response.json).toHaveBeenCalledTimes(1) + expect(response.json).toHaveBeenCalledWith({ projectAutoProvisioning: true }) + }) +}) diff --git a/src/server/controllers/global-settings/get-global-settings-controller.ts b/src/server/controllers/global-settings/get-global-settings-controller.ts new file mode 100644 index 00000000..0c9d95a8 --- /dev/null +++ b/src/server/controllers/global-settings/get-global-settings-controller.ts @@ -0,0 +1,12 @@ +import { IGetUserAuthInfoRequest } from "../../middleware/request.model" +import { Response } from "express" +import { db } from "../../../db/db" +import { getGlobalSettings } from "../../queries/global-settings" +import { StatusCode } from "../../utils/status-code" + +export const getGlobalSettingsController = async (req: IGetUserAuthInfoRequest, res: Response) => { + const globalSettings = await db.one(getGlobalSettings()) + res.status(StatusCode.Ok).json({ + projectAutoProvisioning: globalSettings.project_auto_provisioning, + }) +} diff --git a/src/server/controllers/global-settings/update-global-settings-controller.spec.ts b/src/server/controllers/global-settings/update-global-settings-controller.spec.ts new file mode 100644 index 00000000..3bf7ad10 --- /dev/null +++ b/src/server/controllers/global-settings/update-global-settings-controller.spec.ts @@ -0,0 +1,36 @@ +import { db } from "../../../db/db" +import { AllowedRoles } from "../../middleware/authorization-middleware" +import { IGetUserAuthInfoRequest } from "../../middleware/request.model" +import { Response } from "express" +import { updateGlobalSettingsController } from "./update-global-settings-controller" + +jest.mock("../../../db/db") +const mockResponse = () => { + const res: Partial = {} + res.send = jest.fn().mockReturnValue(res) + res.status = jest.fn().mockReturnValue(res) + return res +} + +describe("updateGlobalSettingsController", () => { + it("should save the new settings into database", async () => { + const response = mockResponse() + const querySpy = jest.spyOn(require("../../queries/global-settings"), "updateGlobalSettings") + const request = { + user: { + role: AllowedRoles.Admin, + }, + body: { + projectAutoProvisioning: true, + }, + } + db.none = jest.fn().mockResolvedValueOnce(null) + + await updateGlobalSettingsController( + request as unknown as IGetUserAuthInfoRequest, + response as unknown as Response) + expect(querySpy).toHaveBeenCalledTimes(1) + expect(querySpy).toHaveBeenCalledWith(true) + expect(response.send).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/server/controllers/global-settings/update-global-settings-controller.ts b/src/server/controllers/global-settings/update-global-settings-controller.ts new file mode 100644 index 00000000..c88ad040 --- /dev/null +++ b/src/server/controllers/global-settings/update-global-settings-controller.ts @@ -0,0 +1,12 @@ +import { IGetUserAuthInfoRequest } from "../../middleware/request.model" +import { Response } from "express" +import { updateGlobalSettings } from "../../queries/global-settings" +import { db } from "../../../db/db" +import { StatusCode } from "../../utils/status-code" + +export const updateGlobalSettingsController = async (req: IGetUserAuthInfoRequest, res: Response) => { + const updatedSettings = req.body + await db.none(updateGlobalSettings(updatedSettings.projectAutoProvisioning)) + return res.status(StatusCode.NoContent).send() + +} diff --git a/src/server/middleware/project-exists-middleware.spec.ts b/src/server/middleware/project-exists-middleware.spec.ts index 031ed630..dab9d236 100644 --- a/src/server/middleware/project-exists-middleware.spec.ts +++ b/src/server/middleware/project-exists-middleware.spec.ts @@ -1,6 +1,6 @@ import { IGetUserAuthInfoRequest } from "./request.model" import { Response, NextFunction } from "express" -import { projectExistsMiddleware } from "./project-exists-middleware" +import { projectAutoProvisioningMiddleware, projectExistsMiddleware } from "./project-exists-middleware" import { db } from "../../db/db" import Boom = require("boom") @@ -31,5 +31,54 @@ describe("projectExistsMiddleware", () => { expect(nextFunction).toHaveBeenCalledTimes(1) expect(nextFunction).toHaveBeenCalledWith() }) +}) + + +describe("projectAutoProvisioningMiddleware", () => { + const nextFunction: NextFunction = jest.fn() + + + beforeEach(() => { + jest.resetAllMocks() + }) + + it("should create new project when it does not exists and auto provisioning is enabled in global settings", + async () => { + const request: any = { params: { projectName: "does not exist" } } + db.oneOrNone = jest.fn().mockReturnValueOnce(null) + db.one = jest.fn().mockReturnValueOnce({ project_auto_provisioning: true }) + const spy = jest.spyOn(require("../queries/projects"), "createNewProject") + await projectAutoProvisioningMiddleware(request as unknown as IGetUserAuthInfoRequest, + {} as unknown as Response, nextFunction) + expect(nextFunction).toHaveBeenCalledTimes(1) + expect(nextFunction).toHaveBeenCalledWith() + expect(spy).toHaveBeenCalledWith("does not exist", true) + }) + + + it("should continue when project exists", + async () => { + const request: any = { params: { projectName: "does not exist" } } + db.oneOrNone = jest.fn().mockReturnValueOnce("project data") + const spy = jest.spyOn(require("../queries/global-settings"), "getGlobalSettings") + await projectAutoProvisioningMiddleware(request as unknown as IGetUserAuthInfoRequest, + {} as unknown as Response, nextFunction) + expect(nextFunction).toHaveBeenCalledTimes(1) + expect(nextFunction).toHaveBeenCalledWith() + expect(spy).toHaveBeenCalledTimes(0) + }) + + it("should return 404 when project does not exists and auto provisioning is disabled in global settings", + async () => { + const request: any = { params: { projectName: "does not exist" } } + db.oneOrNone = jest.fn().mockReturnValueOnce(null) + db.one = jest.fn().mockReturnValueOnce({ project_auto_provisioning: false }) + const spy = jest.spyOn(require("../queries/projects"), "createNewProject") + await projectAutoProvisioningMiddleware(request as unknown as IGetUserAuthInfoRequest, + {} as unknown as Response, nextFunction) + expect(nextFunction).toHaveBeenCalledTimes(1) + expect(nextFunction).toHaveBeenCalledWith(Boom.notFound("Project not found")) + expect(spy).toHaveBeenCalledTimes(0) + }) }) diff --git a/src/server/middleware/project-exists-middleware.ts b/src/server/middleware/project-exists-middleware.ts index 944e9b32..5f0f0919 100644 --- a/src/server/middleware/project-exists-middleware.ts +++ b/src/server/middleware/project-exists-middleware.ts @@ -2,7 +2,9 @@ import { IGetUserAuthInfoRequest } from "./request.model" import { NextFunction, Response } from "express" import { db } from "../../db/db" import * as boom from "boom" -import { findProjectId } from "../queries/projects" +import { createNewProject, findProjectId } from "../queries/projects" +import { getGlobalSettings } from "../queries/global-settings" +import { logger } from "../../logger" export const projectExistsMiddleware = async (req: IGetUserAuthInfoRequest, res: Response, next: NextFunction) => { @@ -13,3 +15,19 @@ export const projectExistsMiddleware = async (req: IGetUserAuthInfoRequest, res: } next() } + +export const projectAutoProvisioningMiddleware = async ( + req: IGetUserAuthInfoRequest, res: Response, next: NextFunction) => { + const { projectName } = req.params + const project = await db.oneOrNone(findProjectId(projectName)) + if (project) { + return next() + } + const globalSettings = await db.one(getGlobalSettings()) + if (globalSettings.project_auto_provisioning === true) { + logger.info(`Project auto-provisioning is enabled, creating a new project ${projectName}`) + await db.one(createNewProject(projectName, true)) + return next() + } + return next(boom.notFound("Project not found")) +} diff --git a/src/server/queries/global-settings.ts b/src/server/queries/global-settings.ts new file mode 100644 index 00000000..f5be18fd --- /dev/null +++ b/src/server/queries/global-settings.ts @@ -0,0 +1,12 @@ +export const getGlobalSettings = () => { + return { + text: `SELECT * FROM jtl.global_settings`, + } +} + +export const updateGlobalSettings = (projectAutoprovisioning: boolean) => { + return { + text: "UPDATE jtl.global_settings SET project_auto_provisioning = $1", + values: [projectAutoprovisioning], + } +} diff --git a/src/server/queries/projects.ts b/src/server/queries/projects.ts index 054fe0f4..41d642b7 100644 --- a/src/server/queries/projects.ts +++ b/src/server/queries/projects.ts @@ -7,11 +7,11 @@ export const isExistingProject = projectName => { } } -export const createNewProject = projectName => { +export const createNewProject = (projectName, upsertScenario = false) => { return { name: "create-new-project", - text: "INSERT INTO jtl.projects(project_name) VALUES($1) RETURNING id", - values: [projectName], + text: "INSERT INTO jtl.projects(project_name, upsert_scenario) VALUES($1, $2) RETURNING id", + values: [projectName, upsertScenario], } } diff --git a/src/server/router.ts b/src/server/router.ts index 56f2e641..4f7435a4 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -7,6 +7,7 @@ import { AuthRoutes } from "./routes/auth" import { ApiTokensRoutes } from "./routes/api-token" import { UsersRoutes } from "./routes/users" import { InitRoutes } from "./routes/init" +import { GlobalSettings } from "./routes/global-settings" const env = process.env.ENVIRONMENT export class Router { @@ -19,6 +20,7 @@ export class Router { private userRoutes: UsersRoutes private testDataSetup: TestDataSetup private initRoutes: InitRoutes + private globalSettings: GlobalSettings constructor() { this.projectRoutes = new ProjectRoutes() this.scenarioRoutes = new ScenarioRoutes() @@ -29,6 +31,7 @@ export class Router { this.userRoutes = new UsersRoutes() this.testDataSetup = new TestDataSetup() this.initRoutes = new InitRoutes() + this.globalSettings = new GlobalSettings() } getRoutes(app) { @@ -43,5 +46,6 @@ export class Router { this.testDataSetup.routes(app) } this.initRoutes.routes(app) + this.globalSettings.routes(app) } } diff --git a/src/server/routes/global-settings.ts b/src/server/routes/global-settings.ts new file mode 100644 index 00000000..05179434 --- /dev/null +++ b/src/server/routes/global-settings.ts @@ -0,0 +1,28 @@ +import * as express from "express" +import { authenticationMiddleware } from "../middleware/authentication-middleware" +import { AllowedRoles, authorizationMiddleware } from "../middleware/authorization-middleware" +import { wrapAsync } from "../errors/error-handler" +import { IGetUserAuthInfoRequest } from "../middleware/request.model" +import { Response } from "express" +import { getGlobalSettingsController } from "../controllers/global-settings/get-global-settings-controller" +import { updateGlobalSettingsController } from "../controllers/global-settings/update-global-settings-controller" +import { bodySchemaValidator } from "../schema-validator/schema-validator-middleware" +import { globalSettingsBodySchema } from "../schema-validator/global-settings" + +export class GlobalSettings { + routes(app: express.Application): void { + app.route("/api/global-settings") + .get( + authenticationMiddleware, + authorizationMiddleware([AllowedRoles.Admin]), + wrapAsync((req: IGetUserAuthInfoRequest, res: Response) => getGlobalSettingsController(req, res)) + ) + + .put( + authenticationMiddleware, + authorizationMiddleware([AllowedRoles.Admin]), + bodySchemaValidator(globalSettingsBodySchema), + wrapAsync((req: IGetUserAuthInfoRequest, res: Response) => updateGlobalSettingsController(req, res)) + ) + } +} diff --git a/src/server/routes/item.ts b/src/server/routes/item.ts index 4a9f2e64..886ad644 100644 --- a/src/server/routes/item.ts +++ b/src/server/routes/item.ts @@ -33,7 +33,7 @@ import { getItemChartSettingsController } from "../controllers/item/get-item-cha import { AllowedRoles, authorizationMiddleware } from "../middleware/authorization-middleware" import { authenticationMiddleware } from "../middleware/authentication-middleware" import { getRequestStatsExportController } from "../controllers/item/get-request-stats-export-controller" -import { projectExistsMiddleware } from "../middleware/project-exists-middleware" +import { projectAutoProvisioningMiddleware, projectExistsMiddleware } from "../middleware/project-exists-middleware" export class ItemsRoutes { @@ -52,7 +52,7 @@ export class ItemsRoutes { authenticationMiddleware, authorizationMiddleware([AllowedRoles.Operator, AllowedRoles.Admin]), paramsSchemaValidator(newItemParamSchema), - projectExistsMiddleware, + projectAutoProvisioningMiddleware, createItemController) app.route("/api/projects/:projectName/scenarios/:scenarioName/items/start-async") diff --git a/src/server/schema-validator/global-settings.ts b/src/server/schema-validator/global-settings.ts new file mode 100644 index 00000000..4acf584e --- /dev/null +++ b/src/server/schema-validator/global-settings.ts @@ -0,0 +1,6 @@ +import * as Joi from "joi" + + +export const globalSettingsBodySchema = { + projectAutoProvisioning: Joi.boolean().required(), +} diff --git a/src/tests/integration/api-tokens.spec.ts b/src/tests/integration/api-tokens.spec.ts index 79fb534f..fb9853f8 100644 --- a/src/tests/integration/api-tokens.spec.ts +++ b/src/tests/integration/api-tokens.spec.ts @@ -45,7 +45,7 @@ describe("Api tokens", () => { }) describe("GET /api-tokens", () => { - it("should not be able to get api tokens as unathorized user", async () => { + it("should not be able to get api tokens as unauthorized user", async () => { await request(__server__) .get(routes.apiTokens) .send() @@ -71,7 +71,7 @@ describe("Api tokens", () => { tokenId = body[0].id }) }) - it("should not be able to delete api token as uanthorized user", async () => { + it("should not be able to delete api token as unauthorized user", async () => { await request(__server__) .delete(routes.apiTokens) .send() diff --git a/src/tests/integration/global-settings.spec.ts b/src/tests/integration/global-settings.spec.ts new file mode 100644 index 00000000..86ee7b81 --- /dev/null +++ b/src/tests/integration/global-settings.spec.ts @@ -0,0 +1,33 @@ +import { userSetup } from "./helper/state" +import * as request from "supertest" +import { routes } from "./helper/routes" +import { StatusCode } from "../../server/utils/status-code" + +describe("global settings", () => { + let credentials + beforeAll(async () => { + try { + ({ data: credentials } = await userSetup()) + } catch(error) { + console.log(error) + } + }) + it("should return the global settings", async () => { + await request(__server__) + .get(routes.globalSettings) + .set(__tokenHeaderKey__, credentials.token) + .set("Accept", "application/json") + .send() + .expect(StatusCode.Ok, { projectAutoProvisioning: false }) + }) + it("should be able to change the global settings", async () => { + await request(__server__) + .put(routes.globalSettings) + .set(__tokenHeaderKey__, credentials.token) + .set("Accept", "application/json") + .send({ + projectAutoProvisioning: true, + }) + .expect(StatusCode.NoContent) + }) +}) diff --git a/src/tests/integration/helper/routes.ts b/src/tests/integration/helper/routes.ts index ff197f1b..6094ea6c 100644 --- a/src/tests/integration/helper/routes.ts +++ b/src/tests/integration/helper/routes.ts @@ -7,4 +7,5 @@ export const routes = { apiTokens: "/api/api-tokens", users: "/api/users", init: "/api/info", + globalSettings: "/api/global-settings", }