diff --git a/apps/api/src/common/get-environment-with-authority.ts b/apps/api/src/common/get-environment-with-authority.ts index ab057314..f0353c9d 100644 --- a/apps/api/src/common/get-environment-with-authority.ts +++ b/apps/api/src/common/get-environment-with-authority.ts @@ -1,4 +1,4 @@ -import { ConflictException, NotFoundException } from '@nestjs/common' +import { NotFoundException, UnauthorizedException } from '@nestjs/common' import { Authority, Environment, PrismaClient, User } from '@prisma/client' import getCollectiveProjectAuthorities from './get-collective-project-authorities' @@ -35,7 +35,7 @@ export default async function getEnvironmentWithAuthority( !permittedAuthorities.has(authority) && !permittedAuthorities.has(Authority.WORKSPACE_ADMIN) ) { - throw new ConflictException( + throw new UnauthorizedException( `User ${userId} does not have the required authorities` ) } diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts new file mode 100644 index 00000000..049e6c9e --- /dev/null +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -0,0 +1,586 @@ +import { Test } from '@nestjs/testing' +import { AppModule } from '../app/app.module' +import { EnvironmentModule } from './environment.module' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import cleanUp from '../common/cleanup' +import { + Authority, + Environment, + EventSeverity, + EventSource, + EventTriggerer, + EventType, + Project, + User, + Workspace, + WorkspaceRole +} from '@prisma/client' +import { v4 } from 'uuid' +import fetchEvents from '../common/fetch-events' +import { ProjectModule } from '../project/project.module' +import { WorkspaceService } from '../workspace/service/workspace.service' +import { ProjectService } from '../project/service/project.service' +import { CreateWorkspace } from '../workspace/dto/create.workspace/create.workspace' +import { EventModule } from '../event/event.module' +import { UserModule } from '../user/user.module' +import { WorkspaceModule } from '../workspace/workspace.module' +import { WorkspaceRoleModule } from '../workspace-role/workspace-role.module' +import { SecretModule } from '../secret/secret.module' +import { ApiKeyModule } from '../api-key/api-key.module' + +describe('Environment Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + let projectService: ProjectService + let workspaceService: WorkspaceService + + let user1: User, user2: User + let workspace1: Workspace, workspace2: Workspace + let project1: Project + let environment1: Environment, environment2: Environment + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + AppModule, + EventModule, + UserModule, + WorkspaceModule, + WorkspaceRoleModule, + SecretModule, + ProjectModule, + EnvironmentModule, + ApiKeyModule + ] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + projectService = moduleRef.get(ProjectService) + workspaceService = moduleRef.get(WorkspaceService) + + 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: [] + }) + }) + + it('should be defined', () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + }) + + it('should be able to create an environment under a project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.id}`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true, + projectId: project1.id, + lastUpdatedById: user1.id, + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + + environment1 = response.json() + }) + + it('should ensure there is only one default environment per project', async () => { + const environments = await prisma.environment.findMany({ + where: { + projectId: project1.id + } + }) + + expect(environments.length).toBe(2) + expect(environments.filter((e) => e.isDefault).length).toBe(1) + }) + + it('should not be able to create an environment in a project that does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/123`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Project with id 123 not found') + }) + + it('should not be able to create an environment in a project that the user does not have access to', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.id}`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toBe( + `User with id ${user2.id} does not have the authority ${Authority.CREATE_ENVIRONMENT} in the project with id ${project1.id}` + ) + }) + + it('should not be able to create a duplicate environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.id}`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description', + isDefault: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toBe( + `Environment with name Environment 1 already exists in project ${project1.name} (${project1.id})` + ) + }) + + it('should not make other environments non-default if the current environment is not the default one', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.id}`, + payload: { + name: 'Environment 2', + description: 'Environment 2 description', + isDefault: false + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json().name).toBe('Environment 2') + expect(response.json().description).toBe('Environment 2 description') + expect(response.json().isDefault).toBe(false) + + environment2 = response.json() + + const environments = await prisma.environment.findMany({ + where: { + projectId: project1.id + } + }) + + expect(environments.length).toBe(3) + expect(environments.filter((e) => e.isDefault).length).toBe(1) + }) + + it('should have created a ENVIRONMENT_ADDED event', async () => { + const response = await fetchEvents( + app, + user1, + 'environmentId=' + environment1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.ENVIRONMENT, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.ENVIRONMENT_ADDED, + 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 an environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.id}`, + payload: { + name: 'Environment 1 Updated', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: environment1.id, + name: 'Environment 1 Updated', + description: 'Environment 1 description updated', + isDefault: true, + projectId: project1.id, + lastUpdatedById: user1.id, + lastUpdatedBy: expect.any(Object), + secrets: [], + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + + environment1 = response.json() + }) + + it('should not be able to update an environment that does not exist', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/123`, + payload: { + name: 'Environment 1 Updated', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Environment with id 123 not found') + }) + + it('should not be able to update an environment that the user does not have access to', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.id}`, + payload: { + name: 'Environment 1 Updated', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toBe( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should not be able to update an environment to a duplicate name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.id}`, + payload: { + name: 'Environment 2', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json().message).toBe( + `Environment with name Environment 2 already exists in project ${project1.id}` + ) + }) + + it('should create a ENVIRONMENT_UPDATED event', async () => { + const response = await fetchEvents( + app, + user1, + 'environmentId=' + environment1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.ENVIRONMENT, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.ENVIRONMENT_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(expect.arrayContaining([event])) + }) + + it('should make other environments non-default if the current environment is the default one', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment2.id}`, + payload: { + name: 'Environment 2 Updated', + description: 'Environment 2 description updated', + isDefault: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toBe('Environment 2 Updated') + expect(response.json().description).toBe( + 'Environment 2 description updated' + ) + expect(response.json().isDefault).toBe(true) + + const environments = await prisma.environment.findMany({ + where: { + projectId: project1.id + } + }) + + expect(environments.length).toBe(3) + expect(environments.filter((e) => e.isDefault).length).toBe(1) + + environment2 = response.json() + environment1.isDefault = false + }) + + it('should be able to fetch an environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toBe('Environment 1 Updated') + expect(response.json().description).toBe( + 'Environment 1 description updated' + ) + expect(response.json().isDefault).toBe(false) + }) + + it('should not be able to fetch an environment that does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Environment with id 123 not found') + }) + + it('should not be able to fetch an environment that the user does not have access to', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toBe( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should be able to fetch all environments of a project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + it('should not be able to fetch all environments of a project that does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/all/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Project with id 123 not found') + }) + + it('should not be able to fetch all environments of a project that the user does not have access to', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/all/${project1.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toBe( + `User with id ${user2.id} does not have the authority ${Authority.READ_ENVIRONMENT} in the project with id ${project1.id}` + ) + }) + + it('should be able to delete an environment', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/environment/${environment1.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + }) + + it('should not be able to delete an environment that does not exist', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/environment/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Environment with id 123 not found') + }) + + it('should not be able to delete an environment that the user does not have access to', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/environment/${environment2.id}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json().message).toBe( + `User ${user2.id} does not have the required authorities` + ) + }) + + it('should not be able to delete the default environment of a project', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/environment/${environment2.id}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toBe( + 'Cannot delete the default environment' + ) + }) + + it('should not be able to make the only environment non-default', async () => { + await prisma.environment.delete({ + where: { + projectId_name: { + projectId: project1.id, + name: 'Default' + } + } + }) + + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment2.id}`, + payload: { + isDefault: false + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(400) + expect(response.json().message).toBe( + 'Cannot make the last environment non-default' + ) + }) + + afterAll(async () => { + await cleanUp(prisma) + }) +}) diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index a1564835..3ac530e3 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -1,4 +1,8 @@ -import { ConflictException, Injectable } from '@nestjs/common' +import { + BadRequestException, + ConflictException, + Injectable +} from '@nestjs/common' import { Authority, Environment, @@ -33,7 +37,9 @@ export class EnvironmentService { // Check if an environment with the same name already exists if (await this.environmentExists(dto.name, projectId)) { - throw new ConflictException('Environment already exists') + throw new ConflictException( + `Environment with name ${dto.name} already exists in project ${project.name} (${project.id})` + ) } // If the current environment needs to be the default one, we will @@ -45,7 +51,7 @@ export class EnvironmentService { } // Create the environment - ops.unshift( + ops.push( this.prisma.environment.create({ data: { name: dto.name, @@ -66,7 +72,7 @@ export class EnvironmentService { ) const result = await this.prisma.$transaction(ops) - const environment = result[0] as Environment + const environment = result[result.length - 1] createEvent( { @@ -102,15 +108,31 @@ export class EnvironmentService { // Check if an environment with the same name already exists if ( - (dto.name && - (await this.environmentExists(dto.name, environment.projectId))) || - environment.name === dto.name + dto.name && + (environment.name === dto.name || + (await this.environmentExists(dto.name, environment.projectId))) ) { - throw new ConflictException('Environment already exists') + throw new ConflictException( + `Environment with name ${dto.name} already exists in project ${environment.projectId}` + ) } const ops = [] + // If this environment is the last one, and is being updated to be non-default + // we will skip this operation + const count = await this.prisma.environment.count({ + where: { + projectId: environment.projectId + } + }) + + if (dto.isDefault === false && environment.isDefault && count === 1) { + throw new BadRequestException( + 'Cannot make the last environment non-default' + ) + } + // If the current environment needs to be the default one, we will // need to update the existing default environment to be a regular one if (dto.isDefault) { @@ -118,7 +140,7 @@ export class EnvironmentService { } // Update the environment - ops.unshift( + ops.push( this.prisma.environment.update({ where: { id: environmentId @@ -126,7 +148,10 @@ export class EnvironmentService { data: { name: dto.name, description: dto.description, - isDefault: dto.isDefault, + isDefault: + dto.isDefault !== undefined || dto.isDefault !== null + ? dto.isDefault + : environment.isDefault, lastUpdatedById: user.id }, include: { @@ -137,7 +162,7 @@ export class EnvironmentService { ) const result = await this.prisma.$transaction(ops) - const updatedEnvironment = result[0] as Environment + const updatedEnvironment = result[result.length - 1] createEvent( { @@ -241,7 +266,7 @@ export class EnvironmentService { // Check if the environment is the default one if (environment.isDefault) { - throw new ConflictException('Cannot delete the default environment') + throw new BadRequestException('Cannot delete the default environment') } // Check if this is the last environment @@ -251,7 +276,7 @@ export class EnvironmentService { } }) if (count === 1) { - throw new ConflictException('Cannot delete the last environment') + throw new BadRequestException('Cannot delete the last environment') } // Delete the environment @@ -264,7 +289,6 @@ export class EnvironmentService { createEvent( { triggeredBy: user, - entity: environment, type: EventType.ENVIRONMENT_DELETED, source: EventSource.ENVIRONMENT, title: `Environment deleted`, @@ -290,8 +314,8 @@ export class EnvironmentService { }) } - private async makeAllNonDefault(projectId: Project['id']): Promise { - this.prisma.environment.updateMany({ + private makeAllNonDefault(projectId: Project['id']) { + return this.prisma.environment.updateMany({ where: { projectId }, diff --git a/apps/api/src/prisma/migrations/20240216155823_add_unique_key_to_environment/migration.sql b/apps/api/src/prisma/migrations/20240216155823_add_unique_key_to_environment/migration.sql new file mode 100644 index 00000000..7d5ae6d8 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240216155823_add_unique_key_to_environment/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[projectId,name]` on the table `Environment` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index c10ea203..7de82c5e 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -204,6 +204,8 @@ model Environment { projectId String events Event[] + + @@unique([projectId, name]) } model Project {