From c3522025c8d9392bfcd5b418688ae0158d45134f Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Fri, 24 Nov 2023 18:55:42 +0600 Subject: [PATCH] feat: infra config module add with get-update-reset functionality --- .../20231124104640_infra_config/migration.sql | 14 ++ .../hoppscotch-backend/prisma/schema.prisma | 9 + packages/hoppscotch-backend/src/app.module.ts | 4 + packages/hoppscotch-backend/src/errors.ts | 20 ++ .../src/infra-config/helper.ts | 30 +++ .../src/infra-config/infra-config.model.ts | 14 ++ .../src/infra-config/infra-config.module.ts | 10 + .../infra-config/infra-config.service.spec.ts | 96 +++++++++ .../src/infra-config/infra-config.service.ts | 191 ++++++++++++++++++ .../src/types/InfraConfig.ts | 21 ++ 10 files changed, 409 insertions(+) create mode 100644 packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql create mode 100644 packages/hoppscotch-backend/src/infra-config/helper.ts create mode 100644 packages/hoppscotch-backend/src/infra-config/infra-config.model.ts create mode 100644 packages/hoppscotch-backend/src/infra-config/infra-config.module.ts create mode 100644 packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/infra-config/infra-config.service.ts create mode 100644 packages/hoppscotch-backend/src/types/InfraConfig.ts diff --git a/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql new file mode 100644 index 000000000..1e2d72c46 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20231124104640_infra_config/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "InfraConfig" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "value" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InfraConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "InfraConfig_name_key" ON "InfraConfig"("name"); diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 1c945fa2d..13b364dcb 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -209,3 +209,12 @@ enum TeamMemberRole { VIEWER EDITOR } + +model InfraConfig { + id String @id @default(cuid()) + name String @unique + value String? + active Boolean @default(true) // Use case: Let's say, Admin wants to disable Google SSO, but doesn't want to delete the config + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) +} diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 09571f263..e181de29e 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -21,11 +21,14 @@ import { COOKIES_NOT_FOUND } from './errors'; import { ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { InfraConfigModule } from './infra-config/infra-config.module'; +import { loadInfraConfiguration } from './infra-config/helper'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, + load: [async () => loadInfraConfiguration()], }), GraphQLModule.forRootAsync({ driver: ApolloDriver, @@ -90,6 +93,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; TeamInvitationModule, UserCollectionModule, ShortcodeModule, + InfraConfigModule, ], providers: [GQLComplexityPlugin], controllers: [AppController], diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index df81a5b85..845a884f2 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -644,3 +644,23 @@ export const SHORTCODE_INVALID_PROPERTIES_JSON = */ export const SHORTCODE_PROPERTIES_NOT_FOUND = 'shortcode/properties_not_found' as const; + +/** + * Infra Config not found + * (InfraConfigService) + */ +export const INFRA_CONFIG_NOT_FOUND = 'infra_config/not_found' as const; + +/** + * Infra Config not listed for onModuleInit creation + * (InfraConfigService) + */ +export const INFRA_CONFIG_NOT_LISTED = + 'infra_config/properly_not_listed' as const; + +/** + * Error message for when the database table does not exist + * (InfraConfigService) + */ +export const DATABASE_TABLE_NOT_EXIST = + 'Database migration not performed. Please check the FAQ for assistance: https://docs.hoppscotch.io/support/faq'; diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts new file mode 100644 index 000000000..ff4d6be3c --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -0,0 +1,30 @@ +import { PrismaService } from 'src/prisma/prisma.service'; + +/** + * Load environment variables from the database and set them in the process + * + * @Description Fetch the 'infra_config' table from the database and return it as an object + * (ConfigModule will set the environment variables in the process) + */ +export async function loadInfraConfiguration() { + const prisma = new PrismaService(); + + const infraConfigs = await prisma.infraConfig.findMany(); + + let environmentObject: Record = {}; + infraConfigs.forEach((infraConfig) => { + environmentObject[infraConfig.name] = infraConfig.value; + }); + + return environmentObject; +} + +/** + * Stop the app after 5 seconds + * (Docker will re-start the app) + */ +export function stopApp() { + setTimeout(() => { + process.exit(); + }, 5000); +} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts new file mode 100644 index 000000000..d275726ce --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class InfraConfig { + @Field({ + description: 'Infra Config Name', + }) + name: string; + + @Field({ + description: 'Infra Config Value', + }) + value: string; +} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts new file mode 100644 index 000000000..dafd25ae8 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InfraConfigService } from './infra-config.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [InfraConfigService], + exports: [InfraConfigService], +}) +export class InfraConfigModule {} diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts new file mode 100644 index 000000000..9357d6b14 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts @@ -0,0 +1,96 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfigService } from './infra-config.service'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { INFRA_CONFIG_NOT_FOUND } from 'src/errors'; + +const mockPrisma = mockDeep(); + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const infraConfigService = new InfraConfigService(mockPrisma); + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('InfraConfigService', () => { + describe('update', () => { + it('should update the infra config', async () => { + const name = InfraConfigEnum.EULAConfig; + const value = 'true'; + + mockPrisma.infraConfig.update.mockResolvedValueOnce({ + id: '', + name, + value, + active: true, + createdOn: new Date(), + updatedOn: new Date(), + }); + const result = await infraConfigService.update(name, value); + expect(result).toEqualRight({ name, value }); + }); + + it('should pass correct params to prisma update', async () => { + const name = InfraConfigEnum.EULAConfig; + const value = 'true'; + + await infraConfigService.update(name, value); + + expect(mockPrisma.infraConfig.update).toHaveBeenCalledWith({ + where: { name }, + data: { value }, + }); + expect(mockPrisma.infraConfig.update).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if the infra config does not exist', async () => { + const name = InfraConfigEnum.EULAConfig; + const value = 'true'; + + mockPrisma.infraConfig.update.mockRejectedValueOnce('null'); + + const result = await infraConfigService.update(name, value); + expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); + }); + }); + + describe('get', () => { + it('should get the infra config', async () => { + const name = InfraConfigEnum.EULAConfig; + const value = 'true'; + + mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({ + id: '', + name, + value, + active: true, + createdOn: new Date(), + updatedOn: new Date(), + }); + const result = await infraConfigService.get(name); + expect(result).toEqualRight({ name, value }); + }); + + it('should pass correct params to prisma findUnique', async () => { + const name = InfraConfigEnum.EULAConfig; + + await infraConfigService.get(name); + + expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledWith({ + where: { name }, + }); + expect(mockPrisma.infraConfig.findUniqueOrThrow).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if the infra config does not exist', async () => { + const name = InfraConfigEnum.EULAConfig; + + mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null'); + + const result = await infraConfigService.get(name); + expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts new file mode 100644 index 000000000..8976be4d2 --- /dev/null +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -0,0 +1,191 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InfraConfig } from './infra-config.model'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { InfraConfig as DBInfraConfig } from '@prisma/client'; +import * as E from 'fp-ts/Either'; +import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { + DATABASE_TABLE_NOT_EXIST, + INFRA_CONFIG_NOT_FOUND, + INFRA_CONFIG_NOT_LISTED, +} from 'src/errors'; +import { throwErr } from 'src/utils'; +import { ConfigService } from '@nestjs/config'; +import { stopApp } from './helper'; + +@Injectable() +export class InfraConfigService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.initializeInfraConfigTable(); + } + + getDefaultInfraConfigs(): InfraConfig[] { + // Prepare rows for 'infra_config' table with default values for each 'name' + const infraConfigDefaultObjs: InfraConfig[] = [ + { + name: InfraConfigEnum.MAILER_SMTP_URL, + value: process.env.MAILER_SMTP_URL, + }, + { + name: InfraConfigEnum.MAILER_ADDRESS_FROM, + value: process.env.MAILER_ADDRESS_FROM, + }, + { + name: InfraConfigEnum.GOOGLE_CLIENT_ID, + value: process.env.GOOGLE_CLIENT_ID, + }, + { + name: InfraConfigEnum.GOOGLE_CLIENT_SECRET, + value: process.env.GOOGLE_CLIENT_SECRET, + }, + { + name: InfraConfigEnum.GITHUB_CLIENT_ID, + value: process.env.GITHUB_CLIENT_ID, + }, + { + name: InfraConfigEnum.GITHUB_CLIENT_SECRET, + value: process.env.GITHUB_CLIENT_SECRET, + }, + { + name: InfraConfigEnum.MICROSOFT_CLIENT_ID, + value: process.env.MICROSOFT_CLIENT_ID, + }, + { + name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET, + value: process.env.MICROSOFT_CLIENT_SECRET, + }, + { + name: InfraConfigEnum.SAML_ISSUER, + value: process.env.SAML_ISSUER, + }, + { + name: InfraConfigEnum.SAML_AUDIENCE, + value: process.env.SAML_AUDIENCE, + }, + { + name: InfraConfigEnum.SAML_CALLBACK_URL, + value: process.env.SAML_CALLBACK_URL, + }, + { + name: InfraConfigEnum.SAML_CERT, + value: process.env.SAML_CERT, + }, + { + name: InfraConfigEnum.SAML_ENTRY_POINT, + value: process.env.SAML_ENTRY_POINT, + }, + { + name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, + value: process.env.VITE_ALLOWED_AUTH_PROVIDERS, + }, + ]; + + return infraConfigDefaultObjs; + } + + /** + * Initialize the 'infra_config' table with values from .env + */ + async initializeInfraConfigTable() { + try { + // Get all the 'names' for 'infra_config' table + const enumValues = Object.values(InfraConfigEnum); + + // Prepare rows for 'infra_config' table with default values for each 'name' + const infraConfigDefaultObjs = this.getDefaultInfraConfigs(); + + // Check if all the 'names' are listed in the default values + if (enumValues.length !== infraConfigDefaultObjs.length) { + throw new Error(INFRA_CONFIG_NOT_LISTED); + } + + // Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table + const dbInfraConfigs = await this.prisma.infraConfig.findMany(); + const propsToInsert = infraConfigDefaultObjs.filter( + (p) => !dbInfraConfigs.find((e) => e.name === p.name), + ); + + if (propsToInsert.length > 0) { + await this.prisma.infraConfig.createMany({ data: propsToInsert }); + stopApp(); + } + } catch (error) { + if (error.code === 'P2021') { + // Prisma error code for 'Table does not exist' + throwErr(DATABASE_TABLE_NOT_EXIST); + } else { + throwErr(error); + } + } + } + + /** + * Typecast a database InfraConfig to a InfraConfig model + * @param dbInfraConfig database InfraConfig + * @returns InfraConfig model + */ + cast(dbInfraConfig: DBInfraConfig) { + return { + name: dbInfraConfig.name, + value: dbInfraConfig.value, + }; + } + + /** + * Update InfraConfig by name + * @param name Name of the InfraConfig + * @param value Value of the InfraConfig + * @returns InfraConfig model + */ + async update(name: InfraConfigEnum, value: string) { + try { + const infraConfig = await this.prisma.infraConfig.update({ + where: { name }, + data: { value }, + }); + + stopApp(); + + return E.right(this.cast(infraConfig)); + } catch (e) { + return E.left(INFRA_CONFIG_NOT_FOUND); + } + } + + /** + * Get InfraConfig by name + * @param name Name of the InfraConfig + * @returns InfraConfig model + */ + async get(name: InfraConfigEnum) { + try { + const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({ + where: { name }, + }); + + return E.right(this.cast(infraConfig)); + } catch (e) { + return E.left(INFRA_CONFIG_NOT_FOUND); + } + } + + /** + * Reset all the InfraConfigs to their default values (from .env) + */ + async reset() { + const infraConfigDefaultObjs = this.getDefaultInfraConfigs(); + + await this.prisma.infraConfig.deleteMany({ + where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } }, + }); + + await this.prisma.infraConfig.createMany({ data: infraConfigDefaultObjs }); + + stopApp(); + } +} diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts new file mode 100644 index 000000000..3521e6265 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -0,0 +1,21 @@ +export enum InfraConfigEnum { + MAILER_SMTP_URL = 'MAILER_SMTP_URL', + MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM', + + GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', + GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', + + GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID', + GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET', + + MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID', + MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET', + + SAML_ISSUER = 'SAML_ISSUER', + SAML_AUDIENCE = 'SAML_AUDIENCE', + SAML_CALLBACK_URL = 'SAML_CALLBACK_URL', + SAML_CERT = 'SAML_CERT', + SAML_ENTRY_POINT = 'SAML_ENTRY_POINT', + + VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS', +}