diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4a3cdbe8..924570cd 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -16,6 +16,7 @@ import { WorkspaceModule } from '../workspace/workspace.module' import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' import { ApiKeyGuard } from '../auth/guard/api-key/api-key.guard' import { EventModule } from '../event/event.module' +import { VariableModule } from '../variable/variable.module' @Module({ controllers: [AppController], @@ -34,7 +35,8 @@ import { EventModule } from '../event/event.module' EnvironmentModule, WorkspaceModule, WorkspaceRoleModule, - EventModule + EventModule, + VariableModule ], providers: [ { diff --git a/apps/api/src/common/cleanup.ts b/apps/api/src/common/cleanup.ts index eed8ca87..54e359b7 100644 --- a/apps/api/src/common/cleanup.ts +++ b/apps/api/src/common/cleanup.ts @@ -11,6 +11,7 @@ export default async function cleanUp(prisma: PrismaClient) { prisma.project.deleteMany(), prisma.user.deleteMany(), prisma.event.deleteMany(), - prisma.apiKey.deleteMany() + prisma.apiKey.deleteMany(), + prisma.variable.deleteMany() ]) } diff --git a/apps/api/src/common/create-event.ts b/apps/api/src/common/create-event.ts index bdb7b795..8dce8a69 100644 --- a/apps/api/src/common/create-event.ts +++ b/apps/api/src/common/create-event.ts @@ -10,7 +10,8 @@ import { Secret, User, Workspace, - WorkspaceRole + WorkspaceRole, + Variable } from '@prisma/client' import { JsonObject } from '@prisma/client/runtime/library' @@ -19,7 +20,14 @@ export default async function createEvent( triggerer?: EventTriggerer severity?: EventSeverity triggeredBy?: User - entity?: Workspace | Project | Environment | WorkspaceRole | ApiKey | Secret + entity?: + | Workspace + | Project + | Environment + | WorkspaceRole + | ApiKey + | Secret + | Variable type: EventType source: EventSource title: string @@ -85,6 +93,12 @@ export default async function createEvent( } break } + case EventSource.VARIABLE: { + if (data.entity) { + baseData.sourceVariableId = data.entity.id + } + break + } case EventSource.USER: { break } diff --git a/apps/api/src/common/get-default-project-environemnt.ts b/apps/api/src/common/get-default-project-environemnt.ts new file mode 100644 index 00000000..f451c902 --- /dev/null +++ b/apps/api/src/common/get-default-project-environemnt.ts @@ -0,0 +1,13 @@ +import { Environment, PrismaClient, Project } from '@prisma/client' + +export default async function getDefaultEnvironmentOfProject( + projectId: Project['id'], + prisma: PrismaClient +): Promise { + return await prisma.environment.findFirst({ + where: { + projectId, + isDefault: true + } + }) +} diff --git a/apps/api/src/common/get-variable-with-authority.ts b/apps/api/src/common/get-variable-with-authority.ts new file mode 100644 index 00000000..afb4d1bc --- /dev/null +++ b/apps/api/src/common/get-variable-with-authority.ts @@ -0,0 +1,57 @@ +import { Authority, PrismaClient, User, Variable } from '@prisma/client' +import getCollectiveProjectAuthorities from './get-collective-project-authorities' +import { NotFoundException, UnauthorizedException } from '@nestjs/common' +import { VariableWithProjectAndVersion } from '../variable/variable.types' + +export default async function getVariableWithAuthority( + userId: User['id'], + variableId: Variable['id'], + authority: Authority, + prisma: PrismaClient +): Promise { + // Fetch the variable + let variable: VariableWithProjectAndVersion + + try { + variable = await prisma.variable.findUnique({ + where: { + id: variableId + }, + include: { + versions: true, + project: true, + environment: { + select: { + id: true, + name: true + } + } + } + }) + } catch (error) { + /* empty */ + } + + if (!variable) { + throw new NotFoundException(`Variable with id ${variableId} not found`) + } + + // Check if the user has the project in their workspace role list + const permittedAuthorities = await getCollectiveProjectAuthorities( + userId, + variable.project, + prisma + ) + + // Check if the user has the required authorities + if ( + !permittedAuthorities.has(authority) && + !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) + ) { + throw new UnauthorizedException( + `User ${userId} does not have the required authorities` + ) + } + + return variable +} diff --git a/apps/api/src/event/controller/event.controller.ts b/apps/api/src/event/controller/event.controller.ts index c47f3433..4587c792 100644 --- a/apps/api/src/event/controller/event.controller.ts +++ b/apps/api/src/event/controller/event.controller.ts @@ -22,6 +22,7 @@ export class EventController { @Query('projectId') projectId: string, @Query('environmentId') environmentId: string, @Query('secretId') secretId: string, + @Query('variableId') variableId: string, @Query('apiKeyId') apiKeyId: string, @Query('workspaceRoleId') workspaceRoleId: string ) { @@ -32,6 +33,7 @@ export class EventController { projectId, environmentId, secretId, + variableId, apiKeyId, workspaceRoleId }, diff --git a/apps/api/src/event/event.e2e.spec.ts b/apps/api/src/event/event.e2e.spec.ts index 60488b32..6ca67d5b 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -36,6 +36,8 @@ import { EnvironmentModule } from '../environment/environment.module' import { ApiKeyModule } from '../api-key/api-key.module' import createEvent from '../common/create-event' import fetchEvents from '../common/fetch-events' +import { VariableService } from '../variable/service/variable.service' +import { VariableModule } from '../variable/variable.module' describe('Event Controller Tests', () => { let app: NestFastifyApplication @@ -48,6 +50,7 @@ describe('Event Controller Tests', () => { let workspaceRoleService: WorkspaceRoleService let projectService: ProjectService let secretService: SecretService + let variableService: VariableService let user: User let workspace: Workspace @@ -67,7 +70,8 @@ describe('Event Controller Tests', () => { SecretModule, ProjectModule, EnvironmentModule, - ApiKeyModule + ApiKeyModule, + VariableModule ] }) .overrideProvider(MAIL_SERVICE) @@ -85,6 +89,7 @@ describe('Event Controller Tests', () => { workspaceRoleService = moduleRef.get(WorkspaceRoleService) projectService = moduleRef.get(ProjectService) secretService = moduleRef.get(SecretService) + variableService = moduleRef.get(VariableService) await app.init() await app.getHttpAdapter().getInstance().ready() @@ -294,6 +299,43 @@ describe('Event Controller Tests', () => { expect(response.json()).toEqual([event]) }) + it('should be able to fetch a variable event', async () => { + const newVariable = await variableService.createVariable( + user, + { + name: 'My variable', + value: 'My value', + environmentId: environment.id + }, + project.id + ) + + expect(newVariable).toBeDefined() + + const response = await fetchEvents( + app, + user, + `variableId=${newVariable.id}` + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.VARIABLE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.VARIABLE_ADDED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual([event]) + }) + it('should be able to fetch a workspace role event', async () => { const newWorkspaceRole = await workspaceRoleService.createWorkspaceRole( user, diff --git a/apps/api/src/event/service/event.service.ts b/apps/api/src/event/service/event.service.ts index 800cf35a..020d4261 100644 --- a/apps/api/src/event/service/event.service.ts +++ b/apps/api/src/event/service/event.service.ts @@ -5,6 +5,7 @@ import getProjectWithAuthority from '../../common/get-project-with-authority' import getEnvironmentWithAuthority from '../../common/get-environment-with-authority' import getSecretWithAuthority from '../../common/get-secret-with-authority' import { PrismaService } from '../../prisma/prisma.service' +import getVariableWithAuthority from '../../common/get-variable-with-authority' @Injectable() export class EventService { @@ -17,6 +18,7 @@ export class EventService { projectId?: string environmentId?: string secretId?: string + variableId?: string apiKeyId?: string workspaceRoleId?: string }, @@ -69,6 +71,14 @@ export class EventService { this.prisma ) whereCondition['sourceSecretId'] = context.secretId + } else if (context.variableId) { + await getVariableWithAuthority( + user.id, + context.variableId, + Authority.READ_VARIABLE, + this.prisma + ) + whereCondition['sourceVariableId'] = context.variableId } else if (context.apiKeyId) { whereCondition['sourceApiKeyId'] = context.apiKeyId } else if (context.workspaceRoleId) { diff --git a/apps/api/src/prisma/migrations/20240219163241_add_variable/migration.sql b/apps/api/src/prisma/migrations/20240219163241_add_variable/migration.sql new file mode 100644 index 00000000..c78d895e --- /dev/null +++ b/apps/api/src/prisma/migrations/20240219163241_add_variable/migration.sql @@ -0,0 +1,91 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Authority" ADD VALUE 'CREATE_VARIABLE'; +ALTER TYPE "Authority" ADD VALUE 'READ_VARIABLE'; +ALTER TYPE "Authority" ADD VALUE 'UPDATE_VARIABLE'; +ALTER TYPE "Authority" ADD VALUE 'DELETE_VARIABLE'; + +-- AlterEnum +ALTER TYPE "EventSource" ADD VALUE 'VARIABLE'; + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "EventType" ADD VALUE 'VARIABLE_UPDATED'; +ALTER TYPE "EventType" ADD VALUE 'VARIABLE_DELETED'; +ALTER TYPE "EventType" ADD VALUE 'VARIABLE_ADDED'; + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "NotificationType" ADD VALUE 'VARIABLE_UPDATED'; +ALTER TYPE "NotificationType" ADD VALUE 'VARIABLE_DELETED'; +ALTER TYPE "NotificationType" ADD VALUE 'VARIABLE_ADDED'; + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "sourceVariableId" TEXT; + +-- CreateTable +CREATE TABLE "VariableVersion" ( + "id" TEXT NOT NULL, + "value" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "variableId" TEXT NOT NULL, + "createdOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdById" TEXT, + + CONSTRAINT "VariableVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Variable" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastUpdatedById" TEXT, + "projectId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + + CONSTRAINT "Variable_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "VariableVersion_variableId_version_key" ON "VariableVersion"("variableId", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "Variable_projectId_environmentId_name_key" ON "Variable"("projectId", "environmentId", "name"); + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_sourceVariableId_fkey" FOREIGN KEY ("sourceVariableId") REFERENCES "Variable"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VariableVersion" ADD CONSTRAINT "VariableVersion_variableId_fkey" FOREIGN KEY ("variableId") REFERENCES "Variable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VariableVersion" ADD CONSTRAINT "VariableVersion_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Variable" ADD CONSTRAINT "Variable_lastUpdatedById_fkey" FOREIGN KEY ("lastUpdatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Variable" ADD CONSTRAINT "Variable_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Variable" ADD CONSTRAINT "Variable_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 681fe049..d8029427 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -9,6 +9,7 @@ datasource db { enum EventSource { SECRET + VARIABLE API_KEY ENVIRONMENT PROJECT @@ -48,6 +49,9 @@ enum EventType { SECRET_UPDATED SECRET_DELETED SECRET_ADDED + VARIABLE_UPDATED + VARIABLE_DELETED + VARIABLE_ADDED API_KEY_UPDATED API_KEY_DELETED API_KEY_ADDED @@ -85,6 +89,10 @@ enum Authority { READ_ENVIRONMENT UPDATE_ENVIRONMENT DELETE_ENVIRONMENT + CREATE_VARIABLE + READ_VARIABLE + UPDATE_VARIABLE + DELETE_VARIABLE // User authorities CREATE_WORKSPACE @@ -112,6 +120,9 @@ enum NotificationType { ENVIRONMENT_UPDATED ENVIRONMENT_DELETED ENVIRONMENT_ADDED + VARIABLE_UPDATED + VARIABLE_DELETED + VARIABLE_ADDED } model Event { @@ -137,6 +148,8 @@ model Event { sourceEnvironmentId String? sourceSecret Secret? @relation(fields: [sourceSecretId], references: [id], onDelete: SetNull, onUpdate: Cascade) sourceSecretId String? + sourceVariable Variable? @relation(fields: [sourceVariableId], references: [id], onDelete: SetNull, onUpdate: Cascade) + sourceVariableId String? sourceApiKey ApiKey? @relation(fields: [sourceApiKeyId], references: [id], onDelete: SetNull, onUpdate: Cascade) sourceApiKeyId String? sourceWorkspaceMembership WorkspaceMember? @relation(fields: [sourceWorkspaceMembershipId], references: [id], onDelete: SetNull, onUpdate: Cascade) @@ -170,9 +183,11 @@ model User { otp Otp? notifications Notification[] secrets Secret[] // Stores the secrets the user updated + variables Variable[] // Stores the variables the user updated projects Project[] // Stores the projects the user updated environments Environment[] // Stores the environments the user updated secretVersion SecretVersion[] + variableVersion VariableVersion[] events Event[] @@index([email], name: "email") @@ -198,7 +213,8 @@ model Environment { lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? - secrets Secret[] + secrets Secret[] + variables Variable[] project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) projectId String @@ -226,11 +242,12 @@ model Project { workspaceId String workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade, onUpdate: Cascade) - events Event[] - secrets Secret[] - environments Environment[] - workspaceRole WorkspaceRole? @relation(fields: [workspaceRoleId], references: [id], onDelete: SetNull, onUpdate: Cascade) - workspaceRoleId String? + events Event[] + secrets Secret[] + variables Variable[] + environments Environment[] + workspaceRole WorkspaceRole? @relation(fields: [workspaceRoleId], references: [id], onDelete: SetNull, onUpdate: Cascade) + workspaceRoleId String? } model WorkspaceRole { @@ -317,6 +334,42 @@ model Secret { @@unique([projectId, environmentId, name]) } +model VariableVersion { + id String @id @default(cuid()) + value String + version Int @default(1) + + variableId String + variable Variable @relation(fields: [variableId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + createdOn DateTime @default(now()) + createdBy User? @relation(fields: [createdById], references: [id], onUpdate: Cascade, onDelete: SetNull) + createdById String? + + @@unique([variableId, version]) +} + +model Variable { + id String @id @default(cuid()) + name String + versions VariableVersion[] // Stores the versions of the variable + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) + lastUpdatedById String? + + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + environmentId String + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + events Event[] + + @@unique([projectId, environmentId, name]) +} + model ApiKey { id String @id @default(cuid()) name String diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index 5cea1034..d36133a0 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -26,6 +26,7 @@ import getEnvironmentWithAuthority from '../../common/get-environment-with-autho import getSecretWithAuthority from '../../common/get-secret-with-authority' import { SecretWithVersion } from '../secret.types' import createEvent from '../../common/create-event' +import getDefaultEnvironmentOfProject from '../../common/get-default-project-environemnt' @Injectable() export class SecretService { @@ -54,7 +55,7 @@ export class SecretService { ) } if (!environment) { - environment = await this.getDefaultEnvironmentOfProject(projectId) + environment = await getDefaultEnvironmentOfProject(projectId, this.prisma) } // If any default environment was not found, throw an error @@ -310,7 +311,7 @@ export class SecretService { } // Rollback the secret - return await this.prisma.secretVersion.deleteMany({ + const result = await this.prisma.secretVersion.deleteMany({ where: { secretId, version: { @@ -318,6 +319,27 @@ export class SecretService { } } }) + + createEvent( + { + triggeredBy: user, + entity: secret, + type: EventType.SECRET_UPDATED, + source: EventSource.SECRET, + title: `Secret rolled back`, + metadata: { + secretId: secret.id, + name: secret.name, + projectId: secret.projectId, + projectName: secret.project.name + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} rolled back secret ${secret.id}`) + + return result } async deleteSecret(user: User, secretId: Secret['id']) { @@ -330,11 +352,26 @@ export class SecretService { ) // Delete the secret - return await this.prisma.secret.delete({ + await this.prisma.secret.delete({ where: { id: secretId } }) + + createEvent( + { + triggeredBy: user, + type: EventType.SECRET_DELETED, + source: EventSource.SECRET, + title: `Secret deleted`, + metadata: { + secretId + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} deleted secret ${secretId}`) } async getSecretById( @@ -497,17 +534,6 @@ export class SecretService { // }) // } - private async getDefaultEnvironmentOfProject( - projectId: Project['id'] - ): Promise { - return await this.prisma.environment.findFirst({ - where: { - projectId, - isDefault: true - } - }) - } - private async secretExists( secretName: Secret['name'], environmentId: Environment['id'] diff --git a/apps/api/src/variable/controller/variable.controller.spec.ts b/apps/api/src/variable/controller/variable.controller.spec.ts new file mode 100644 index 00000000..71e372d4 --- /dev/null +++ b/apps/api/src/variable/controller/variable.controller.spec.ts @@ -0,0 +1,30 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { VariableController } from './variable.controller' +import { PrismaService } from '../../prisma/prisma.service' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { MockMailService } from '../../mail/services/mock.service' +import { VariableService } from '../service/variable.service' + +describe('VariableController', () => { + let controller: VariableController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PrismaService, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + VariableService + ], + controllers: [VariableController] + }).compile() + + controller = module.get(VariableController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/apps/api/src/variable/controller/variable.controller.ts b/apps/api/src/variable/controller/variable.controller.ts new file mode 100644 index 00000000..5a713c83 --- /dev/null +++ b/apps/api/src/variable/controller/variable.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query +} from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' +import { VariableService } from '../service/variable.service' +import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-authorities.decorator' +import { Authority, User } from '@prisma/client' +import { CurrentUser } from '../../decorators/user.decorator' +import { CreateVariable } from '../dto/create.variable/create.variable' + +@ApiTags('Variable Controller') +@Controller('variable') +export class VariableController { + constructor(private readonly variableService: VariableService) {} + + @Post(':projectId') + @RequiredApiKeyAuthorities(Authority.CREATE_VARIABLE) + async createVariable( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Body() dto: CreateVariable + ) { + return await this.variableService.createVariable(user, dto, projectId) + } + + @Put(':variableId') + @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) + async updateVariable( + @CurrentUser() user: User, + @Param('variableId') variableId: string, + @Body() dto: CreateVariable + ) { + return await this.variableService.updateVariable(user, variableId, dto) + } + + @Put(':variableId/environment/:environmentId') + @RequiredApiKeyAuthorities( + Authority.UPDATE_VARIABLE, + Authority.READ_ENVIRONMENT + ) + async updateVariableEnvironment( + @CurrentUser() user: User, + @Param('variableId') variableId: string, + @Param('environmentId') environmentId: string + ) { + return await this.variableService.updateVariableEnvironment( + user, + variableId, + environmentId + ) + } + + @Put(':variableId/rollback/:rollbackVersion') + @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) + async rollbackVariable( + @CurrentUser() user: User, + @Param('variableId') variableId: string, + @Param('rollbackVersion') rollbackVersion: number + ) { + return await this.variableService.rollbackVariable( + user, + variableId, + rollbackVersion + ) + } + + @Delete(':variableId') + @RequiredApiKeyAuthorities(Authority.DELETE_VARIABLE) + async deleteVariable( + @CurrentUser() user: User, + @Param('variableId') variableId: string + ) { + return await this.variableService.deleteVariable(user, variableId) + } + + @Get(':variableId') + @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) + async getVariable( + @CurrentUser() user: User, + @Param('variableId') variableId: string + ) { + return await this.variableService.getVariableById(user, variableId) + } + + @Get('/all/:projectId') + @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) + async getAllVariablesOfProject( + @CurrentUser() user: User, + @Param('projectId') projectId: string, + @Query('page') page: number = 0, + @Query('limit') limit: number = 10, + @Query('sort') sort: string = 'name', + @Query('order') order: string = 'asc', + @Query('search') search: string = '' + ) { + return await this.variableService.getAllVariablesOfProject( + user, + projectId, + page, + limit, + sort, + order, + search + ) + } +} diff --git a/apps/api/src/variable/dto/create.variable/create.variable.spec.ts b/apps/api/src/variable/dto/create.variable/create.variable.spec.ts new file mode 100644 index 00000000..784e711f --- /dev/null +++ b/apps/api/src/variable/dto/create.variable/create.variable.spec.ts @@ -0,0 +1,7 @@ +import { CreateVariable } from './create.variable'; + +describe('CreateVariable', () => { + it('should be defined', () => { + expect(new CreateVariable()).toBeDefined(); + }); +}); diff --git a/apps/api/src/variable/dto/create.variable/create.variable.ts b/apps/api/src/variable/dto/create.variable/create.variable.ts new file mode 100644 index 00000000..3703b2c7 --- /dev/null +++ b/apps/api/src/variable/dto/create.variable/create.variable.ts @@ -0,0 +1,13 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator' + +export class CreateVariable { + @IsString() + name: string + + @IsString() + value: string + + @IsNumber() + @IsOptional() + environmentId: string +} diff --git a/apps/api/src/variable/dto/update.variable/update.variable.spec.ts b/apps/api/src/variable/dto/update.variable/update.variable.spec.ts new file mode 100644 index 00000000..3a1daeed --- /dev/null +++ b/apps/api/src/variable/dto/update.variable/update.variable.spec.ts @@ -0,0 +1,7 @@ +import { UpdateVariable } from './update.variable'; + +describe('UpdateVariable', () => { + it('should be defined', () => { + expect(new UpdateVariable()).toBeDefined(); + }); +}); diff --git a/apps/api/src/variable/dto/update.variable/update.variable.ts b/apps/api/src/variable/dto/update.variable/update.variable.ts new file mode 100644 index 00000000..c9555c6a --- /dev/null +++ b/apps/api/src/variable/dto/update.variable/update.variable.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger' +import { CreateVariable } from '../create.variable/create.variable' + +export class UpdateVariable extends PartialType(CreateVariable) {} diff --git a/apps/api/src/variable/service/variable.service.spec.ts b/apps/api/src/variable/service/variable.service.spec.ts new file mode 100644 index 00000000..b00a288f --- /dev/null +++ b/apps/api/src/variable/service/variable.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing' +import { VariableService } from './variable.service' +import { PrismaService } from '../../prisma/prisma.service' +import { MAIL_SERVICE } from '../../mail/services/interface.service' +import { MockMailService } from '../../mail/services/mock.service' + +describe('VariableService', () => { + let service: VariableService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PrismaService, + { + provide: MAIL_SERVICE, + useClass: MockMailService + }, + VariableService + ] + }).compile() + + service = module.get(VariableService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/apps/api/src/variable/service/variable.service.ts b/apps/api/src/variable/service/variable.service.ts new file mode 100644 index 00000000..547c2f5f --- /dev/null +++ b/apps/api/src/variable/service/variable.service.ts @@ -0,0 +1,464 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException +} from '@nestjs/common' +import { PrismaService } from '../../prisma/prisma.service' +import { + Authority, + Environment, + EventSource, + EventType, + Project, + User, + Variable, + VariableVersion +} from '@prisma/client' +import { CreateVariable } from '../dto/create.variable/create.variable' +import getProjectWithAuthority from '../../common/get-project-with-authority' +import getEnvironmentWithAuthority from '../../common/get-environment-with-authority' +import getDefaultEnvironmentOfProject from '../../common/get-default-project-environemnt' +import createEvent from '../../common/create-event' +import { UpdateVariable } from '../dto/update.variable/update.variable' +import getVariableWithAuthority from '../../common/get-variable-with-authority' + +@Injectable() +export class VariableService { + private readonly logger = new Logger(VariableService.name) + + constructor(private readonly prisma: PrismaService) {} + + async createVariable( + user: User, + dto: CreateVariable, + projectId: Project['id'] + ) { + const environmentId = dto.environmentId + // Fetch the project + const project = await getProjectWithAuthority( + user.id, + projectId, + Authority.CREATE_VARIABLE, + this.prisma + ) + + // Check i the environment exists + let environment: Environment | null = null + if (environmentId) { + environment = await getEnvironmentWithAuthority( + user.id, + environmentId, + Authority.READ_ENVIRONMENT, + this.prisma + ) + } + if (!environment) { + environment = await getDefaultEnvironmentOfProject(projectId, this.prisma) + } + + // If any default environment was not found, throw an error + if (!environment) { + throw new NotFoundException( + `No default environment found for project with id ${projectId}` + ) + } + + // Check if the variable already exists in the environment + if (await this.variableExists(dto.name, environment.id)) { + throw new ConflictException( + `Variable already exists: ${dto.name} in environment ${environment.id} in project ${projectId}` + ) + } + + // Create the variable + const variable = await this.prisma.variable.create({ + data: { + name: dto.name, + versions: { + create: { + value: dto.value, + version: 1, + createdBy: { + connect: { + id: user.id + } + } + } + }, + environment: { + connect: { + id: environment.id + } + }, + project: { + connect: { + id: projectId + } + }, + lastUpdatedBy: { + connect: { + id: user.id + } + } + } + }) + + createEvent( + { + triggeredBy: user, + entity: variable, + type: EventType.VARIABLE_ADDED, + source: EventSource.VARIABLE, + title: `Variable created`, + metadata: { + variableId: variable.id, + name: variable.name, + projectId, + projectName: project.name, + environmentId: environment.id, + environmentName: environment.name + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} created variable ${variable.id}`) + + return variable + } + + async updateVariable( + user: User, + variableId: Variable['id'], + dto: UpdateVariable + ) { + const variable = await getVariableWithAuthority( + user.id, + variableId, + Authority.UPDATE_VARIABLE, + this.prisma + ) + + let result + + // Check if the variable already exists in the environment + if ( + (dto.name && + (await this.variableExists(dto.name, variable.environmentId))) || + variable.name === dto.name + ) { + throw new ConflictException( + `Variable already exists: ${dto.name} in environment ${variable.environmentId}` + ) + } + + // Update the variable + // If a new variable value is proposed, we want to create a new version for that variable + if (dto.value) { + const previousVersion = await this.prisma.variableVersion.findFirst({ + where: { + variableId + }, + select: { + version: true + }, + orderBy: { + version: 'desc' + }, + take: 1 + }) + + result = await this.prisma.variable.update({ + where: { + id: variableId + }, + data: { + name: dto.name, + lastUpdatedById: user.id, + versions: { + create: { + value: dto.value, + version: previousVersion.version + 1, + createdById: user.id + } + } + } + }) + } else { + result = await this.prisma.variable.update({ + where: { + id: variableId + }, + data: { + name: dto.name ?? variable.name, + lastUpdatedById: user.id + } + }) + } + + createEvent( + { + triggeredBy: user, + entity: variable, + type: EventType.VARIABLE_UPDATED, + source: EventSource.VARIABLE, + title: `Variable updated`, + metadata: { + variableId: variable.id, + name: variable.name, + projectId: variable.projectId, + projectName: variable.project.name + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} updated variable ${variable.id}`) + + return result + } + + async updateVariableEnvironment( + user: User, + variableId: Variable['id'], + environmentId: Environment['id'] + ) { + const variable = await getVariableWithAuthority( + user.id, + variableId, + Authority.UPDATE_VARIABLE, + this.prisma + ) + + if (variable.environmentId === environmentId) { + throw new BadRequestException( + `Can not update the environment of the variable to the same environment: ${environmentId}` + ) + } + + // Check if the environment exists + const environment = await getEnvironmentWithAuthority( + user.id, + environmentId, + Authority.READ_ENVIRONMENT, + this.prisma + ) + + // Check if the environment belongs to the same project + if (environment.projectId !== variable.projectId) { + throw new BadRequestException( + `Environment ${environmentId} does not belong to the same project ${variable.projectId}` + ) + } + + // Check if the variable already exists in the environment + if (await this.variableExists(variable.name, environment.id)) { + throw new ConflictException( + `Variable already exists: ${variable.name} in environment ${environment.id} in project ${variable.projectId}` + ) + } + + // Update the variable + const result = await this.prisma.variable.update({ + where: { + id: variableId + }, + data: { + environment: { + connect: { + id: environmentId + } + } + } + }) + + createEvent( + { + triggeredBy: user, + entity: variable, + type: EventType.VARIABLE_UPDATED, + source: EventSource.VARIABLE, + title: `Variable environment updated`, + metadata: { + variableId: variable.id, + name: variable.name, + projectId: variable.projectId, + projectName: variable.project.name, + environmentId: environment.id, + environmentName: environment.name + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} updated variable ${variable.id}`) + + return result + } + + async rollbackVariable( + user: User, + variableId: Variable['id'], + rollbackVersion: VariableVersion['version'] + ) { + const variable = await getVariableWithAuthority( + user.id, + variableId, + Authority.UPDATE_VARIABLE, + this.prisma + ) + + const maxVersion = variable.versions[variable.versions.length - 1].version + + // Check if the rollback version is valid + if (rollbackVersion < 1 || rollbackVersion >= maxVersion) { + throw new NotFoundException( + `Invalid rollback version: ${rollbackVersion} for variable: ${variableId}` + ) + } + + // Rollback the variable + const result = await this.prisma.variableVersion.deleteMany({ + where: { + variableId, + version: { + gt: Number(rollbackVersion) + } + } + }) + + createEvent( + { + triggeredBy: user, + entity: variable, + type: EventType.VARIABLE_UPDATED, + source: EventSource.VARIABLE, + title: `Variable rolled back`, + metadata: { + variableId: variable.id, + name: variable.name, + projectId: variable.projectId, + projectName: variable.project.name, + rollbackVersion + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} rolled back variable ${variable.id}`) + + return result + } + + async deleteVariable(user: User, variableId: Variable['id']) { + const variable = await getVariableWithAuthority( + user.id, + variableId, + Authority.DELETE_VARIABLE, + this.prisma + ) + + await this.prisma.variable.delete({ + where: { + id: variableId + } + }) + + createEvent( + { + triggeredBy: user, + type: EventType.VARIABLE_DELETED, + source: EventSource.VARIABLE, + title: `Variable deleted`, + metadata: { + variableId: variable.id, + name: variable.name, + projectId: variable.projectId, + projectName: variable.project.name + } + }, + this.prisma + ) + + this.logger.log(`User ${user.id} deleted variable ${variable.id}`) + } + + async getVariableById(user: User, variableId: Variable['id']) { + return getVariableWithAuthority( + user.id, + variableId, + Authority.READ_VARIABLE, + this.prisma + ) + } + + async getAllVariablesOfProject( + user: User, + projectId: Project['id'], + page: number, + limit: number, + sort: string, + order: string, + search: string + ) { + // Check if the user has the required authorities in the project + await getProjectWithAuthority( + user.id, + projectId, + Authority.READ_VARIABLE, + this.prisma + ) + + return await this.prisma.variable.findMany({ + where: { + projectId, + name: { + contains: search + } + }, + include: { + versions: { + orderBy: { + version: 'desc' + }, + take: 1 + }, + lastUpdatedBy: { + select: { + id: true, + name: true + } + }, + environment: { + select: { + id: true, + name: true + } + } + }, + skip: page * limit, + take: limit, + orderBy: { + [sort]: order + } + }) + } + + private async variableExists( + variableName: Variable['name'], + environmentId: Environment['id'] + ): Promise { + return ( + (await this.prisma.variable.count({ + where: { + name: variableName, + environment: { + id: environmentId + } + } + })) > 0 + ) + } +} diff --git a/apps/api/src/variable/variable.e2e.spec.ts b/apps/api/src/variable/variable.e2e.spec.ts new file mode 100644 index 00000000..a88d9447 --- /dev/null +++ b/apps/api/src/variable/variable.e2e.spec.ts @@ -0,0 +1,865 @@ +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import { ProjectService } from '../project/service/project.service' +import { WorkspaceService } from '../workspace/service/workspace.service' +import { + Environment, + EventSeverity, + EventSource, + EventTriggerer, + EventType, + Project, + Variable, + VariableVersion, + User, + Workspace +} from '@prisma/client' +import { Test } from '@nestjs/testing' +import { AppModule } from '../app/app.module' +import { EventModule } from '../event/event.module' +import { WorkspaceModule } from '../workspace/workspace.module' +import { ProjectModule } from '../project/project.module' +import { EnvironmentModule } from '../environment/environment.module' +import { VariableModule } from './variable.module' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import cleanUp from '../common/cleanup' +import { EnvironmentService } from '../environment/service/environment.service' +import { v4 } from 'uuid' +import fetchEvents from '../common/fetch-events' +import { VariableService } from './service/variable.service' + +describe('Variable Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let projectService: ProjectService + let workspaceService: WorkspaceService + let environmentService: EnvironmentService + let variableService: VariableService + + let user1: User, user2: User + let workspace1: Workspace, workspace2: Workspace + let project1: Project, project2: Project, workspace2Project: Project + let environment1: Environment, + environment2: Environment, + workspace2Environment: Environment + let variable1: Variable + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AppModule, + EventModule, + WorkspaceModule, + ProjectModule, + EnvironmentModule, + VariableModule + ] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + projectService = moduleRef.get(ProjectService) + workspaceService = moduleRef.get(WorkspaceService) + environmentService = moduleRef.get(EnvironmentService) + variableService = moduleRef.get(VariableService) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + + await cleanUp(prisma) + + const user1Id = v4() + const user2Id = v4() + + user1 = await prisma.user.create({ + data: { + id: user1Id, + email: 'johndoe@keyshade.xyz', + name: 'John Doe', + isOnboardingFinished: true + } + }) + + user2 = await prisma.user.create({ + data: { + id: user2Id, + email: 'janedoe@keyshade.xyz', + name: 'Jane Doe', + isOnboardingFinished: true + } + }) + + workspace1 = await workspaceService.createWorkspace(user1, { + name: 'Workspace 1', + description: 'Workspace 1 description' + }) + + workspace2 = await workspaceService.createWorkspace(user2, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + project1 = await projectService.createProject(user1, workspace1.id, { + name: 'Project 1', + description: 'Project 1 description', + storePrivateKey: true, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + { + name: 'Environment 2', + description: 'Environment 2 description', + isDefault: false + } + ] + }) + + project2 = await projectService.createProject(user1, workspace1.id, { + name: 'Project 2', + description: 'Project 2 description', + storePrivateKey: false, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + } + ] + }) + + workspace2Project = await projectService.createProject( + user2, + workspace2.id, + { + name: 'Workspace 2 Project', + description: 'Workspace 2 Project description', + storePrivateKey: true, + environments: [ + { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + } + ] + } + ) + + workspace2Environment = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: workspace2Project.id, + name: 'Environment 1' + } + } + }) + + environment1 = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + } + }) + + environment2 = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 2' + } + } + }) + }) + + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + expect(projectService).toBeDefined() + expect(workspaceService).toBeDefined() + expect(environmentService).toBeDefined() + }) + + it('should be able to create a variable', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + environmentId: environment2.id, + name: 'Variable 1', + value: 'Variable 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + + const body = response.json() + + expect(body).toBeDefined() + expect(body.name).toBe('Variable 1') + expect(body.environmentId).toBe(environment2.id) + expect(body.projectId).toBe(project1.id) + + variable1 = body + }) + + it('should have created a variable version', async () => { + const variableVersion = await prisma.variableVersion.findFirst({ + where: { + variableId: variable1.id + } + }) + + expect(variableVersion).toBeDefined() + expect(variableVersion.value).toBe('Variable 1 value') + expect(variableVersion.version).toBe(1) + }) + + it('should create variable in default environment if environmentId is not provided', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + name: 'Variable 2', + value: 'Variable 2 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + + const body = response.json() + + expect(body).toBeDefined() + expect(body.name).toBe('Variable 2') + expect(body.environmentId).toBe(environment1.id) + expect(body.projectId).toBe(project1.id) + }) + + it('should not be able to create a variable with a non-existing environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + environmentId: 'non-existing-environment-id', + name: 'Variable 3', + value: 'Variable 3 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to create a variable if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + name: 'Variable 3', + value: 'Variable 3 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` + ) + }) + + it('should fail if project has no default environment(hypothetical case)', async () => { + await prisma.environment.update({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + }, + data: { + isDefault: false + } + }) + + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + name: 'Variable 4', + value: 'Variable 4 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `No default environment found for project with id ${project1.id}` + ) + + await prisma.environment.update({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Environment 1' + } + }, + data: { + isDefault: true + } + }) + }) + + it('should not be able to create a duplicate variable in the same environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.id}`, + payload: { + environmentId: environment2.id, + name: 'Variable 1', + value: 'Variable 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Variable already exists: Variable 1 in environment ${environment2.id} in project ${project1.id}` + ) + }) + + it('should have created a VARIABLE_ADDED event', async () => { + const response = await fetchEvents(app, user1, 'variableId=' + variable1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.VARIABLE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.VARIABLE_ADDED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should not be able to update a non-existing variable', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/non-existing-variable-id`, + payload: { + name: 'Updated Variable 1', + value: 'Updated Variable 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable with id non-existing-variable-id not found' + ) + }) + + it('should not be able to update a variable with same name in the same environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}`, + payload: { + name: 'Variable 1', + value: 'Updated Variable 1 value', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Variable already exists: Variable 1 in environment ${environment2.id}` + ) + }) + + it('should be able to update the environment name without creating a new version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}`, + payload: { + name: 'Updated Variable 1' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toEqual('Updated Variable 1') + + const variableVersion = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) + + expect(variableVersion.length).toBe(1) + + variable1 = response.json() + }) + + it('should create a new version if the value is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}`, + payload: { + value: 'Updated Variable 1 value' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + + const variableVersion = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) + + expect(variableVersion.length).toBe(2) + }) + + it('should have created a VARIABLE_UPDATED event', async () => { + const response = await fetchEvents(app, user1, 'variableId=' + variable1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.VARIABLE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.VARIABLE_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should be able to update the environment of a variable', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().environmentId).toBe(environment1.id) + }) + + it('should not be able to move to a non-existing environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/environment/non-existing-environment-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + }) + + it('should not be able to move to an environment in another project', async () => { + const otherEnvironment = await prisma.environment.findUnique({ + where: { + projectId_name: { + projectId: project2.id, + name: 'Environment 1' + } + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/environment/${otherEnvironment.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Environment ${otherEnvironment.id} does not belong to the same project ${project1.id}` + ) + }) + + it('should not be able to move the variable to the same environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Can not update the environment of the variable to the same environment: ${environment1.id}` + ) + }) + + it('should not be able to move the variable if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/environment/${workspace2Environment.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user1.id} does not have the required authorities` + ) + }) + + it('should have created a VARIABLE_UPDATED event', async () => { + const response = await fetchEvents(app, user1, 'variableId=' + variable1.id) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.VARIABLE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.VARIABLE_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should not be able to move a variable of the same name to an environment', async () => { + const newVariable = await prisma.variable.create({ + data: { + projectId: project1.id, + environmentId: environment2.id, + name: 'Updated Variable 1' + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/variable/${newVariable.id}/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Variable already exists: Updated Variable 1 in environment ${environment1.id} in project ${project1.id}` + ) + }) + + it('should not be able to roll back a non-existing variable', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/non-existing-variable-id/rollback/1`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable with id non-existing-variable-id not found' + ) + }) + + it('should not be able to roll back a variable it does not have access to', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/rollback/1`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should not be able to roll back to a non-existing version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/rollback/2`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Invalid rollback version: 2 for variable: ${variable1.id}` + ) + }) + + it('should be able to roll back a variable', async () => { + // Creating a few versions first + await variableService.updateVariable(user1, variable1.id, { + value: 'Updated Variable 1 value' + }) + + await variableService.updateVariable(user1, variable1.id, { + value: 'Updated Variable 1 value 2' + }) + + let versions: VariableVersion[] + + versions = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) + + expect(versions.length).toBe(4) + + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.id}/rollback/1`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().count).toEqual(3) + + versions = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) + + expect(versions.length).toBe(1) + }) + + it('should not be able to fetch a non existing variable', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/non-existing-variable-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable with id non-existing-variable-id not found' + ) + }) + + it('should not be able to fetch a variable it does not have access to', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should be able to fetch a variable', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toEqual(variable1.id) + + const versions = await response.json().versions + + expect(versions.length).toBe(1) + expect(versions[0].value).toEqual('Variable 1 value') // Variable should be in encrypted form until specified otherwise + }) + + it('should be able to fetch a variable', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toEqual(variable1.id) + + const versions = await response.json().versions + + expect(versions.length).toBe(1) + expect(versions[0].value).toEqual('Variable 1 value') + }) + + it('should be able to fetch all variables', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(3) + }) + + it('should be able to fetch all variables', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(3) + + const variable = response.json()[0] + + expect(variable.versions[0].value).toEqual('Variable 1 value') + }) + + it('should not be able to fetch all variables if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User with id ${user2.id} does not have the authority in the project with id ${project1.id}` + ) + }) + + it('should not be able to fetch all variables if the project does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/all/non-existing-project-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project with id non-existing-project-id not found' + ) + }) + + it('should not be able to delete a non-existing variable', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/variable/non-existing-variable-id`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable with id non-existing-variable-id not found' + ) + }) + + it('should not be able to delete a variable it does not have access to', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/variable/${variable1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toEqual( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should be able to delete a variable', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/variable/${variable1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/apps/api/src/variable/variable.module.ts b/apps/api/src/variable/variable.module.ts new file mode 100644 index 00000000..8df51a08 --- /dev/null +++ b/apps/api/src/variable/variable.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { VariableController } from './controller/variable.controller' +import { VariableService } from './service/variable.service' + +@Module({ + controllers: [VariableController], + providers: [VariableService] +}) +export class VariableModule {} diff --git a/apps/api/src/variable/variable.types.ts b/apps/api/src/variable/variable.types.ts new file mode 100644 index 00000000..89eb040c --- /dev/null +++ b/apps/api/src/variable/variable.types.ts @@ -0,0 +1,16 @@ +import { Project, Variable, VariableVersion } from '@prisma/client' + +export interface VariableWithValue extends Variable { + value: string +} + +export interface VariableWithVersion extends Variable { + versions: VariableVersion[] +} + +export interface VariableWithProject extends Variable { + project: Project +} + +export type VariableWithProjectAndVersion = VariableWithProject & + VariableWithVersion