diff --git a/apps/api/src/api-key/api-key.e2e.spec.ts b/apps/api/src/api-key/api-key.e2e.spec.ts index e86bc36b..ae942be0 100644 --- a/apps/api/src/api-key/api-key.e2e.spec.ts +++ b/apps/api/src/api-key/api-key.e2e.spec.ts @@ -65,283 +65,317 @@ describe('Api Key Role Controller Tests', () => { expect(apiKeyService).toBeDefined() }) - it('should be able to create api key', async () => { - const response = await app.inject({ - method: 'POST', - url: '/api-key', - payload: { - name: 'Test', - expiresAfter: '24', - authorities: ['READ_API_KEY'] - }, - headers: { - 'x-e2e-user-email': user.email - } + describe('Create API Key Tests', () => { + it('should be able to create api key', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api-key', + payload: { + name: 'Test', + expiresAfter: '24', + authorities: ['READ_API_KEY'] + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json().id).toBeDefined() + expect(response.json().name).toBe('Test') + expect(response.json().slug).toBeDefined() + expect(response.json().value).toMatch(/^ks_*/) + expect(response.json().authorities).toEqual(['READ_API_KEY']) + + const apiKey = await prisma.apiKey.findUnique({ + where: { + id: response.json().id + } + }) + + expect(apiKey).toBeDefined() + expect(apiKey!.name).toBe('Test') }) - expect(response.statusCode).toBe(201) - expect(response.json().id).toBeDefined() - expect(response.json().name).toBe('Test') - expect(response.json().value).toMatch(/^ks_*/) - expect(response.json().authorities).toEqual(['READ_API_KEY']) - - const apiKey = await prisma.apiKey.findUnique({ - where: { - id: response.json().id - } + it('should not be able to create api key with same name', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api-key', + payload: { + name: 'Test Key', + expiresAfter: '24', + authorities: ['READ_API_KEY'] + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(409) }) - expect(apiKey).toBeDefined() - expect(apiKey!.name).toBe('Test') - }) - - it('should not be able to create api key with same name', async () => { - const response = await app.inject({ - method: 'POST', - url: '/api-key', - payload: { - name: 'Test Key', - expiresAfter: '24', - authorities: ['READ_API_KEY'] - }, - headers: { - 'x-e2e-user-email': user.email - } + it('should not have any authorities if none are provided', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api-key', + payload: { + name: 'Test', + expiresAfter: '24' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(201) + expect(response.json().id).toBeDefined() + expect(response.json().name).toBe('Test') + expect(response.json().value).toMatch(/^ks_*/) + expect(response.json().authorities).toEqual([]) }) - expect(response.statusCode).toBe(409) - }) - - it('should not have any authorities if none are provided', async () => { - const response = await app.inject({ - method: 'POST', - url: '/api-key', - payload: { - name: 'Test', - expiresAfter: '24' - }, - headers: { - 'x-e2e-user-email': user.email - } + it('should not be able to create api key with invalid authorities of API key', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api-key', + payload: { + name: 'Test Key', + expiresAfter: '24' + }, + headers: { + 'x-keyshade-token': apiKey.value + } + }) + + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(201) - expect(response.json().id).toBeDefined() - expect(response.json().name).toBe('Test') - expect(response.json().value).toMatch(/^ks_*/) - expect(response.json().authorities).toEqual([]) }) - it('should not be able to update an api key with the same name', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/api-key/${apiKey.id}`, - payload: { - name: 'Test Key', - expiresAfter: '168' - }, - headers: { - 'x-e2e-user-email': user.email - } + describe('Update API Key Tests', () => { + it('should not be able to update an api key with the same name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api-key/${apiKey.slug}`, + payload: { + name: 'Test Key', + expiresAfter: '168' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(409) }) - expect(response.statusCode).toBe(409) - }) - - it('should be able to update the api key without without changing the authorities', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/api-key/${apiKey.id}`, - payload: { - name: 'Updated Test Key' - }, - headers: { - 'x-e2e-user-email': user.email - } + it('should change the slug if the name is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api-key/${apiKey.slug}`, + payload: { + name: 'Updated Test Key' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toBe(apiKey.id) + expect(response.json().name).toBe('Updated Test Key') + expect(response.json().slug).not.toBe(apiKey.slug) }) - expect(response.statusCode).toBe(200) - expect(response.json().id).toBe(apiKey.id) - expect(response.json().name).toBe('Updated Test Key') - expect(response.json().authorities).toEqual([ - 'READ_API_KEY', - 'CREATE_ENVIRONMENT' - ]) - - const updatedApiKey = await prisma.apiKey.findUnique({ - where: { - id: apiKey.id - } + it('should be able to update the api key without without changing the authorities', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api-key/${apiKey.slug}`, + payload: { + name: 'Updated Test Key' + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toBe(apiKey.id) + expect(response.json().name).toBe('Updated Test Key') + expect(response.json().authorities).toEqual([ + 'READ_API_KEY', + 'CREATE_ENVIRONMENT' + ]) + + const updatedApiKey = await prisma.apiKey.findUnique({ + where: { + id: apiKey.id + } + }) + + expect(updatedApiKey).toBeDefined() + expect(updatedApiKey!.name).toBe('Updated Test Key') }) - expect(updatedApiKey).toBeDefined() - expect(updatedApiKey!.name).toBe('Updated Test Key') - }) - - it('should be able to update the api key with changing the expiry', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/api-key/${apiKey.id}`, - payload: { - name: 'Updated Test Key', - authorities: ['READ_API_KEY', 'CREATE_ENVIRONMENT'] - }, - headers: { - 'x-e2e-user-email': user.email - } + it('should be able to update the api key with changing the expiry', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/api-key/${apiKey.slug}`, + payload: { + name: 'Updated Test Key', + authorities: ['READ_API_KEY', 'CREATE_ENVIRONMENT'] + }, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().id).toBe(apiKey.id) + expect(response.json().name).toBe('Updated Test Key') + expect(response.json().authorities).toEqual([ + 'READ_API_KEY', + 'CREATE_ENVIRONMENT' + ]) }) - - expect(response.statusCode).toBe(200) - expect(response.json().id).toBe(apiKey.id) - expect(response.json().name).toBe('Updated Test Key') - expect(response.json().authorities).toEqual([ - 'READ_API_KEY', - 'CREATE_ENVIRONMENT' - ]) }) - it('should be able to get the api key', async () => { - const response = await app.inject({ - method: 'GET', - url: `/api-key/${apiKey.id}`, - headers: { - 'x-e2e-user-email': user.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - id: apiKey.id, - name: 'Test Key', - authorities: ['READ_API_KEY', 'CREATE_ENVIRONMENT'], - expiresAt: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String) - }) - }) - - it('should not be able to get the API key if not exists', async () => { - const response = await app.inject({ - method: 'GET', - url: `/api-key/ks_1234567890`, - headers: { - 'x-e2e-user-email': user.email - } + describe('Get API Key Tests', () => { + it('should be able to get the api key', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api-key/${apiKey.slug}`, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: apiKey.id, + name: 'Test Key', + slug: apiKey.slug, + authorities: ['READ_API_KEY', 'CREATE_ENVIRONMENT'], + expiresAt: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) }) - expect(response.statusCode).toBe(404) - }) + it('should not be able to get the API key if not exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api-key/ks_1234567890`, + headers: { + 'x-e2e-user-email': user.email + } + }) - it('should be able to get all the api keys of the user', async () => { - const response = await app.inject({ - method: 'GET', - url: '/api-key', - headers: { - 'x-e2e-user-email': user.email - } + expect(response.statusCode).toBe(404) }) - - expect(response.statusCode).toBe(200) - expect(response.json()[0].id).toBe(apiKey.id) - expect(response.json()[0].name).toBe('Test Key') - expect(response.json()[0].authorities).toEqual([ - 'READ_API_KEY', - 'CREATE_ENVIRONMENT' - ]) }) - it('should be able to get all api keys using the API key', async () => { - const response = await app.inject({ - method: 'GET', - url: '/api-key', - headers: { - 'x-keyshade-token': apiKey.value - } + describe('Get All API Keys Tests', () => { + it('should be able to get all the api keys of the user', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api-key', + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()[0].id).toBe(apiKey.id) + expect(response.json()[0].name).toBe('Test Key') + expect(response.json()[0].slug).toBe(apiKey.slug) + expect(response.json()[0].authorities).toEqual([ + 'READ_API_KEY', + 'CREATE_ENVIRONMENT' + ]) }) - expect(response.statusCode).toBe(200) - expect(response.json()[0].id).toBe(apiKey.id) - expect(response.json()[0].name).toBe('Test Key') - expect(response.json()[0].authorities).toEqual([ - 'READ_API_KEY', - 'CREATE_ENVIRONMENT' - ]) - }) - - it('should not be able to create api key with invalid authorities of API key', async () => { - const response = await app.inject({ - method: 'POST', - url: '/api-key', - payload: { - name: 'Test Key', - expiresAfter: '24' - }, - headers: { - 'x-keyshade-token': apiKey.value - } + it('should be able to get all api keys using the API key', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api-key', + headers: { + 'x-keyshade-token': apiKey.value + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()[0].id).toBe(apiKey.id) + expect(response.json()[0].name).toBe('Test Key') + expect(response.json()[0].slug).toBe(apiKey.slug) + expect(response.json()[0].authorities).toEqual([ + 'READ_API_KEY', + 'CREATE_ENVIRONMENT' + ]) }) - - expect(response.statusCode).toBe(401) }) - it('should be able to access live updates if API key has the required authorities', async () => { - // Create a new API key with the required authorities - const newApiKey = await apiKeyService.createApiKey(user, { - name: 'Test Key 2', - authorities: [ - Authority.READ_SECRET, - Authority.READ_VARIABLE, - Authority.READ_ENVIRONMENT, - Authority.READ_PROJECT, - Authority.READ_WORKSPACE - ] - }) - - const response = await app.inject({ - method: 'GET', - url: '/api-key/access/live-updates', - headers: { - 'x-keyshade-token': newApiKey.value - } + describe('Access Live Updates Tests', () => { + it('should be able to access live updates if API key has the required authorities', async () => { + // Create a new API key with the required authorities + const newApiKey = await apiKeyService.createApiKey(user, { + name: 'Test Key 2', + authorities: [ + Authority.READ_SECRET, + Authority.READ_VARIABLE, + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT, + Authority.READ_WORKSPACE + ] + }) + + const response = await app.inject({ + method: 'GET', + url: '/api-key/access/live-updates', + headers: { + 'x-keyshade-token': newApiKey.value + } + }) + + expect(response.statusCode).toBe(200) }) - expect(response.statusCode).toBe(200) - }) + it('should not be able to access live updates if API key does not have the required authorities', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api-key/access/live-updates', + headers: { + 'x-keyshade-token': apiKey.value + } + }) - it('should not be able to access live updates if API key does not have the required authorities', async () => { - const response = await app.inject({ - method: 'GET', - url: '/api-key/access/live-updates', - headers: { - 'x-keyshade-token': apiKey.value - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(401) }) - it('should be able to delete the api key', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/api-key/${apiKey.id}`, - headers: { - 'x-e2e-user-email': user.email - } + describe('Delete API Key Tests', () => { + it('should be able to delete the api key', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/api-key/${apiKey.slug}`, + headers: { + 'x-e2e-user-email': user.email + } + }) + + expect(response.statusCode).toBe(204) }) - expect(response.statusCode).toBe(204) - }) + it('should not be able to delete an api key that does not exist', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/api-key/ks_1234567890`, + headers: { + 'x-e2e-user-email': user.email + } + }) - it('should not be able to delete an api key that does not exist', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/api-key/ks_1234567890`, - headers: { - 'x-e2e-user-email': user.email - } + expect(response.statusCode).toBe(404) }) - - expect(response.statusCode).toBe(404) }) afterAll(async () => { diff --git a/apps/api/src/api-key/controller/api-key.controller.ts b/apps/api/src/api-key/controller/api-key.controller.ts index 02c53970..d28dacde 100644 --- a/apps/api/src/api-key/controller/api-key.controller.ts +++ b/apps/api/src/api-key/controller/api-key.controller.ts @@ -26,21 +26,24 @@ export class ApiKeyController { return this.apiKeyService.createApiKey(user, dto) } - @Put(':id') + @Put(':apiKeySlug') @RequiredApiKeyAuthorities(Authority.UPDATE_API_KEY) async updateApiKey( @CurrentUser() user: User, @Body() dto: UpdateApiKey, - @Param('id') id: string + @Param('apiKeySlug') apiKeySlug: string ) { - return this.apiKeyService.updateApiKey(user, id, dto) + return this.apiKeyService.updateApiKey(user, apiKeySlug, dto) } - @Delete(':id') + @Delete(':apiKeySlug') @RequiredApiKeyAuthorities(Authority.DELETE_API_KEY) @HttpCode(204) - async deleteApiKey(@CurrentUser() user: User, @Param('id') id: string) { - return this.apiKeyService.deleteApiKey(user, id) + async deleteApiKey( + @CurrentUser() user: User, + @Param('apiKeySlug') apiKeySlug: string + ) { + return this.apiKeyService.deleteApiKey(user, apiKeySlug) } @Get('/') @@ -63,10 +66,13 @@ export class ApiKeyController { ) } - @Get(':id') + @Get(':apiKeySlug') @RequiredApiKeyAuthorities(Authority.READ_API_KEY) - async getApiKey(@CurrentUser() user: User, @Param('id') id: string) { - return this.apiKeyService.getApiKeyById(user, id) + async getApiKey( + @CurrentUser() user: User, + @Param('apiKeySlug') apiKeySlug: string + ) { + return this.apiKeyService.getApiKeyBySlug(user, apiKeySlug) } @Get('/access/live-updates') diff --git a/apps/api/src/api-key/service/api-key.service.ts b/apps/api/src/api-key/service/api-key.service.ts index bd1c3bdb..f0ac4f1d 100644 --- a/apps/api/src/api-key/service/api-key.service.ts +++ b/apps/api/src/api-key/service/api-key.service.ts @@ -6,12 +6,11 @@ import { } from '@nestjs/common' import { PrismaService } from '@/prisma/prisma.service' import { CreateApiKey } from '../dto/create.api-key/create.api-key' -import { addHoursToDate } from '@/common/add-hours-to-date' -import { generateApiKey } from '@/common/api-key-generator' -import { toSHA256 } from '@/common/to-sha256' import { UpdateApiKey } from '../dto/update.api-key/update.api-key' import { ApiKey, User } from '@prisma/client' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import generateEntitySlug from '@/common/slug-generator' +import { generateApiKey, toSHA256 } from '@/common/cryptography' +import { addHoursToDate, limitMaxItemsPerPage } from '@/common/util' @Injectable() export class ApiKeyService { @@ -19,6 +18,24 @@ export class ApiKeyService { constructor(private readonly prisma: PrismaService) {} + private apiKeySelect = { + id: true, + expiresAt: true, + name: true, + slug: true, + authorities: true, + createdAt: true, + updatedAt: true + } + + /** + * Creates a new API key for the given user. + * + * @throws `ConflictException` if the API key already exists. + * @param user The user to create the API key for. + * @param dto The data to create the API key with. + * @returns The created API key. + */ async createApiKey(user: User, dto: CreateApiKey) { await this.isApiKeyUnique(user, dto.name) @@ -27,6 +44,7 @@ export class ApiKeyService { const apiKey = await this.prisma.apiKey.create({ data: { name: dto.name, + slug: await generateEntitySlug(dto.name, 'API_KEY', this.prisma), value: hashedApiKey, authorities: dto.authorities ? { @@ -50,15 +68,29 @@ export class ApiKeyService { } } - async updateApiKey(user: User, apiKeyId: string, dto: UpdateApiKey) { + /** + * Updates an existing API key of the given user. + * + * @throws `ConflictException` if the API key name already exists. + * @throws `NotFoundException` if the API key with the given slug does not exist. + * @param user The user to update the API key for. + * @param apiKeySlug The slug of the API key to update. + * @param dto The data to update the API key with. + * @returns The updated API key. + */ + async updateApiKey( + user: User, + apiKeySlug: ApiKey['slug'], + dto: UpdateApiKey + ) { await this.isApiKeyUnique(user, dto.name) const apiKey = await this.prisma.apiKey.findUnique({ where: { - id: apiKeyId, - userId: user.id + slug: apiKeySlug } }) + const apiKeyId = apiKey.id if (!apiKey) { throw new NotFoundException(`API key with id ${apiKeyId} not found`) @@ -71,6 +103,9 @@ export class ApiKeyService { }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'API_KEY', this.prisma) + : apiKey.slug, authorities: { set: dto.authorities ? dto.authorities : apiKey.authorities }, @@ -78,14 +113,7 @@ export class ApiKeyService { ? addHoursToDate(dto.expiresAfter) : undefined }, - select: { - id: true, - expiresAt: true, - name: true, - authorities: true, - createdAt: true, - updatedAt: true - } + select: this.apiKeySelect }) this.logger.log(`User ${user.id} updated API key ${apiKeyId}`) @@ -93,44 +121,63 @@ export class ApiKeyService { return updatedApiKey } - async deleteApiKey(user: User, apiKeyId: string) { + /** + * Deletes an API key of the given user. + * + * @throws `NotFoundException` if the API key with the given slug does not exist. + * @param user The user to delete the API key for. + * @param apiKeySlug The slug of the API key to delete. + */ + async deleteApiKey(user: User, apiKeySlug: ApiKey['slug']) { try { await this.prisma.apiKey.delete({ where: { - id: apiKeyId, + slug: apiKeySlug, userId: user.id } }) } catch (error) { - throw new NotFoundException(`API key with id ${apiKeyId} not found`) + throw new NotFoundException(`API key with id ${apiKeySlug} not found`) } - this.logger.log(`User ${user.id} deleted API key ${apiKeyId}`) + this.logger.log(`User ${user.id} deleted API key ${apiKeySlug}`) } - async getApiKeyById(user: User, apiKeyId: string) { + /** + * Retrieves an API key of the given user by slug. + * + * @throws `NotFoundException` if the API key with the given slug does not exist. + * @param user The user to retrieve the API key for. + * @param apiKeySlug The slug of the API key to retrieve. + * @returns The API key with the given slug. + */ + async getApiKeyBySlug(user: User, apiKeySlug: ApiKey['slug']) { const apiKey = await this.prisma.apiKey.findUnique({ where: { - id: apiKeyId, + slug: apiKeySlug, userId: user.id }, - select: { - id: true, - expiresAt: true, - name: true, - authorities: true, - createdAt: true, - updatedAt: true - } + select: this.apiKeySelect }) if (!apiKey) { - throw new NotFoundException(`API key with id ${apiKeyId} not found`) + throw new NotFoundException(`API key with id ${apiKeySlug} not found`) } return apiKey } + /** + * Retrieves all API keys of the given user. + * + * @param user The user to retrieve the API keys for. + * @param page The page number to retrieve. + * @param limit The maximum number of items to retrieve per page. + * @param sort The column to sort by. + * @param order The order to sort by. + * @param search The search string to filter the API keys by. + * @returns The API keys of the given user, filtered by the search string. + */ async getAllApiKeysOfUser( user: User, page: number, @@ -151,17 +198,17 @@ export class ApiKeyService { orderBy: { [sort]: order }, - select: { - id: true, - expiresAt: true, - name: true, - authorities: true, - createdAt: true, - updatedAt: true - } + select: this.apiKeySelect }) } + /** + * Checks if an API key with the given name already exists for the given user. + * + * @throws `ConflictException` if the API key already exists. + * @param user The user to check for. + * @param apiKeyName The name of the API key to check. + */ private async isApiKeyUnique(user: User, apiKeyName: string) { let apiKey: ApiKey | null = null diff --git a/apps/api/src/auth/controller/auth.controller.ts b/apps/api/src/auth/controller/auth.controller.ts index dfce03e2..ba3facea 100644 --- a/apps/api/src/auth/controller/auth.controller.ts +++ b/apps/api/src/auth/controller/auth.controller.ts @@ -20,11 +20,11 @@ import { GoogleOAuthStrategyFactory } from '@/config/factory/google/google-strat import { GitlabOAuthStrategyFactory } from '@/config/factory/gitlab/gitlab-strategy.factory' import { Response } from 'express' import { AuthProvider } from '@prisma/client' -import setCookie from '@/common/set-cookie' import { sendOAuthFailureRedirect, sendOAuthSuccessRedirect } from '@/common/redirect' +import { setCookie } from '@/common/util' @Controller('auth') export class AuthController { diff --git a/apps/api/src/auth/guard/admin/admin.guard.ts b/apps/api/src/auth/guard/admin/admin.guard.ts index 30ea6a80..560541fb 100644 --- a/apps/api/src/auth/guard/admin/admin.guard.ts +++ b/apps/api/src/auth/guard/admin/admin.guard.ts @@ -4,6 +4,14 @@ import { Observable } from 'rxjs' @Injectable() export class AdminGuard implements CanActivate { + /** + * This guard will check if the request's user is an admin. + * If the user is an admin, then the canActivate function will return true. + * If the user is not an admin, then the canActivate function will return false. + * + * @param context The ExecutionContext for the request. + * @returns A boolean indicating whether or not the request's user is an admin. + */ canActivate( context: ExecutionContext ): boolean | Promise | Observable { diff --git a/apps/api/src/auth/guard/api-key/api-key.guard.ts b/apps/api/src/auth/guard/api-key/api-key.guard.ts index 68ed2dfb..b32370e0 100644 --- a/apps/api/src/auth/guard/api-key/api-key.guard.ts +++ b/apps/api/src/auth/guard/api-key/api-key.guard.ts @@ -16,6 +16,25 @@ import { IS_PUBLIC_KEY } from '@/decorators/public.decorator' export class ApiKeyGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} + /** + * This method will check if the user is authenticated via an API key, + * and if the API key has the required authorities for the route. + * + * If the user is not authenticated via an API key, or if the API key does not have the required authorities, + * then the canActivate method will return true. + * + * If the user is authenticated via an API key, and the API key has the required authorities, + * then the canActivate method will return true. + * + * If the user is authenticated via an API key, but the API key does not have the required authorities, + * then the canActivate method will throw an UnauthorizedException. + * + * If the user is authenticated via an API key, but the API key is forbidden for the route, + * then the canActivate method will throw an UnauthorizedException. + * + * @param context The ExecutionContext for the request. + * @returns A boolean indicating whether or not the user is authenticated via an API key and has the required authorities for the route. + */ canActivate( context: ExecutionContext ): boolean | Promise | Observable { diff --git a/apps/api/src/auth/guard/auth/auth.guard.ts b/apps/api/src/auth/guard/auth/auth.guard.ts index 10865292..0b470c9d 100644 --- a/apps/api/src/auth/guard/auth/auth.guard.ts +++ b/apps/api/src/auth/guard/auth/auth.guard.ts @@ -11,9 +11,9 @@ import { IS_PUBLIC_KEY } from '@/decorators/public.decorator' import { PrismaService } from '@/prisma/prisma.service' import { ONBOARDING_BYPASSED } from '@/decorators/bypass-onboarding.decorator' import { AuthenticatedUserContext } from '../../auth.types' -import { toSHA256 } from '@/common/to-sha256' import { EnvSchema } from '@/common/env/env.schema' import { CacheService } from '@/cache/cache.service' +import { toSHA256 } from '@/common/cryptography' const X_E2E_USER_EMAIL = 'x-e2e-user-email' const X_KEYSHADE_TOKEN = 'x-keyshade-token' @@ -25,10 +25,19 @@ export class AuthGuard implements CanActivate { constructor( private readonly jwtService: JwtService, private readonly prisma: PrismaService, - private reflector: Reflector, - private cache: CacheService + private readonly reflector: Reflector, + private readonly cache: CacheService ) {} + /** + * This method is called by NestJS every time an HTTP request is made to an endpoint + * that is protected by this guard. It checks if the request is authenticated and if + * the user is active. If the user is not active, it throws an UnauthorizedException. + * If the onboarding is not finished, it throws an UnauthorizedException. + * @param context The ExecutionContext object that contains information about the + * request. + * @returns A boolean indicating if the request is authenticated and the user is active. + */ async canActivate(context: ExecutionContext): Promise { // Get the kind of route. Routes marked with the @Public() decorator are public. const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ diff --git a/apps/api/src/auth/service/auth.service.ts b/apps/api/src/auth/service/auth.service.ts index a689a6d9..f680dfb3 100644 --- a/apps/api/src/auth/service/auth.service.ts +++ b/apps/api/src/auth/service/auth.service.ts @@ -12,10 +12,10 @@ import { Cron, CronExpression } from '@nestjs/schedule' import { UserAuthenticatedResponse } from '../auth.types' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' import { PrismaService } from '@/prisma/prisma.service' -import createUser from '@/common/create-user' import { AuthProvider } from '@prisma/client' -import generateOtp from '@/common/generate-otp' import { CacheService } from '@/cache/cache.service' +import { generateOtp } from '@/common/util' +import { createUser } from '@/common/user' @Injectable() export class AuthService { @@ -30,6 +30,11 @@ export class AuthService { this.logger = new Logger(AuthService.name) } + /** + * Sends a login code to the given email address + * @throws {BadRequestException} If the email address is invalid + * @param email The email address to send the login code to + */ async sendOtp(email: string): Promise { if (!email || !email.includes('@')) { this.logger.error(`Invalid email address: ${email}`) @@ -45,6 +50,14 @@ export class AuthService { } /* istanbul ignore next */ + /** + * Validates a login code sent to the given email address + * @throws {NotFoundException} If the user is not found + * @throws {UnauthorizedException} If the login code is invalid + * @param email The email address the login code was sent to + * @param otp The login code to validate + * @returns An object containing the user and a JWT token + */ async validateOtp( email: string, otp: string @@ -93,6 +106,14 @@ export class AuthService { } /* istanbul ignore next */ + /** + * Handles a login with an OAuth provider + * @param email The email of the user + * @param name The name of the user + * @param profilePictureUrl The profile picture URL of the user + * @param oauthProvider The OAuth provider used + * @returns An object containing the user and a JWT token + */ async handleOAuthLogin( email: string, name: string, @@ -116,6 +137,10 @@ export class AuthService { } /* istanbul ignore next */ + /** + * Cleans up expired OTPs every hour + * @throws {PrismaError} If there is an error deleting expired OTPs + */ @Cron(CronExpression.EVERY_HOUR) async cleanUpExpiredOtps() { try { @@ -133,6 +158,17 @@ export class AuthService { } } + /** + * Creates a user if it doesn't exist yet. If the user has signed up with a + * different authentication provider, it throws an UnauthorizedException. + * @param email The email address of the user + * @param authProvider The AuthProvider used + * @param name The name of the user + * @param profilePictureUrl The profile picture URL of the user + * @returns The user + * @throws {UnauthorizedException} If the user has signed up with a different + * authentication provider + */ private async createUserIfNotExists( email: string, authProvider: AuthProvider, diff --git a/apps/api/src/common/add-hours-to-date.ts b/apps/api/src/common/add-hours-to-date.ts deleted file mode 100644 index 47550885..00000000 --- a/apps/api/src/common/add-hours-to-date.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const addHoursToDate = (hours?: string): Date | undefined => { - if (!hours || hours === 'never') return undefined - - const date = new Date() - date.setHours(date.getHours() + +hours) - return date -} diff --git a/apps/api/src/common/api-key-generator.ts b/apps/api/src/common/api-key-generator.ts deleted file mode 100644 index 1af1d41e..00000000 --- a/apps/api/src/common/api-key-generator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { randomBytes } from 'crypto' - -export const generateApiKey = (): string => - 'ks_' + randomBytes(24).toString('hex') diff --git a/apps/api/src/common/authority-checker.service.ts b/apps/api/src/common/authority-checker.service.ts index 3957e6f8..d053fc66 100644 --- a/apps/api/src/common/authority-checker.service.ts +++ b/apps/api/src/common/authority-checker.service.ts @@ -2,7 +2,6 @@ import { PrismaClient, Authority, Workspace, - Integration, ProjectAccessLevel } from '@prisma/client' import { VariableWithProjectAndVersion } from '@/variable/variable.types' @@ -12,18 +11,21 @@ import { NotFoundException, UnauthorizedException } from '@nestjs/common' -import getCollectiveProjectAuthorities from './get-collective-project-authorities' -import getCollectiveWorkspaceAuthorities from './get-collective-workspace-authorities' import { EnvironmentWithProject } from '@/environment/environment.types' import { ProjectWithSecrets } from '@/project/project.types' import { SecretWithProjectAndVersion } from '@/secret/secret.types' import { CustomLoggerService } from './logger.service' +import { + getCollectiveProjectAuthorities, + getCollectiveWorkspaceAuthorities +} from './collective-authorities' +import { IntegrationWithWorkspace } from '@/integration/integration.types' export interface AuthorityInput { userId: string authorities: Authority[] prisma: PrismaClient - entity: { id?: string; name?: string } + entity: { slug?: string; name?: string } } @Injectable() @@ -47,10 +49,10 @@ export class AuthorityCheckerService { let workspace: Workspace try { - if (entity.id) { + if (entity.slug) { workspace = await prisma.workspace.findUnique({ where: { - id: entity.id + slug: entity.slug } }) } else { @@ -67,11 +69,11 @@ export class AuthorityCheckerService { } if (!workspace) { - throw new NotFoundException(`Workspace with id ${entity.id} not found`) + throw new NotFoundException(`Workspace ${entity.slug} not found`) } const permittedAuthorities = await getCollectiveWorkspaceAuthorities( - entity.id, + workspace.id, userId, prisma ) @@ -98,10 +100,10 @@ export class AuthorityCheckerService { let project: ProjectWithSecrets try { - if (entity.id) { + if (entity.slug) { project = await prisma.project.findUnique({ where: { - id: entity.id + slug: entity.slug }, include: { secrets: true @@ -124,7 +126,7 @@ export class AuthorityCheckerService { } if (!project) { - throw new NotFoundException(`Project with id ${entity.id} not found`) + throw new NotFoundException(`Project ${entity.slug} not found`) } const permittedAuthoritiesForProject: Set = @@ -190,10 +192,10 @@ export class AuthorityCheckerService { let environment: EnvironmentWithProject try { - if (entity.id) { + if (entity.slug) { environment = await prisma.environment.findUnique({ where: { - id: entity.id + slug: entity.slug }, include: { project: true @@ -216,7 +218,7 @@ export class AuthorityCheckerService { } if (!environment) { - throw new NotFoundException(`Environment with id ${entity.id} not found`) + throw new NotFoundException(`Environment ${entity.slug} not found`) } const permittedAuthorities = await getCollectiveProjectAuthorities( @@ -247,10 +249,10 @@ export class AuthorityCheckerService { let variable: VariableWithProjectAndVersion try { - if (entity.id) { + if (entity.slug) { variable = await prisma.variable.findUnique({ where: { - id: entity.id + slug: entity.slug }, include: { versions: true, @@ -275,7 +277,7 @@ export class AuthorityCheckerService { } if (!variable) { - throw new NotFoundException(`Variable with id ${entity.id} not found`) + throw new NotFoundException(`Variable ${entity.slug} not found`) } const permittedAuthorities = await getCollectiveProjectAuthorities( @@ -306,10 +308,10 @@ export class AuthorityCheckerService { let secret: SecretWithProjectAndVersion try { - if (entity.id) { + if (entity.slug) { secret = await prisma.secret.findUnique({ where: { - id: entity.id + slug: entity.slug }, include: { versions: true, @@ -334,7 +336,7 @@ export class AuthorityCheckerService { } if (!secret) { - throw new NotFoundException(`Secret with id ${entity.id} not found`) + throw new NotFoundException(`Secret ${entity.slug} not found`) } const permittedAuthorities = await getCollectiveProjectAuthorities( @@ -359,16 +361,19 @@ export class AuthorityCheckerService { */ public async checkAuthorityOverIntegration( input: AuthorityInput - ): Promise { + ): Promise { const { userId, entity, authorities, prisma } = input - let integration: Integration | null + let integration: IntegrationWithWorkspace | null try { - if (entity.id) { + if (entity.slug) { integration = await prisma.integration.findUnique({ where: { - id: entity.id + slug: entity.slug + }, + include: { + workspace: true } }) } else { @@ -376,6 +381,9 @@ export class AuthorityCheckerService { where: { name: entity.name, workspace: { members: { some: { userId: userId } } } + }, + include: { + workspace: true } }) } @@ -385,7 +393,7 @@ export class AuthorityCheckerService { } if (!integration) { - throw new NotFoundException(`Integration with id ${entity.id} not found`) + throw new NotFoundException(`Integration ${entity.slug} not found`) } const permittedAuthorities = await getCollectiveWorkspaceAuthorities( @@ -405,7 +413,7 @@ export class AuthorityCheckerService { if (!project) { throw new NotFoundException( - `Project with id ${integration.projectId} not found` + `Project with ID ${integration.projectId} not found` ) } @@ -427,7 +435,7 @@ export class AuthorityCheckerService { * * @param permittedAuthorities The set of authorities that the user has * @param authorities The set of authorities required to perform the action - * @param userId The id of the user + * @param userId The slug of the user * @returns void * @throws UnauthorizedException if the user does not have all the required authorities */ diff --git a/apps/api/src/common/cleanup.ts b/apps/api/src/common/cleanup.ts deleted file mode 100644 index 2b01a177..00000000 --- a/apps/api/src/common/cleanup.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PrismaClient } from '@prisma/client' - -export default async function cleanUp(prisma: PrismaClient) { - await prisma.$transaction([ - prisma.workspaceMemberRoleAssociation.deleteMany(), - prisma.workspaceMember.deleteMany(), - prisma.workspaceRole.deleteMany(), - prisma.workspace.deleteMany(), - prisma.secret.deleteMany(), - prisma.environment.deleteMany(), - prisma.project.deleteMany(), - prisma.user.deleteMany(), - prisma.event.deleteMany(), - prisma.apiKey.deleteMany(), - prisma.variable.deleteMany(), - prisma.integration.deleteMany() - ]) -} diff --git a/apps/api/src/common/collective-authorities.ts b/apps/api/src/common/collective-authorities.ts new file mode 100644 index 00000000..b351dede --- /dev/null +++ b/apps/api/src/common/collective-authorities.ts @@ -0,0 +1,95 @@ +import { + Authority, + PrismaClient, + Project, + User, + Workspace +} from '@prisma/client' + +/** + * Given the userId and workspaceId, this function returns the set of authorities + * that are formed by accumulating a set of all the authorities across all the + * roles that the user has in the workspace. + * @param workspaceId The id of the workspace + * @param userId The id of the user + * @param prisma The prisma client + */ +export const getCollectiveWorkspaceAuthorities = async ( + workspaceId: Workspace['id'], + userId: User['id'], + prisma: PrismaClient +): Promise> => { + const authorities = new Set() + + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { + where: { + workspaceMember: { + userId, + workspaceId + } + }, + include: { + role: true + } + } + ) + roleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + return authorities +} + +/** + * Given the userId and project, this function returns the set of authorities + * that are formed by accumulating a set of all the authorities across all the + * roles that the user has in the workspace, adding an extra layer of filtering + * by the project. + * @param userId The id of the user + * @param project The project + * @param prisma The prisma client + * @returns + */ +export const getCollectiveProjectAuthorities = async ( + userId: User['id'], + project: Project, + prisma: PrismaClient +): Promise> => { + const authorities = new Set() + + const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( + { + where: { + workspaceMember: { + userId, + workspaceId: project.workspaceId + }, + role: { + projects: { + some: { + projectId: project.id + } + } + } + }, + select: { + role: { + select: { + authorities: true + } + } + } + } + ) + + roleAssociations.forEach((roleAssociation) => { + roleAssociation.role.authorities.forEach((authority) => { + authorities.add(authority) + }) + }) + + return authorities +} diff --git a/apps/api/src/common/create-key-pair.ts b/apps/api/src/common/create-key-pair.ts deleted file mode 100644 index 05665c08..00000000 --- a/apps/api/src/common/create-key-pair.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as eccrypto from 'eccrypto' - -export const createKeyPair = (): { - publicKey: string - privateKey: string -} => { - const privateKey = eccrypto.generatePrivate() - const publicKey = eccrypto.getPublic(privateKey) - - return { - publicKey: publicKey.toString('hex'), - privateKey: privateKey.toString('hex') - } -} diff --git a/apps/api/src/common/create-user.ts b/apps/api/src/common/create-user.ts deleted file mode 100644 index 7a84516a..00000000 --- a/apps/api/src/common/create-user.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AuthProvider, User, Workspace } from '@prisma/client' -import { PrismaService } from '@/prisma/prisma.service' -import { CreateUserDto } from '@/user/dto/create.user/create.user' -import createWorkspace from './create-workspace' -import { Logger } from '@nestjs/common' - -const createUser = async ( - dto: Partial & { authProvider: AuthProvider }, - prisma: PrismaService -): Promise => { - const logger = new Logger('createUser') - - // Create the user - const user = await prisma.user.create({ - data: { - email: dto.email, - name: dto.name, - profilePictureUrl: dto.profilePictureUrl, - isActive: dto.isActive ?? true, - isAdmin: dto.isAdmin ?? false, - isOnboardingFinished: dto.isOnboardingFinished ?? false, - authProvider: dto.authProvider - } - }) - - if (user.isAdmin) { - logger.log(`Created admin user ${user.id}`) - return user - } - - // Create the user's default workspace - const workspace = await createWorkspace( - user, - { name: 'My Workspace' }, - prisma, - true - ) - - logger.log(`Created user ${user.id} with default workspace ${workspace.id}`) - - return { - ...user, - defaultWorkspace: workspace - } -} - -export default createUser diff --git a/apps/api/src/common/cryptography.spec.ts b/apps/api/src/common/cryptography.spec.ts new file mode 100644 index 00000000..7e2875fd --- /dev/null +++ b/apps/api/src/common/cryptography.spec.ts @@ -0,0 +1,59 @@ +import { + createKeyPair, + decrypt, + encrypt, + generateApiKey, + toSHA256 +} from './cryptography' + +describe('Cryptography Tests', () => { + it('should be defined', () => { + expect(createKeyPair).toBeDefined() + expect(decrypt).toBeDefined() + expect(encrypt).toBeDefined() + expect(generateApiKey).toBeDefined() + expect(toSHA256).toBeDefined() + }) + + it('should create a key pair', () => { + const keyPair = createKeyPair() + expect(keyPair).toBeDefined() + expect(keyPair.publicKey).toBeDefined() + expect(keyPair.privateKey).toBeDefined() + }) + + it('should encrypt and decrypt a string', async () => { + const keyPair = createKeyPair() + const plaintext = 'hello world' + const encrypted = await encrypt(keyPair.publicKey, plaintext) + const decrypted = await decrypt(keyPair.privateKey, encrypted) + expect(decrypted).toEqual(plaintext) + }) + + it('should fail to encrypt and decrypt a string', async () => { + const keyPair = createKeyPair() + const differentKeyPair = createKeyPair() + const plainText = 'hello world' + const encrypted = await encrypt(keyPair.publicKey, plainText) + try { + await decrypt(differentKeyPair.privateKey, encrypted) + } catch (e) { + expect(e).toBeDefined() + expect(e.message).toEqual('Bad MAC') + } + }) + + it('should generate an API key', () => { + const apiKey = generateApiKey() + expect(apiKey).toBeDefined() + expect(apiKey).toBeDefined() + }) + + it('should generate a SHA256 hash', () => { + const hash = toSHA256('hello world') + expect(hash).toBeDefined() + expect(hash).toEqual( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' + ) + }) +}) diff --git a/apps/api/src/common/cryptography.ts b/apps/api/src/common/cryptography.ts new file mode 100644 index 00000000..25b4ad4d --- /dev/null +++ b/apps/api/src/common/cryptography.ts @@ -0,0 +1,83 @@ +import { createHash, randomBytes } from 'crypto' +import * as eccrypto from 'eccrypto' + +/** + * Encrypts the given data using the given public key. + * + * @param publicKey The public key to use for encryption. Must be a hexadecimal string. + * @param data The data to encrypt. + * + * @returns The encrypted data as a JSON string. + */ +export const encrypt = async ( + publicKey: string, + data: string +): Promise => { + const encrypted = await eccrypto.encrypt( + Buffer.from(publicKey, 'hex'), + Buffer.from(data) + ) + + return JSON.stringify(encrypted) +} + +/** + * Decrypts the given data using the given private key. + * + * @param privateKey The private key to use for decryption. Must be a hexadecimal string. + * @param data The data to decrypt. + * + * @returns The decrypted data as a string. + */ +export const decrypt = async ( + privateKey: string, + data: string +): Promise => { + const parsed = JSON.parse(data) + + const eicesData = { + iv: Buffer.from(parsed.iv), + ephemPublicKey: Buffer.from(parsed.ephemPublicKey), + ciphertext: Buffer.from(parsed.ciphertext), + mac: Buffer.from(parsed.mac) + } + + const decrypted = await eccrypto.decrypt( + Buffer.from(privateKey, 'hex'), + eicesData + ) + + return decrypted.toString() +} + +/** + * Generates a new key pair. + * + * @returns An object containing the public key and the private key, both as hexadecimal strings. + */ +export const createKeyPair = (): { + publicKey: string + privateKey: string +} => { + const privateKey = eccrypto.generatePrivate() + const publicKey = eccrypto.getPublic(privateKey) + + return { + publicKey: publicKey.toString('hex'), + privateKey: privateKey.toString('hex') + } +} + +/** + * Generates a new API key. + * + * @returns A new API key as a string in the format 'ks_<24 bytes of random data>'. + */ +export const generateApiKey = (): string => + 'ks_' + randomBytes(24).toString('hex') + +/** + * Returns the SHA256 hash of the given string as a hexadecimal string. + */ +export const toSHA256 = (value: string): string => + createHash('sha256').update(value).digest().toString('hex') diff --git a/apps/api/src/common/decrypt.ts b/apps/api/src/common/decrypt.ts deleted file mode 100644 index 29a6417f..00000000 --- a/apps/api/src/common/decrypt.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as eccrypto from 'eccrypto' - -export const decrypt = async ( - privateKey: string, - data: string -): Promise => { - const parsed = JSON.parse(data) - - const eicesData = { - iv: Buffer.from(parsed.iv), - ephemPublicKey: Buffer.from(parsed.ephemPublicKey), - ciphertext: Buffer.from(parsed.ciphertext), - mac: Buffer.from(parsed.mac) - } - - const decrypted = await eccrypto.decrypt( - Buffer.from(privateKey, 'hex'), - eicesData - ) - - return decrypted.toString() -} diff --git a/apps/api/src/common/encrypt.ts b/apps/api/src/common/encrypt.ts deleted file mode 100644 index 3a043c62..00000000 --- a/apps/api/src/common/encrypt.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as eccrypto from 'eccrypto' - -export const encrypt = async ( - publicKey: string, - data: string -): Promise => { - const encrypted = await eccrypto.encrypt( - Buffer.from(publicKey, 'hex'), - Buffer.from(data) - ) - - return JSON.stringify(encrypted) -} diff --git a/apps/api/src/common/environment.ts b/apps/api/src/common/environment.ts index b6364392..27bdaba2 100644 --- a/apps/api/src/common/environment.ts +++ b/apps/api/src/common/environment.ts @@ -1,3 +1,63 @@ -export const Environment = { - DATABASE_URL: process.env.DATABASE_URL! +import { PrismaService } from '@/prisma/prisma.service' +import { CreateSecret } from '@/secret/dto/create.secret/create.secret' +import { UpdateSecret } from '@/secret/dto/update.secret/update.secret' +import { CreateVariable } from '@/variable/dto/create.variable/create.variable' +import { UpdateVariable } from '@/variable/dto/update.variable/update.variable' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Authority, Project, User } from '@prisma/client' +import { AuthorityCheckerService } from './authority-checker.service' + +/** + * Given a list of environment slugs in a CreateSecret, UpdateSecret, CreateVariable, or UpdateVariable DTO, + * this function checks if the user has access to all the environments and if the environments belong to the given project. + * If all the checks pass, it returns a Map of environment slug to environment ID. + * + * @param dto The DTO containing the list of environment slugs + * @param user The user making the request + * @param project The project that the environments must belong to + * @param prisma The PrismaService instance + * @param authorityCheckerService The AuthorityCheckerService instance + * + * @throws NotFoundException if any of the environments do not exist + * @throws BadRequestException if any of the environments do not belong to the given project + * + * @returns A Map of environment slug to environment ID + */ +export const getEnvironmentIdToSlugMap = async ( + dto: CreateSecret | UpdateSecret | CreateVariable | UpdateVariable, + user: User, + project: Project, + prisma: PrismaService, + authorityCheckerService: AuthorityCheckerService +): Promise> => { + const environmentSlugToIdMap = new Map() + + // Check if the user has access to the environments + const environmentSlugs = dto.entries.map((entry) => entry.environmentSlug) + await Promise.all( + environmentSlugs.map(async (environmentSlug) => { + const environment = + await authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: prisma + }) + + if (!environment) { + throw new NotFoundException(`Environment: ${environmentSlug} not found`) + } + + // Check if the environment belongs to the project + if (environment.projectId !== project.id) { + throw new BadRequestException( + `Environment: ${environmentSlug} does not belong to project: ${project.slug}` + ) + } + + environmentSlugToIdMap.set(environmentSlug, environment.id) + }) + ) + + return environmentSlugToIdMap } diff --git a/apps/api/src/common/create-event.ts b/apps/api/src/common/event.ts similarity index 76% rename from apps/api/src/common/create-event.ts rename to apps/api/src/common/event.ts index 4475a1ee..6445326f 100644 --- a/apps/api/src/common/create-event.ts +++ b/apps/api/src/common/event.ts @@ -16,10 +16,17 @@ import { } from '@prisma/client' import { JsonObject } from '@prisma/client/runtime/library' import IntegrationFactory from '@/integration/plugins/factory/integration.factory' +import { EventService } from '@/event/service/event.service' -const logger = new Logger('CreateEvent') - -export default async function createEvent( +/** + * Creates a new event and saves it to the database. + * + * @param data The data for the event. + * @param prisma The Prisma client. + * + * @throws {InternalServerErrorException} If the user is not provided for non-system events. + */ +export const createEvent = async ( data: { triggerer?: EventTriggerer severity?: EventSeverity @@ -40,7 +47,9 @@ export default async function createEvent( metadata: JsonObject }, prisma: PrismaClient -) { +): Promise => { + const logger = new Logger('CreateEvent') + if (data.triggerer !== EventTriggerer.SYSTEM && !data.triggeredBy) { throw new InternalServerErrorException( 'User must be provided for non-system events' @@ -152,3 +161,32 @@ export default async function createEvent( logger.log(`Event with id ${event.id} created`) } + +/** + * Fetches events from the event service. It calls the getEvents method on the + * event service with the provided parameters. + * + * @param eventService The event service to call the getEvents method on. + * @param user The user to fetch events for. + * @param workspaceSlug The id of the workspace to fetch events for. + * @param source The source of the events to fetch. If undefined, all sources are fetched. + * @param severity The severity of the events to fetch. If undefined, all severities are fetched. + * @returns A promise that resolves to the events fetched from the event service. + */ +export const fetchEvents = async ( + eventService: EventService, + user: User, + workspaceSlug: string, + source?: EventSource, + severity?: EventSeverity +): Promise => { + return await eventService.getEvents( + user, + workspaceSlug, + 0, + 10, + '', + severity, + source + ) +} diff --git a/apps/api/src/common/exclude-fields.ts b/apps/api/src/common/exclude-fields.ts deleted file mode 100644 index b88afe01..00000000 --- a/apps/api/src/common/exclude-fields.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Removes fields from an object - * @param key The object to remove fields from - * @param fields The fields to remove - * @returns The object without the removed fields - */ -export const excludeFields = ( - key: T, - ...fields: K[] -): Partial => - Object.fromEntries( - Object.entries(key).filter(([k]) => !fields.includes(k as K)) - ) as Partial diff --git a/apps/api/src/common/fetch-events.ts b/apps/api/src/common/fetch-events.ts deleted file mode 100644 index edbbdcb2..00000000 --- a/apps/api/src/common/fetch-events.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { EventSeverity, EventSource, User } from '@prisma/client' -import { EventService } from 'src/event/service/event.service' - -export default async function fetchEvents( - eventService: EventService, - user: User, - workspaceId: string, - source?: EventSource, - severity?: EventSeverity -): Promise { - return await eventService.getEvents( - user, - workspaceId, - 0, - 10, - '', - severity, - source - ) -} diff --git a/apps/api/src/common/generate-otp.ts b/apps/api/src/common/generate-otp.ts deleted file mode 100644 index ba5f7cd4..00000000 --- a/apps/api/src/common/generate-otp.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Otp, PrismaClient, User } from '@prisma/client' - -const OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes - -export default async function generateOtp( - email: User['email'], - userId: User['id'], - prisma: PrismaClient -): Promise { - const otp = await prisma.otp.upsert({ - where: { - userId: userId - }, - update: { - code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) - .toString() - .substring(0, 6), - expiresAt: new Date(new Date().getTime() + OTP_EXPIRY) - }, - create: { - code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) - .toString() - .substring(0, 6), - expiresAt: new Date(new Date().getTime() + OTP_EXPIRY), - user: { - connect: { - email - } - } - } - }) - - return otp -} diff --git a/apps/api/src/common/get-collective-project-authorities.ts b/apps/api/src/common/get-collective-project-authorities.ts deleted file mode 100644 index 19787753..00000000 --- a/apps/api/src/common/get-collective-project-authorities.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Authority, PrismaClient, Project, User } from '@prisma/client' - -/** - * Given the userId and project, this function returns the set of authorities - * that are formed by accumulating a set of all the authorities across all the - * roles that the user has in the workspace, adding an extra layer of filtering - * by the project. - * @param userId The id of the user - * @param project The project - * @param prisma The prisma client - * @returns - */ -export default async function getCollectiveProjectAuthorities( - userId: User['id'], - project: Project, - prisma: PrismaClient -): Promise> { - const authorities = new Set() - - const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( - { - where: { - workspaceMember: { - userId, - workspaceId: project.workspaceId - }, - role: { - projects: { - some: { - projectId: project.id - } - } - } - }, - select: { - role: { - select: { - authorities: true - } - } - } - } - ) - - roleAssociations.forEach((roleAssociation) => { - roleAssociation.role.authorities.forEach((authority) => { - authorities.add(authority) - }) - }) - - return authorities -} diff --git a/apps/api/src/common/get-collective-workspace-authorities.ts b/apps/api/src/common/get-collective-workspace-authorities.ts deleted file mode 100644 index 50ee908e..00000000 --- a/apps/api/src/common/get-collective-workspace-authorities.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Authority, PrismaClient, User, Workspace } from '@prisma/client' - -/** - * Given the userId and workspaceId, this function returns the set of authorities - * that are formed by accumulating a set of all the authorities across all the - * roles that the user has in the workspace. - * @param workspaceId The id of the workspace - * @param userId The id of the user - * @param prisma The prisma client - */ -const getCollectiveWorkspaceAuthorities = async ( - workspaceId: Workspace['id'], - userId: User['id'], - prisma: PrismaClient -): Promise> => { - const authorities = new Set() - - const roleAssociations = await prisma.workspaceMemberRoleAssociation.findMany( - { - where: { - workspaceMember: { - userId, - workspaceId - } - }, - include: { - role: true - } - } - ) - roleAssociations.forEach((roleAssociation) => { - roleAssociation.role.authorities.forEach((authority) => { - authorities.add(authority) - }) - }) - - return authorities -} - -export default getCollectiveWorkspaceAuthorities diff --git a/apps/api/src/common/limit-max-items-per-page.ts b/apps/api/src/common/limit-max-items-per-page.ts deleted file mode 100644 index 296d73bc..00000000 --- a/apps/api/src/common/limit-max-items-per-page.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function limitMaxItemsPerPage( - limit: number, - maxlimit: number = 30 -): number { - return Math.min(limit, maxlimit) -} diff --git a/apps/api/src/common/paginate.ts b/apps/api/src/common/paginate.ts index b25f43c3..29a6a67e 100644 --- a/apps/api/src/common/paginate.ts +++ b/apps/api/src/common/paginate.ts @@ -27,6 +27,14 @@ const getQueryString = (query: QueryOptions) => { .join('&') } +/** + * Paginates a list of items. + * @param totalCount The total number of items + * @param relativeUrl The relative URL of the API endpoint + * @param query The query options + * @param defaultQuery The default query options + * @returns The paginated metadata + */ export const paginate = ( totalCount: number, relativeUrl: string, @@ -57,7 +65,7 @@ export const paginate = ( if (query.page >= metadata.pageCount) return {} as PaginatedMetadata - //create links from relativeUrl , defalutQueryStr and query of type QueryOptions + //create links from relativeUrl , defaultQueryStr and query of type QueryOptions metadata.links = { self: `${relativeUrl}?${defaultQueryStr + getQueryString(query)}`, first: `${relativeUrl}?${defaultQueryStr + getQueryString({ ...query, page: 0 })}`, diff --git a/apps/api/src/common/query.transform.pipe.spec.ts b/apps/api/src/common/pipes/query.transform.pipe.spec.ts similarity index 100% rename from apps/api/src/common/query.transform.pipe.spec.ts rename to apps/api/src/common/pipes/query.transform.pipe.spec.ts diff --git a/apps/api/src/common/query.transform.pipe.ts b/apps/api/src/common/pipes/query.transform.pipe.ts similarity index 100% rename from apps/api/src/common/query.transform.pipe.ts rename to apps/api/src/common/pipes/query.transform.pipe.ts diff --git a/apps/api/src/common/set-cookie.ts b/apps/api/src/common/set-cookie.ts deleted file mode 100644 index 74ca5df2..00000000 --- a/apps/api/src/common/set-cookie.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { User } from '@prisma/client' -import { Response } from 'express' -import { UserAuthenticatedResponse } from '@/auth/auth.types' - -/* istanbul ignore next */ -export default function setCookie( - response: Response, - data: UserAuthenticatedResponse -): User { - const { token, ...user } = data - response.cookie('token', `Bearer ${token}`, { - domain: process.env.DOMAIN ?? 'localhost', - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days, - }) - return user -} diff --git a/apps/api/src/common/slug-generator.ts b/apps/api/src/common/slug-generator.ts new file mode 100644 index 00000000..2402c485 --- /dev/null +++ b/apps/api/src/common/slug-generator.ts @@ -0,0 +1,150 @@ +import { PrismaService } from '@/prisma/prisma.service' +import { Workspace } from '@prisma/client' + +/** + * Generates a unique slug for the given name. It keeps generating slugs until it finds + * one that does not exist in the database. + * + * @param name The name of the entity. + * @returns A unique slug for the given entity. + */ +const generateSlug = (name: string): string => { + // Convert to lowercase + const lowerCaseName = name.trim().toLowerCase() + + // Replace spaces with hyphens + const hyphenatedName = lowerCaseName.replace(/\s+/g, '-') + + // Replace all non-alphanumeric characters with hyphens + const alphanumericName = hyphenatedName.replace(/[^a-zA-Z0-9-]/g, '-') + + // Append the name with 5 alphanumeric characters + const slug = + alphanumericName + '-' + Math.random().toString(36).substring(2, 7) + return slug +} + +const checkWorkspaceRoleSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.workspaceRole.count({ where: { slug } })) > 0 +} + +const checkWorkspaceSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.workspace.count({ where: { slug } })) > 0 +} + +const checkProjectSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.project.count({ where: { slug } })) > 0 +} + +const checkVariableSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.variable.count({ where: { slug } })) > 0 +} + +const checkSecretSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.secret.count({ where: { slug } })) > 0 +} + +const checkIntegrationSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.integration.count({ where: { slug } })) > 0 +} + +const checkEnvironmentSlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.environment.count({ where: { slug } })) > 0 +} + +const checkApiKeySlugExists = async ( + slug: Workspace['slug'], + prisma: PrismaService +): Promise => { + return (await prisma.apiKey.count({ where: { slug } })) > 0 +} + +/** + * Generates a unique slug for the given entity type and name. It keeps + * generating slugs until it finds one that does not exist in the database. + * + * @param name The name of the entity. + * @param entityType The type of the entity. + * @param prisma The Prisma client to use to check the existence of the slug. + * @returns A unique slug for the given entity. + */ +export default async function generateEntitySlug( + name: string, + entityType: + | 'WORKSPACE_ROLE' + | 'WORKSPACE' + | 'PROJECT' + | 'VARIABLE' + | 'SECRET' + | 'INTEGRATION' + | 'ENVIRONMENT' + | 'API_KEY', + prisma: PrismaService +): Promise { + while (true) { + const slug = generateSlug(name) + switch (entityType) { + case 'WORKSPACE_ROLE': + if (await checkWorkspaceRoleSlugExists(slug, prisma)) { + continue + } + return slug + case 'WORKSPACE': + if (await checkWorkspaceSlugExists(slug, prisma)) { + continue + } + return slug + case 'PROJECT': + if (await checkProjectSlugExists(slug, prisma)) { + continue + } + return slug + case 'VARIABLE': + if (await checkVariableSlugExists(slug, prisma)) { + continue + } + return slug + case 'SECRET': + if (await checkSecretSlugExists(slug, prisma)) { + continue + } + return slug + case 'INTEGRATION': + if (await checkIntegrationSlugExists(slug, prisma)) { + continue + } + return slug + case 'ENVIRONMENT': + if (await checkEnvironmentSlugExists(slug, prisma)) { + continue + } + return slug + case 'API_KEY': + if (await checkApiKeySlugExists(slug, prisma)) { + continue + } + return slug + } + } +} diff --git a/apps/api/src/common/static.ts b/apps/api/src/common/static.ts deleted file mode 100644 index e43cd059..00000000 --- a/apps/api/src/common/static.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const invalidAuthenticationResponse = { - description: 'Invalid authentication header or API key' -} diff --git a/apps/api/src/common/to-sha256.ts b/apps/api/src/common/to-sha256.ts deleted file mode 100644 index a3d8c76b..00000000 --- a/apps/api/src/common/to-sha256.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createHash } from 'crypto' - -export const toSHA256 = (value: string): string => - createHash('sha256').update(value).digest().toString('hex') diff --git a/apps/api/src/common/user.ts b/apps/api/src/common/user.ts new file mode 100644 index 00000000..15a6125f --- /dev/null +++ b/apps/api/src/common/user.ts @@ -0,0 +1,76 @@ +import { AuthProvider, User, Workspace } from '@prisma/client' +import { PrismaService } from '@/prisma/prisma.service' +import { CreateUserDto } from '@/user/dto/create.user/create.user' +import { Logger, NotFoundException } from '@nestjs/common' +import { createWorkspace } from './workspace' + +/** + * Creates a new user and optionally creates a default workspace for them. + * @param dto - The user data to create a user with. + * @param prisma - The prisma service to use for database operations. + * @returns The created user and, if the user is not an admin, a default workspace. + */ +export async function createUser( + dto: Partial & { authProvider: AuthProvider }, + prisma: PrismaService +): Promise { + const logger = new Logger('createUser') + + // Create the user + const user = await prisma.user.create({ + data: { + email: dto.email, + name: dto.name, + profilePictureUrl: dto.profilePictureUrl, + isActive: dto.isActive ?? true, + isAdmin: dto.isAdmin ?? false, + isOnboardingFinished: dto.isOnboardingFinished ?? false, + authProvider: dto.authProvider + } + }) + + if (user.isAdmin) { + logger.log(`Created admin user ${user.id}`) + return user + } + + // Create the user's default workspace + const workspace = await createWorkspace( + user, + { name: 'My Workspace' }, + prisma, + true + ) + + logger.log(`Created user ${user.id} with default workspace ${workspace.id}`) + + return { + ...user, + defaultWorkspace: workspace + } +} + +/** + * Finds a user by their email address. + * + * @param email The email address to search for. + * @param prisma The Prisma client instance. + * @returns The user with the given email address, or null if no user is found. + * @throws NotFoundException if no user is found with the given email address. + */ +export async function getUserByEmail( + email: User['email'], + prisma: PrismaService +): Promise { + const user = await prisma.user.findUnique({ + where: { + email + } + }) + + if (!user) { + throw new NotFoundException(`User ${email} not found`) + } + + return user +} diff --git a/apps/api/src/common/util.spec.ts b/apps/api/src/common/util.spec.ts index 42ccba44..3443a869 100644 --- a/apps/api/src/common/util.spec.ts +++ b/apps/api/src/common/util.spec.ts @@ -1,44 +1,6 @@ -import { createKeyPair } from './create-key-pair' -import { decrypt } from './decrypt' -import { encrypt } from './encrypt' -import { excludeFields } from './exclude-fields' - -describe('util', () => { - it('should be defined', () => { - expect(createKeyPair).toBeDefined() - expect(decrypt).toBeDefined() - expect(encrypt).toBeDefined() - expect(excludeFields).toBeDefined() - }) - - it('should create a key pair', () => { - const keyPair = createKeyPair() - expect(keyPair).toBeDefined() - expect(keyPair.publicKey).toBeDefined() - expect(keyPair.privateKey).toBeDefined() - }) - - it('should encrypt and decrypt a string', async () => { - const keyPair = createKeyPair() - const plaintext = 'hello world' - const encrypted = await encrypt(keyPair.publicKey, plaintext) - const decrypted = await decrypt(keyPair.privateKey, encrypted) - expect(decrypted).toEqual(plaintext) - }) - - it('should fail to encrypt and decrypt a string', async () => { - const keyPair = createKeyPair() - const differenetKeyPair = createKeyPair() - const plainText = 'hello world' - const encrypted = await encrypt(keyPair.publicKey, plainText) - try { - await decrypt(differenetKeyPair.privateKey, encrypted) - } catch (e) { - expect(e).toBeDefined() - expect(e.message).toEqual('Bad MAC') - } - }) +import { excludeFields } from './util' +describe('Util Tests', () => { it('should exclude fields', () => { const object = { id: '1', diff --git a/apps/api/src/common/util.ts b/apps/api/src/common/util.ts new file mode 100644 index 00000000..a92200fb --- /dev/null +++ b/apps/api/src/common/util.ts @@ -0,0 +1,105 @@ +import { UserAuthenticatedResponse } from '@/auth/auth.types' +import { Otp, PrismaClient, User } from '@prisma/client' +import { Response } from 'express' + +/** + * Limits the given limit to a maximum number of items per page. + * This is useful to prevent users from fetching too many items at once. + * @param limit The limit to check. + * @param maxLimit The maximum number of items per page (default is 30). + * @returns The limited number of items per page. + */ +export const limitMaxItemsPerPage = ( + limit: number, + maxLimit: number = 30 +): number => { + return Math.min(limit, maxLimit) +} + +/** + * Sets a cookie on the given response with the given user's authentication token. + * The cookie will expire after 7 days. + * @param response The response to set the cookie on. + * @param data The user authentication data to set the cookie for. + * @returns The user data without the authentication token. + */ +export const setCookie = ( + response: Response, + data: UserAuthenticatedResponse +): User => { + const { token, ...user } = data + response.cookie('token', `Bearer ${token}`, { + domain: process.env.DOMAIN ?? 'localhost', + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days, + }) + return user +} + +const OTP_EXPIRY = 5 * 60 * 1000 // 5 minutes + +/** + * Generates a new one-time password for the given user. + * If the user already has an OTP, this will update the existing one. + * @param email The email of the user to generate the OTP for. + * @param userId The ID of the user to generate the OTP for. + * @param prisma The Prisma client to use for the database operations. + * @returns The generated OTP. + */ +export const generateOtp = async ( + email: User['email'], + userId: User['id'], + prisma: PrismaClient +): Promise => { + const otp = await prisma.otp.upsert({ + where: { + userId: userId + }, + update: { + code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) + .toString() + .substring(0, 6), + expiresAt: new Date(new Date().getTime() + OTP_EXPIRY) + }, + create: { + code: BigInt(`0x${crypto.randomUUID().replace(/-/g, '')}`) + .toString() + .substring(0, 6), + expiresAt: new Date(new Date().getTime() + OTP_EXPIRY), + user: { + connect: { + email + } + } + } + }) + + return otp +} + +/** + * Removes fields from an object + * @param key The object to remove fields from + * @param fields The fields to remove + * @returns The object without the removed fields + */ +export const excludeFields = ( + key: T, + ...fields: K[] +): Partial => + Object.fromEntries( + Object.entries(key).filter(([k]) => !fields.includes(k as K)) + ) as Partial + +/** + * Adds the given number of hours to the current date. + * If the hours is 'never', or not given, the function returns undefined. + * @param hours The number of hours to add to the current date + * @returns The new date with the given number of hours added, or undefined if the hours is 'never' + */ +export const addHoursToDate = (hours?: string): Date | undefined => { + if (!hours || hours === 'never') return undefined + + const date = new Date() + date.setHours(date.getHours() + +hours) + return date +} diff --git a/apps/api/src/common/create-workspace.ts b/apps/api/src/common/workspace.ts similarity index 73% rename from apps/api/src/common/create-workspace.ts rename to apps/api/src/common/workspace.ts index b5cf9a4f..ae298a68 100644 --- a/apps/api/src/common/create-workspace.ts +++ b/apps/api/src/common/workspace.ts @@ -1,22 +1,38 @@ -import { Authority, EventSource, EventType, User } from '@prisma/client' -import createEvent from './create-event' +import { + Authority, + EventSource, + EventType, + User, + Workspace +} from '@prisma/client' import { CreateWorkspace } from '@/workspace/dto/create.workspace/create.workspace' -import { v4 } from 'uuid' import { PrismaService } from '@/prisma/prisma.service' import { Logger } from '@nestjs/common' +import { v4 } from 'uuid' +import generateEntitySlug from './slug-generator' +import { createEvent } from './event' -export default async function createWorkspace( +/** + * Creates a new workspace and adds the user as the owner. + * @param user The user creating the workspace + * @param dto The workspace data + * @param prisma The Prisma client + * @param isDefault Whether the workspace should be the default workspace + * @returns The created workspace + */ +export const createWorkspace = async ( user: User, dto: CreateWorkspace, prisma: PrismaService, isDefault?: boolean -) { +): Promise => { const workspaceId = v4() const logger = new Logger('createWorkspace') const createNewWorkspace = prisma.workspace.create({ data: { id: workspaceId, + slug: await generateEntitySlug(dto.name, 'WORKSPACE', prisma), name: dto.name, description: dto.description, isFreeTier: true, @@ -27,6 +43,7 @@ export default async function createWorkspace( data: [ { name: 'Admin', + slug: await generateEntitySlug('Admin', 'WORKSPACE_ROLE', prisma), authorities: [Authority.WORKSPACE_ADMIN], hasAdminAuthority: true, colorCode: '#FF0000' diff --git a/apps/api/src/environment/controller/environment.controller.ts b/apps/api/src/environment/controller/environment.controller.ts index 895784d2..b5e92f94 100644 --- a/apps/api/src/environment/controller/environment.controller.ts +++ b/apps/api/src/environment/controller/environment.controller.ts @@ -19,44 +19,48 @@ import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authori export class EnvironmentController { constructor(private readonly environmentService: EnvironmentService) {} - @Post(':projectId') + @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_ENVIRONMENT) async createEnvironment( @CurrentUser() user: User, @Body() dto: CreateEnvironment, - @Param('projectId') projectId: string + @Param('projectSlug') projectSlug: string ) { - return await this.environmentService.createEnvironment(user, dto, projectId) + return await this.environmentService.createEnvironment( + user, + dto, + projectSlug + ) } - @Put(':environmentId') + @Put(':environmentSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_ENVIRONMENT) async updateEnvironment( @CurrentUser() user: User, @Body() dto: UpdateEnvironment, - @Param('environmentId') environmentId: string + @Param('environmentSlug') environmentSlug: string ) { return await this.environmentService.updateEnvironment( user, dto, - environmentId + environmentSlug ) } - @Get(':environmentId') + @Get(':environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_ENVIRONMENT) async getEnvironment( @CurrentUser() user: User, - @Param('environmentId') environmentId: string + @Param('environmentSlug') environmentSlug: string ) { - return await this.environmentService.getEnvironment(user, environmentId) + return await this.environmentService.getEnvironment(user, environmentSlug) } - @Get('/all/:projectId') + @Get('/all/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_ENVIRONMENT) async getEnvironmentsOfProject( @CurrentUser() user: User, - @Param('projectId') projectId: string, + @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -65,7 +69,7 @@ export class EnvironmentController { ) { return await this.environmentService.getEnvironmentsOfProject( user, - projectId, + projectSlug, page, limit, sort, @@ -74,12 +78,15 @@ export class EnvironmentController { ) } - @Delete(':environmentId') + @Delete(':environmentSlug') @RequiredApiKeyAuthorities(Authority.DELETE_ENVIRONMENT) async deleteEnvironment( @CurrentUser() user: User, - @Param('environmentId') environmentId: string + @Param('environmentSlug') environmentSlug: string ) { - return await this.environmentService.deleteEnvironment(user, environmentId) + return await this.environmentService.deleteEnvironment( + user, + environmentSlug + ) } } diff --git a/apps/api/src/environment/environment.e2e.spec.ts b/apps/api/src/environment/environment.e2e.spec.ts index 6d1bd87b..be15ee7e 100644 --- a/apps/api/src/environment/environment.e2e.spec.ts +++ b/apps/api/src/environment/environment.e2e.spec.ts @@ -19,7 +19,6 @@ import { User, Workspace } from '@prisma/client' -import fetchEvents from '@/common/fetch-events' import { ProjectModule } from '@/project/project.module' import { ProjectService } from '@/project/service/project.service' import { EventModule } from '@/event/event.module' @@ -27,7 +26,8 @@ import { EventService } from '@/event/service/event.service' import { EnvironmentService } from './service/environment.service' import { UserModule } from '@/user/user.module' import { UserService } from '@/user/service/user.service' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' describe('Environment Controller Tests', () => { let app: NestFastifyApplication @@ -92,7 +92,7 @@ describe('Environment Controller Tests', () => { user1 = createUser1 user2 = createUser2 - project1 = (await projectService.createProject(user1, workspace1.id, { + project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', description: 'Project 1 description', storePrivateKey: true, @@ -138,382 +138,416 @@ describe('Environment Controller Tests', () => { it('should be defined', () => { expect(app).toBeDefined() expect(prisma).toBeDefined() + expect(projectService).toBeDefined() + expect(environmentService).toBeDefined() + expect(userService).toBeDefined() + expect(eventService).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 3', - description: 'Environment 3 description' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(201) - expect(response.json().name).toBe('Environment 3') - expect(response.json().description).toBe('Environment 3 description') + describe('Create Environment Tests', () => { + it('should be able to create an environment under a project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.slug}`, + payload: { + name: 'Environment 3', + description: 'Environment 3 description' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const environmentFromDb = await prisma.environment.findUnique({ - where: { - id: response.json().id - } - }) + expect(response.statusCode).toBe(201) + expect(response.json().name).toBe('Environment 3') + expect(response.json().description).toBe('Environment 3 description') + expect(response.json().slug).toBeDefined() - expect(environmentFromDb).toBeDefined() - }) + const environmentFromDb = await prisma.environment.findUnique({ + where: { + id: response.json().id + } + }) - 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' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(environmentFromDb).toBeDefined() }) - 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 does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/123`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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' - }, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Project 123 not found') }) - expect(response.statusCode).toBe(401) - }) + 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.slug}`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - 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' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(409) - expect(response.json().message).toBe( - `Environment with name Environment 1 already exists in project ${project1.id}` - ) - }) - - it('should have created a ENVIRONMENT_ADDED event', async () => { - // Create an environment - await environmentService.createEnvironment( - user1, - { - name: 'Environment 4' - }, - project1.id - ) - - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.ENVIRONMENT - ) - - const event = response.items[0] - - expect(event.source).toBe(EventSource.ENVIRONMENT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.ENVIRONMENT_ADDED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + it('should not be able to create a duplicate environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/environment/${project1.slug}`, + payload: { + name: 'Environment 1', + description: 'Environment 1 description' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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(409) + expect(response.json().message).toBe( + `Environment with name Environment 1 already exists in project ${project1.slug}` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - id: environment1.id, - name: 'Environment 1 Updated', - description: 'Environment 1 description updated', - projectId: project1.id, - lastUpdatedById: user1.id, - createdAt: expect.any(String), - updatedAt: expect.any(String) + it('should have created a ENVIRONMENT_ADDED event', async () => { + // Create an environment + await environmentService.createEnvironment( + user1, + { + name: 'Environment 4' + }, + project1.slug + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.ENVIRONMENT + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.ENVIRONMENT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.ENVIRONMENT_ADDED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) - - 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') - }) + describe('Update Environment Tests', () => { + it('should be able to update an environment', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.slug}`, + payload: { + name: 'Environment 1 Updated', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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: { + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: environment1.id, name: 'Environment 1 Updated', - description: 'Environment 1 description updated' - }, - headers: { - 'x-e2e-user-email': user2.email - } + slug: expect.any(String), + description: 'Environment 1 description updated', + projectId: project1.id, + lastUpdatedById: user1.id, + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) + + environment1 = response.json() }) - expect(response.statusCode).toBe(401) - }) + it('should update the slug if name is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.slug}`, + payload: { + name: 'Environment 1 Updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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(200) + expect(response.json().slug).toBeDefined() + expect(response.json().slug).not.toBe(environment1.slug) }) - expect(response.statusCode).toBe(409) - expect(response.json().message).toBe( - `Environment with name Environment 2 already exists in project ${project1.id}` - ) - }) + 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 + } + }) - it('should create a ENVIRONMENT_UPDATED event', async () => { - // Update an environment - await environmentService.updateEnvironment( - user1, - { - name: 'Environment 1 Updated' - }, - environment1.id - ) + expect(response.statusCode).toBe(404) + expect(response.json().message).toBe('Environment 123 not found') + }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.ENVIRONMENT - ) + 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.slug}`, + payload: { + name: 'Environment 1 Updated', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - const event = response.items[0] + expect(response.statusCode).toBe(401) + }) - expect(event.source).toBe(EventSource.ENVIRONMENT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.ENVIRONMENT_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + it('should not be able to update an environment to a duplicate name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/environment/${environment1.slug}`, + payload: { + name: 'Environment 2', + description: 'Environment 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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(409) + expect(response.json().message).toBe( + `Environment with name Environment 2 already exists in project ${project1.slug}` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json().name).toBe('Environment 1') - expect(response.json().description).toBe('Environment 1 description') + it('should create a ENVIRONMENT_UPDATED event', async () => { + // Update an environment + await environmentService.updateEnvironment( + user1, + { + name: 'Environment 1 Updated' + }, + environment1.slug + ) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.ENVIRONMENT + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.ENVIRONMENT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.ENVIRONMENT_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) }) - 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 - } + describe('Get Environment Tests', () => { + it('should be able to fetch an environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().name).toBe('Environment 1') + expect(response.json().slug).toBe(environment1.slug) + expect(response.json().description).toBe('Environment 1 description') }) - 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 does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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(404) + expect(response.json().message).toBe('Environment 123 not found') }) - expect(response.statusCode).toBe(401) - }) + 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.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should be able to fetch all environments of a project', async () => { - const response = await app.inject({ - method: 'GET', - url: `/environment/all/${project1.id}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(200) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(2) - expect(metadata.links.self).toBe( - `/environment/all/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toBe( - `/environment/all/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toBe( - `/environment/all/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) }) - 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 - } + describe('Get All Environments Tests', () => { + it('should be able to fetch all environments of a project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/all/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + // Check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(2) + expect(metadata.links.self).toBe( + `/environment/all/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toBe( + `/environment/all/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toBe( + `/environment/all/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) }) - 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 does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/environment/all/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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(404) + expect(response.json().message).toBe('Project 123 not found') }) - expect(response.statusCode).toBe(401) - }) + 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.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should be able to delete an environment', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/environment/${environment2.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(200) }) - it('should have created a ENVIRONMENT_DELETED event', async () => { - // Delete an environment - await environmentService.deleteEnvironment(user1, environment2.id) + describe('Delete Environment Tests', () => { + it('should be able to delete an environment', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/environment/${environment2.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.ENVIRONMENT - ) + expect(response.statusCode).toBe(200) + }) - const event = response.items[0] + it('should have created a ENVIRONMENT_DELETED event', async () => { + // Delete an environment + await environmentService.deleteEnvironment(user1, environment2.slug) + + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.ENVIRONMENT + ) + + const event = response.items[0] + + expect(event.source).toBe(EventSource.ENVIRONMENT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.ENVIRONMENT_DELETED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) - expect(event.source).toBe(EventSource.ENVIRONMENT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.ENVIRONMENT_DELETED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + 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 + } + }) - 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 123 not found') }) - 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.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - 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.statusCode).toBe(401) - }) + it('should not be able to delete the only environment in a project', async () => { + // Delete the other environment + await environmentService.deleteEnvironment(user1, environment2.slug) - it('should not be able to delete the only environment in a project', async () => { - // Delete the other environment - await environmentService.deleteEnvironment(user1, environment2.id) + const response = await app.inject({ + method: 'DELETE', + url: `/environment/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const response = await app.inject({ - method: 'DELETE', - url: `/environment/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(400) + expect(response.json().message).toBe( + 'Cannot delete the last environment in the project' + ) }) - - expect(response.statusCode).toBe(400) - expect(response.json().message).toBe( - 'Cannot delete the last environment in the project' - ) }) }) diff --git a/apps/api/src/environment/service/environment.service.ts b/apps/api/src/environment/service/environment.service.ts index 13d01624..57a0be15 100644 --- a/apps/api/src/environment/service/environment.service.ts +++ b/apps/api/src/environment/service/environment.service.ts @@ -15,10 +15,11 @@ import { import { CreateEnvironment } from '../dto/create.environment/create.environment' import { UpdateEnvironment } from '../dto/update.environment/update.environment' import { PrismaService } from '@/prisma/prisma.service' -import createEvent from '@/common/create-event' import { AuthorityCheckerService } from '@/common/authority-checker.service' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { limitMaxItemsPerPage } from '@/common/util' @Injectable() export class EnvironmentService { @@ -29,27 +30,58 @@ export class EnvironmentService { private readonly authorityCheckerService: AuthorityCheckerService ) {} + /** + * Creates a new environment in the given project. + * + * This endpoint requires the following authorities: + * - `CREATE_ENVIRONMENT` on the project + * - `READ_ENVIRONMENT` on the project + * - `READ_PROJECT` on the project + * + * If the user does not have the required authorities, a `ForbiddenException` is thrown. + * + * If an environment with the same name already exists in the project, a `ConflictException` is thrown. + * + * The created environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. + * + * An event of type `ENVIRONMENT_ADDED` is created, with the following metadata: + * - `environmentId`: The ID of the created environment + * - `name`: The name of the created environment + * - `projectId`: The ID of the project in which the environment was created + * - `projectName`: The name of the project in which the environment was created + * + * @param user The user that is creating the environment + * @param dto The data for the new environment + * @param projectSlug The slug of the project in which to create the environment + * @returns The created environment + */ async createEnvironment( user: User, dto: CreateEnvironment, - projectId: Project['id'] + projectSlug: Project['slug'] ) { // Check if the user has the required role to create an environment const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, - authorities: [Authority.CREATE_ENVIRONMENT], + entity: { slug: projectSlug }, + authorities: [ + Authority.CREATE_ENVIRONMENT, + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT + ], prisma: this.prisma }) + const projectId = project.id // Check if an environment with the same name already exists - await this.environmentExists(dto.name, projectId) + await this.environmentExists(dto.name, project) // Create the environment const environment = await this.prisma.environment.create({ data: { name: dto.name, + slug: await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma), description: dto.description, project: { connect: { @@ -89,21 +121,50 @@ export class EnvironmentService { return environment } + /** + * Updates an environment in the given project. + * + * This endpoint requires the following authorities: + * - `UPDATE_ENVIRONMENT` on the environment + * - `READ_ENVIRONMENT` on the environment + * - `READ_PROJECT` on the project + * + * If the user does not have the required authorities, a `ForbiddenException` is thrown. + * + * If an environment with the same name already exists in the project, a `ConflictException` is thrown. + * + * The updated environment is returned, with the slug generated using the `name` and `ENVIRONMENT` as the entity type. + * + * An event of type `ENVIRONMENT_UPDATED` is created, with the following metadata: + * - `environmentId`: The ID of the updated environment + * - `name`: The name of the updated environment + * - `projectId`: The ID of the project in which the environment was updated + * - `projectName`: The name of the project in which the environment was updated + * + * @param user The user that is updating the environment + * @param dto The data for the updated environment + * @param environmentSlug The slug of the environment to update + * @returns The updated environment + */ async updateEnvironment( user: User, dto: UpdateEnvironment, - environmentId: Environment['id'] + environmentSlug: Environment['slug'] ) { const environment = await this.authorityCheckerService.checkAuthorityOverEnvironment({ userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.UPDATE_ENVIRONMENT], + entity: { slug: environmentSlug }, + authorities: [ + Authority.UPDATE_ENVIRONMENT, + Authority.READ_ENVIRONMENT, + Authority.READ_PROJECT + ], prisma: this.prisma }) // Check if an environment with the same name already exists - dto.name && (await this.environmentExists(dto.name, environment.projectId)) + dto.name && (await this.environmentExists(dto.name, environment.project)) // Update the environment const updatedEnvironment = await this.prisma.environment.update({ @@ -112,6 +173,9 @@ export class EnvironmentService { }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'ENVIRONMENT', this.prisma) + : environment.slug, description: dto.description, lastUpdatedById: user.id } @@ -143,11 +207,24 @@ export class EnvironmentService { return updatedEnvironment } - async getEnvironment(user: User, environmentId: Environment['id']) { + /** + * Gets an environment by its slug. + * + * This endpoint requires the `READ_ENVIRONMENT` authority on the environment. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * The returned environment object does not include the project property. + * + * @param user The user that is requesting the environment + * @param environmentSlug The slug of the environment to get + * @returns The environment + */ + async getEnvironment(user: User, environmentSlug: Environment['slug']) { const environment = await this.authorityCheckerService.checkAuthorityOverEnvironment({ userId: user.id, - entity: { id: environmentId }, + entity: { slug: environmentSlug }, authorities: [Authority.READ_ENVIRONMENT], prisma: this.prisma }) @@ -157,21 +234,53 @@ export class EnvironmentService { return environment } + /** + * Gets a list of all environments in the given project. + * + * This endpoint requires the `READ_ENVIRONMENT` authority on the project. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * The returned list of environments is paginated and sorted according to the provided parameters. + * + * The metadata object contains the following properties: + * - `href`: The URL to the current page + * - `next`: The URL to the next page (if it exists) + * - `prev`: The URL to the previous page (if it exists) + * - `totalPages`: The total number of pages + * - `totalItems`: The total number of items + * - `limit`: The maximum number of items per page + * - `page`: The current page number + * - `sort`: The sort field + * - `order`: The sort order + * - `search`: The search query + * + * @param user The user that is requesting the environments + * @param projectSlug The slug of the project in which to get the environments + * @param page The page number + * @param limit The maximum number of items per page + * @param sort The sort field + * @param order The sort order + * @param search The search query + * @returns An object with a list of environments and metadata + */ async getEnvironmentsOfProject( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], page: number, limit: number, sort: string, order: string, search: string ) { - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: projectId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: projectSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) + const projectId = project.id // Get the environments for the required page const items = await this.prisma.environment.findMany({ @@ -184,6 +293,7 @@ export class EnvironmentService { select: { id: true, name: true, + slug: true, description: true, createdAt: true, updatedAt: true, @@ -211,7 +321,7 @@ export class EnvironmentService { } } }) - const metadata = paginate(totalCount, `/environment/all/${projectId}`, { + const metadata = paginate(totalCount, `/environment/all/${projectSlug}`, { page, limit: limitMaxItemsPerPage(limit), sort, @@ -222,11 +332,28 @@ export class EnvironmentService { return { items, metadata } } - async deleteEnvironment(user: User, environmentId: Environment['id']) { + /** + * Deletes an environment in a project. + * + * This endpoint requires the `DELETE_ENVIRONMENT` authority on the environment. + * + * If the user does not have the required authority, a `ForbiddenException` is thrown. + * + * If this is the only existing environment in the project, a `BadRequestException` is thrown. + * + * An event of type `ENVIRONMENT_DELETED` is created, with the following metadata: + * - `environmentId`: The ID of the deleted environment + * - `name`: The name of the deleted environment + * - `projectId`: The ID of the project in which the environment was deleted + * + * @param user The user that is deleting the environment + * @param environmentSlug The slug of the environment to delete + */ + async deleteEnvironment(user: User, environmentSlug: Environment['slug']) { const environment = await this.authorityCheckerService.checkAuthorityOverEnvironment({ userId: user.id, - entity: { id: environmentId }, + entity: { slug: environmentSlug }, authorities: [Authority.DELETE_ENVIRONMENT], prisma: this.prisma }) @@ -272,10 +399,14 @@ export class EnvironmentService { ) } - private async environmentExists( - name: Environment['name'], - projectId: Project['id'] - ) { + /** + * Checks if an environment with the given name already exists in the given project. + * @throws ConflictException if an environment with the given name already exists + * @private + */ + private async environmentExists(name: Environment['name'], project: Project) { + const { id: projectId, slug } = project + if ( (await this.prisma.environment.findUnique({ where: { @@ -287,7 +418,7 @@ export class EnvironmentService { })) !== null ) { throw new ConflictException( - `Environment with name ${name} already exists in project ${projectId}` + `Environment with name ${name} already exists in project ${slug}` ) } } diff --git a/apps/api/src/event/controller/event.controller.ts b/apps/api/src/event/controller/event.controller.ts index 8074271a..f8607933 100644 --- a/apps/api/src/event/controller/event.controller.ts +++ b/apps/api/src/event/controller/event.controller.ts @@ -8,11 +8,11 @@ import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authori export class EventController { constructor(private readonly eventService: EventService) {} - @Get(':workspaceId') + @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_EVENT) async getEvents( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: string, + @Param('workspaceSlug') workspaceSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('search') search: string = '', @@ -21,7 +21,7 @@ export class EventController { ) { return await this.eventService.getEvents( user, - workspaceId, + workspaceSlug, page, limit, search, diff --git a/apps/api/src/event/event.e2e.spec.ts b/apps/api/src/event/event.e2e.spec.ts index a538038c..2bd7e705 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -3,14 +3,12 @@ import { NestFastifyApplication } from '@nestjs/platform-fastify' import { - Environment, EventSeverity, EventSource, EventTriggerer, EventType, Project, ProjectAccessLevel, - Secret, User, Variable, Workspace @@ -22,7 +20,6 @@ import { AppModule } from '@/app/app.module' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { EventModule } from './event.module' -import cleanUp from '@/common/cleanup' import { WorkspaceService } from '@/workspace/service/workspace.service' import { WorkspaceModule } from '@/workspace/workspace.module' import { EnvironmentService } from '@/environment/service/environment.service' @@ -32,10 +29,10 @@ import { SecretService } from '@/secret/service/secret.service' import { SecretModule } from '@/secret/secret.module' import { ProjectModule } from '@/project/project.module' import { EnvironmentModule } from '@/environment/environment.module' -import createEvent from '@/common/create-event' import { VariableService } from '@/variable/service/variable.service' import { VariableModule } from '@/variable/variable.module' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { createEvent } from '@/common/event' describe('Event Controller Tests', () => { let app: NestFastifyApplication @@ -49,9 +46,6 @@ describe('Event Controller Tests', () => { let variableService: VariableService let user: User - let workspace: Workspace - let project: Project - let environment: Environment beforeAll(async () => { const moduleRef = await Test.createTestingModule({ @@ -85,9 +79,9 @@ describe('Event Controller Tests', () => { await app.init() await app.getHttpAdapter().getInstance().ready() + }) - await cleanUp(prisma) - + beforeEach(async () => { user = await prisma.user.create({ data: { email: 'johndoe@keyshade.xyz', @@ -103,17 +97,16 @@ describe('Event Controller Tests', () => { }) it('should be able to fetch a workspace event', async () => { - const newWorkspace = await workspaceService.createWorkspace(user, { + const workspace = await workspaceService.createWorkspace(user, { name: 'My workspace', description: 'Some description' }) - workspace = newWorkspace - expect(newWorkspace).toBeDefined() + expect(workspace).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${newWorkspace.id}?source=WORKSPACE`, + url: `/event/${workspace.slug}?source=WORKSPACE`, headers: { 'x-e2e-user-email': user.email } @@ -128,41 +121,45 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.WORKSPACE_CREATED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newWorkspace.id) + expect(event.itemId).toBe(workspace.id) expect(event.userId).toBe(user.id) - expect(event.workspaceId).toBe(newWorkspace.id) + expect(event.workspaceId).toBe(workspace.id) //check metadata const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${newWorkspace.id}?source=WORKSPACE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${newWorkspace.id}?source=WORKSPACE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${newWorkspace.id}?source=WORKSPACE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE&page=0&limit=10&search=` ) }) it('should be able to fetch a project event', async () => { - const newProject = (await projectService.createProject(user, workspace.id, { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + + const project = (await projectService.createProject(user, workspace.slug, { name: 'My project', description: 'Some description', environments: [], storePrivateKey: false, accessLevel: ProjectAccessLevel.GLOBAL })) as Project - project = newProject - expect(newProject).toBeDefined() + expect(project).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=PROJECT`, + url: `/event/${workspace.slug}?source=PROJECT`, headers: { 'x-e2e-user-email': user.email } @@ -178,7 +175,7 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.PROJECT_CREATED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newProject.id) + expect(event.itemId).toBe(project.id) expect(event.userId).toBe(user.id) expect(event.workspaceId).toBe(workspace.id) @@ -186,34 +183,46 @@ describe('Event Controller Tests', () => { const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?source=PROJECT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=PROJECT&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?source=PROJECT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=PROJECT&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?source=PROJECT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=PROJECT&page=0&limit=10&search=` ) }) it('should be able to fetch an environment event', async () => { - const newEnvironment = (await environmentService.createEnvironment( + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + + const project = await projectService.createProject(user, workspace.slug, { + name: 'My project', + description: 'Some description', + environments: [], + storePrivateKey: false, + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const environment = await environmentService.createEnvironment( user, { name: 'My environment', description: 'Some description' }, - project.id - )) as Environment - environment = newEnvironment + project.slug + ) - expect(newEnvironment).toBeDefined() + expect(environment).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=ENVIRONMENT`, + url: `/event/${workspace.slug}?source=ENVIRONMENT`, headers: { 'x-e2e-user-email': user.email } @@ -229,7 +238,7 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.ENVIRONMENT_ADDED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newEnvironment.id) + expect(event.itemId).toBe(environment.id) expect(event.userId).toBe(user.id) expect(event.workspaceId).toBe(workspace.id) @@ -237,40 +246,62 @@ describe('Event Controller Tests', () => { const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?source=ENVIRONMENT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=ENVIRONMENT&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?source=ENVIRONMENT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=ENVIRONMENT&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?source=ENVIRONMENT&page=0&limit=10&search=` + `/event/${workspace.slug}?source=ENVIRONMENT&page=0&limit=10&search=` ) }) it('should be able to fetch a secret event', async () => { - const newSecret = (await secretService.createSecret( + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + + const project = await projectService.createProject(user, workspace.slug, { + name: 'My project', + description: 'Some description', + environments: [], + storePrivateKey: false, + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const environment = await environmentService.createEnvironment( + user, + { + name: 'My environment', + description: 'Some description' + }, + project.slug + ) + + const secret = await secretService.createSecret( user, { name: 'My secret', entries: [ { value: 'My value', - environmentId: environment.id + environmentSlug: environment.slug } ], note: 'Some note', rotateAfter: '720' }, - project.id - )) as Secret + project.slug + ) - expect(newSecret).toBeDefined() + expect(secret).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=SECRET`, + url: `/event/${workspace.slug}?source=SECRET`, headers: { 'x-e2e-user-email': user.email } @@ -286,7 +317,7 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.SECRET_ADDED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newSecret.id) + expect(event.itemId).toBe(secret.id) expect(event.userId).toBe(user.id) expect(event.workspaceId).toBe(workspace.id) @@ -294,39 +325,61 @@ describe('Event Controller Tests', () => { const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?source=SECRET&page=0&limit=10&search=` + `/event/${workspace.slug}?source=SECRET&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?source=SECRET&page=0&limit=10&search=` + `/event/${workspace.slug}?source=SECRET&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?source=SECRET&page=0&limit=10&search=` + `/event/${workspace.slug}?source=SECRET&page=0&limit=10&search=` ) }) it('should be able to fetch a variable event', async () => { - const newVariable = (await variableService.createVariable( + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + + const project = await projectService.createProject(user, workspace.slug, { + name: 'My project', + description: 'Some description', + environments: [], + storePrivateKey: false, + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const environment = await environmentService.createEnvironment( + user, + { + name: 'My environment', + description: 'Some description' + }, + project.slug + ) + + const variable = (await variableService.createVariable( user, { name: 'My variable', entries: [ { value: 'My value', - environmentId: environment.id + environmentSlug: environment.slug } ], note: 'Some note' }, - project.id + project.slug )) as Variable - expect(newVariable).toBeDefined() + expect(variable).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=VARIABLE`, + url: `/event/${workspace.slug}?source=VARIABLE`, headers: { 'x-e2e-user-email': user.email } @@ -343,7 +396,7 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.VARIABLE_ADDED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newVariable.id) + expect(event.itemId).toBe(variable.id) expect(event.userId).toBe(user.id) expect(event.workspaceId).toBe(workspace.id) @@ -351,36 +404,49 @@ describe('Event Controller Tests', () => { const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?source=VARIABLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=VARIABLE&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?source=VARIABLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=VARIABLE&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?source=VARIABLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=VARIABLE&page=0&limit=10&search=` ) }) it('should be able to fetch a workspace role event', async () => { - const newWorkspaceRole = await workspaceRoleService.createWorkspaceRole( + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + + const project = await projectService.createProject(user, workspace.slug, { + name: 'My project', + description: 'Some description', + environments: [], + storePrivateKey: false, + accessLevel: ProjectAccessLevel.GLOBAL + }) + + const workspaceRole = await workspaceRoleService.createWorkspaceRole( user, - workspace.id, + workspace.slug, { name: 'My role', description: 'Some description', colorCode: '#000000', authorities: [], - projectIds: [project.id] + projectSlugs: [project.slug] } ) - expect(newWorkspaceRole).toBeDefined() + expect(workspaceRole).toBeDefined() const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=WORKSPACE_ROLE`, + url: `/event/${workspace.slug}?source=WORKSPACE_ROLE`, headers: { 'x-e2e-user-email': user.email } @@ -396,7 +462,7 @@ describe('Event Controller Tests', () => { expect(event.severity).toBe(EventSeverity.INFO) expect(event.type).toBe(EventType.WORKSPACE_ROLE_CREATED) expect(event.timestamp).toBeDefined() - expect(event.itemId).toBe(newWorkspaceRole.id) + expect(event.itemId).toBe(workspaceRole.id) expect(event.userId).toBe(user.id) expect(event.workspaceId).toBe(workspace.id) @@ -404,80 +470,60 @@ describe('Event Controller Tests', () => { const metadata = response.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?source=WORKSPACE_ROLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE_ROLE&page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?source=WORKSPACE_ROLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE_ROLE&page=0&limit=10&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?source=WORKSPACE_ROLE&page=0&limit=10&search=` + `/event/${workspace.slug}?source=WORKSPACE_ROLE&page=0&limit=10&search=` ) }) it('should be able to fetch all events', async () => { - const response = await app.inject({ - method: 'GET', - url: `/event/${workspace.id}`, - headers: { - 'x-e2e-user-email': user.email - } + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' }) - expect(response.statusCode).toBe(200) - expect(response.json().items).toHaveLength(6) - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(6) - expect(metadata.links.self).toEqual( - `/event/${workspace.id}?page=0&limit=10&search=` - ) - expect(metadata.links.first).toEqual( - `/event/${workspace.id}?page=0&limit=10&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/event/${workspace.id}?page=0&limit=10&search=` - ) - }) - - it('should be able to fetch 2nd page of all events', async () => { const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?page=1&limit=3&`, + url: `/event/${workspace.slug}`, headers: { 'x-e2e-user-email': user.email } }) expect(response.statusCode).toBe(200) - expect(response.json().items).toHaveLength(3) + expect(response.json().items).toHaveLength(1) //check metadata const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(6) + expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/event/${workspace.id}?page=1&limit=3&search=` + `/event/${workspace.slug}?page=0&limit=10&search=` ) expect(metadata.links.first).toEqual( - `/event/${workspace.id}?page=0&limit=3&search=` - ) - expect(metadata.links.previous).toEqual( - `/event/${workspace.id}?page=0&limit=3&search=` + `/event/${workspace.slug}?page=0&limit=10&search=` ) + expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/event/${workspace.id}?page=1&limit=3&search=` + `/event/${workspace.slug}?page=0&limit=10&search=` ) }) it('should throw an error with wrong severity value', async () => { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?severity=INVALID`, + url: `/event/${workspace.slug}?severity=INVALID`, headers: { 'x-e2e-user-email': user.email } @@ -487,9 +533,14 @@ describe('Event Controller Tests', () => { }) it('should throw an error with wrong source value', async () => { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + const response = await app.inject({ method: 'GET', - url: `/event/${workspace.id}?source=INVALID`, + url: `/event/${workspace.slug}?source=INVALID`, headers: { 'x-e2e-user-email': user.email } @@ -499,6 +550,11 @@ describe('Event Controller Tests', () => { }) it('should throw an error if user is not provided in event creation for user-triggered event', async () => { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + try { await createEvent( { @@ -519,6 +575,11 @@ describe('Event Controller Tests', () => { }) it('should throw an exception for invalid event source', async () => { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + try { await createEvent( { @@ -539,6 +600,11 @@ describe('Event Controller Tests', () => { }) it('should throw an exception for invalid event type', async () => { + const workspace = await workspaceService.createWorkspace(user, { + name: 'My workspace', + description: 'Some description' + }) + try { await createEvent( { @@ -561,7 +627,14 @@ describe('Event Controller Tests', () => { } }) + afterEach(async () => { + await prisma.$transaction([ + prisma.user.deleteMany(), + prisma.workspace.deleteMany() + ]) + }) + afterAll(async () => { - await cleanUp(prisma) + await app.close() }) }) diff --git a/apps/api/src/event/service/event.service.ts b/apps/api/src/event/service/event.service.ts index 960ee868..b29e8c73 100644 --- a/apps/api/src/event/service/event.service.ts +++ b/apps/api/src/event/service/event.service.ts @@ -1,9 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common' -import { Authority, EventSeverity, EventSource, User } from '@prisma/client' +import { + Authority, + EventSeverity, + EventSource, + User, + Workspace +} from '@prisma/client' import { PrismaService } from '@/prisma/prisma.service' import { AuthorityCheckerService } from '@/common/authority-checker.service' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import { limitMaxItemsPerPage } from '@/common/util' @Injectable() export class EventService { @@ -14,7 +20,7 @@ export class EventService { async getEvents( user: User, - workspaceId: string, + workspaceSlug: Workspace['slug'], page: number, limit: number, search: string, @@ -30,12 +36,14 @@ export class EventService { } // Check for workspace authority - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_EVENT], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_EVENT], + prisma: this.prisma + }) + const workspaceId = workspace.id const query = { where: { @@ -66,7 +74,7 @@ export class EventService { const metadata = paginate( totalCount, - `/event/${workspaceId}`, + `/event/${workspaceSlug}`, { page, limit: limitMaxItemsPerPage(limit), diff --git a/apps/api/src/feedback/feedback.e2e.spec.ts b/apps/api/src/feedback/feedback.e2e.spec.ts index 8913bf75..cb17a9cb 100644 --- a/apps/api/src/feedback/feedback.e2e.spec.ts +++ b/apps/api/src/feedback/feedback.e2e.spec.ts @@ -42,8 +42,8 @@ describe('Feedback Controller (E2E)', () => { beforeEach(async () => { user = await prisma.user.create({ data: { - email: 'johndoe@keyshade.xyz', - name: 'John', + email: 'janice@keyshade.xyz', + name: 'Janice', isActive: true, isAdmin: false, isOnboardingFinished: false diff --git a/apps/api/src/feedback/service/feedback.service.ts b/apps/api/src/feedback/service/feedback.service.ts index d30a2c9d..19ebfac3 100644 --- a/apps/api/src/feedback/service/feedback.service.ts +++ b/apps/api/src/feedback/service/feedback.service.ts @@ -7,6 +7,11 @@ export class FeedbackService { @Inject(MAIL_SERVICE) private readonly mailService: IMailService ) {} + /** + * Registers a feedback to be sent to the admin's email. + * @param feedback The feedback to be sent. + * @throws {BadRequestException} If the feedback is null or empty. + */ async registerFeedback(feedback: string): Promise { if (!feedback || feedback.trim().length === 0) { throw new BadRequestException('Feedback cannot be null or empty') diff --git a/apps/api/src/integration/controller/integration.controller.ts b/apps/api/src/integration/controller/integration.controller.ts index 463c850c..64da4d2e 100644 --- a/apps/api/src/integration/controller/integration.controller.ts +++ b/apps/api/src/integration/controller/integration.controller.ts @@ -19,7 +19,7 @@ import { UpdateIntegration } from '../dto/update.integration/update.integration' export class IntegrationController { constructor(private readonly integrationService: IntegrationService) {} - @Post(':workspaceId') + @Post(':workspaceSlug') @RequiredApiKeyAuthorities( Authority.CREATE_INTEGRATION, Authority.READ_WORKSPACE, @@ -29,16 +29,16 @@ export class IntegrationController { async createIntegration( @CurrentUser() user: User, @Body() dto: CreateIntegration, - @Param('workspaceId') workspaceId: string + @Param('workspaceSlug') workspaceSlug: string ) { return await this.integrationService.createIntegration( user, dto, - workspaceId + workspaceSlug ) } - @Put(':integrationId') + @Put(':integrationSlug') @RequiredApiKeyAuthorities( Authority.UPDATE_INTEGRATION, Authority.READ_PROJECT, @@ -47,30 +47,30 @@ export class IntegrationController { async updateIntegration( @CurrentUser() user: User, @Body() dto: UpdateIntegration, - @Param('integrationId') integrationId: string + @Param('integrationSlug') integrationSlug: string ) { return await this.integrationService.updateIntegration( user, dto, - integrationId + integrationSlug ) } - @Get(':integrationId') + @Get(':integrationSlug') @RequiredApiKeyAuthorities(Authority.READ_INTEGRATION) async getIntegration( @CurrentUser() user: User, - @Param('integrationId') integrationId: string + @Param('integrationSlug') integrationSlug: string ) { - return await this.integrationService.getIntegration(user, integrationId) + return await this.integrationService.getIntegration(user, integrationSlug) } /* istanbul ignore next */ - @Get('all/:workspaceId') + @Get('all/:workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_INTEGRATION) async getAllIntegrations( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: string, + @Param('workspaceSlug') workspaceSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -79,7 +79,7 @@ export class IntegrationController { ) { return await this.integrationService.getAllIntegrationsOfWorkspace( user, - workspaceId, + workspaceSlug, page, limit, sort, @@ -88,12 +88,15 @@ export class IntegrationController { ) } - @Delete(':integrationId') + @Delete(':integrationSlug') @RequiredApiKeyAuthorities(Authority.DELETE_INTEGRATION) async deleteIntegration( @CurrentUser() user: User, - @Param('integrationId') integrationId: string + @Param('integrationSlug') integrationSlug: string ) { - return await this.integrationService.deleteIntegration(user, integrationId) + return await this.integrationService.deleteIntegration( + user, + integrationSlug + ) } } diff --git a/apps/api/src/integration/dto/create.integration/create.integration.ts b/apps/api/src/integration/dto/create.integration/create.integration.ts index e9bc1c6e..88e0f4a4 100644 --- a/apps/api/src/integration/dto/create.integration/create.integration.ts +++ b/apps/api/src/integration/dto/create.integration/create.integration.ts @@ -27,11 +27,11 @@ export class CreateIntegration { @IsString() @IsOptional() - environmentId?: Environment['id'] + environmentSlug?: Environment['slug'] @IsString() @IsOptional() - projectId?: Project['id'] + projectSlug?: Project['slug'] @IsObject() metadata: Record diff --git a/apps/api/src/integration/integration.e2e.spec.ts b/apps/api/src/integration/integration.e2e.spec.ts index 0413d4e2..a09a5d68 100644 --- a/apps/api/src/integration/integration.e2e.spec.ts +++ b/apps/api/src/integration/integration.e2e.spec.ts @@ -26,7 +26,7 @@ import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { EnvironmentModule } from '@/environment/environment.module' import { EnvironmentService } from '@/environment/service/environment.service' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' describe('Integration Controller Tests', () => { let app: NestFastifyApplication @@ -109,15 +109,15 @@ describe('Integration Controller Tests', () => { }, notifyOn: [EventType.WORKSPACE_UPDATED] }, - workspace1.id + workspace1.slug ) - project1 = (await projectService.createProject(user1, workspace1.id, { + project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', description: 'Description 1' })) as Project - project2 = (await projectService.createProject(user2, workspace2.id, { + project2 = (await projectService.createProject(user2, workspace2.slug, { name: 'Project 2', description: 'Description 2' })) as Project @@ -128,7 +128,7 @@ describe('Integration Controller Tests', () => { name: 'Environment 1', description: 'Description 1' }, - project1.id + project1.slug )) as Environment environment2 = (await environmentService.createEnvironment( @@ -137,7 +137,7 @@ describe('Integration Controller Tests', () => { name: 'Environment 2', description: 'Description 2' }, - project2.id + project2.slug )) as Environment }) @@ -162,450 +162,484 @@ describe('Integration Controller Tests', () => { expect(projectService).toBeDefined() }) - it('should not be able to create an integration in the workspace with the same name', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 1', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + describe('Create Integration Tests', () => { + it('should not be able to create an integration in the workspace with the same name', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED] - } - }) - - expect(result.statusCode).toEqual(409) - }) - - it('should not be able to create an integration in a workspace that does not exist', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/999999`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 1', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED] + } + }) + + expect(result.statusCode).toEqual(409) + }) + + it('should not be able to create an integration in a workspace that does not exist', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/999999`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED] - } - }) - - expect(result.statusCode).toEqual(404) - }) - - it('should not be able to create an integration in a workspace in which the user is not a member', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace2.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED] + } + }) + + expect(result.statusCode).toEqual(404) + }) + + it('should not be able to create an integration in a workspace in which the user is not a member', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace2.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED] - } - }) - - expect(result.statusCode).toEqual(401) - }) - - it('should not be able to create an integration for a project the user does not have access to', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user2.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED] + } + }) + + expect(result.statusCode).toEqual(401) + }) + + it('should not be able to create an integration for a project the user does not have access to', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user2.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - projectId: project1.id - } - }) - - expect(result.statusCode).toEqual(401) - }) - - it('should not be able to create an integration in a project that does not exist', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + projectSlug: project1.slug + } + }) + + expect(result.statusCode).toEqual(401) + }) + + it('should not be able to create an integration in a project that does not exist', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - projectId: '999999' - } - }) - - expect(result.statusCode).toEqual(404) - }) - - it('should throw an error if environment id is specified and not project id', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + projectSlug: '999999' + } + }) + + expect(result.statusCode).toEqual(404) + }) + it('should throw an error if environment slug is specified and not project slug', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - environmentId: '123' - } - }) - - expect(result.statusCode).toEqual(400) - expect(result.json().message).toEqual( - 'Environment can only be provided if project is also provided' - ) - }) - - it('should not be able to create an integration for an environment the user does not have access to', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + environmentSlug: '123' + } + }) + + expect(result.statusCode).toEqual(400) + expect(result.json().message).toEqual( + 'Environment can only be provided if project is also provided' + ) + }) + + it('should not be able to create an integration for an environment the user does not have access to', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - environmentId: environment2.id, - projectId: project1.id - } - }) - - expect(result.statusCode).toEqual(401) - }) - - it('should not be able to create an integration in an environment that does not exist', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + environmentSlug: environment2.slug, + projectSlug: project1.slug + } + }) + + expect(result.statusCode).toEqual(401) + }) + + it('should not be able to create an integration in an environment that does not exist', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - environmentId: '999999', - projectId: project1.id - } - }) - - expect(result.statusCode).toEqual(404) - }) - - it('should be able to create an integration without any project or environment id', async () => { - const result = await app.inject({ - method: 'POST', - url: `/integration/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + environmentSlug: '999999', + projectSlug: project1.slug + } + }) + + expect(result.statusCode).toEqual(404) + }) + + it('should be able to create an integration without any project or environment slug', async () => { + const result = await app.inject({ + method: 'POST', + url: `/integration/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED] - } - }) - - expect(result.statusCode).toEqual(201) - expect(result.json().name).toEqual('Integration 2') - expect(result.json().type).toEqual(IntegrationType.DISCORD) - expect(result.json().id).toBeDefined() - - const integration = await prisma.integration.findUnique({ - where: { - id: result.json().id - } - }) - expect(integration).toBeDefined() - expect(integration!.id).toEqual(result.json().id) - }) - - it('should not be able to update an integration if it does not exist', async () => { - const result = await app.inject({ - method: 'PUT', - url: `/integration/999999`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2' - } - }) - - expect(result.statusCode).toEqual(404) - }) - - it('should not be able to update an integration if the user does not have access to it', async () => { - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user2.email - }, - payload: { - name: 'Integration 2' - } + payload: { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED] + } + }) + + expect(result.statusCode).toEqual(201) + expect(result.json().name).toEqual('Integration 2') + expect(result.json().type).toEqual(IntegrationType.DISCORD) + expect(result.json().id).toBeDefined() + + const integration = await prisma.integration.findUnique({ + where: { + id: result.json().id + } + }) + expect(integration).toBeDefined() + expect(integration!.id).toEqual(result.json().id) }) - - expect(result.statusCode).toEqual(401) }) - it('should not be able to update the name to an existing name', async () => { - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 1' - } - }) - - expect(result.statusCode).toEqual(409) - }) - - it('should have access to a a project if projectId is provided while update', async () => { - // Create the project - const project = (await projectService.createProject(user1, workspace1.id, { - name: 'Project 3', - description: 'Description 3' - })) as Project - - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - projectId: project.id - } - }) - - expect(result.statusCode).toEqual(200) + describe('Update Integration Tests', () => { + it('should not be able to update an integration if it does not exist', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/integration/999999`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + name: 'Integration 2' + } + }) - const updatedIntegration = await prisma.integration.findUnique({ - where: { - id: integration1.id - } + expect(result.statusCode).toEqual(404) }) - expect(updatedIntegration).toBeDefined() - expect(updatedIntegration!.projectId).toEqual(project.id) - }) + it('should not be able to update an integration if the user does not have access to it', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + }, + payload: { + name: 'Integration 2' + } + }) - it('should fail to update if projectId is provided but the user does not have access to the project', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - projectId: project2.id - } + expect(result.statusCode).toEqual(401) }) - expect(result.statusCode).toEqual(401) - }) - - it('should fail to update if projectId is provided but the project does not exist', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - projectId: '999999' - } - }) - - expect(result.statusCode).toEqual(404) - }) + it('should not be able to update the name to an existing name', async () => { + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + name: 'Integration 1' + } + }) + + expect(result.statusCode).toEqual(409) + }) + + it('should have access to a a project if projectSlug is provided while update', async () => { + // Create the project + const project = (await projectService.createProject( + user1, + workspace1.slug, + { + name: 'Project 3', + description: 'Description 3' + } + )) as Project + + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + projectSlug: project.slug + } + }) + + expect(result.statusCode).toEqual(200) + + const updatedIntegration = await prisma.integration.findUnique({ + where: { + id: integration1.id + } + }) + + expect(updatedIntegration).toBeDefined() + expect(updatedIntegration!.projectId).toEqual(project.id) + }) + + it('should fail to update if projectId is provided but the user does not have access to the project', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + projectSlug: project2.slug + } + }) - it('should fail to update if the environment id is specified and not the project id', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - environmentId: environment1.id - } + expect(result.statusCode).toEqual(401) }) - expect(result.statusCode).toEqual(400) - expect(result.json().message).toEqual( - 'Environment can only be provided if project is also provided' - ) - }) - - it('should not fail to update if the integration has projectId present and only environmentId is updated', async () => { - // Create the integration - const integration = await integrationService.createIntegration( - user1, - { - name: 'Integration 2', - type: IntegrationType.DISCORD, - metadata: { - webhookUrl: 'DUMMY_URL' + it('should fail to update if projectId is provided but the project does not exist', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email }, - notifyOn: [EventType.WORKSPACE_UPDATED], - projectId: project1.id - }, - workspace1.id - ) + payload: { + projectSlug: '999999' + } + }) - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - environmentId: environment1.id - } + expect(result.statusCode).toEqual(404) }) - expect(result.statusCode).toEqual(200) + it('should fail to update if the environment slug is specified and not the project slug', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + environmentSlug: environment1.slug + } + }) + + expect(result.statusCode).toEqual(400) + expect(result.json().message).toEqual( + 'Environment can only be provided if project is also provided' + ) + }) + + it('should not fail to update if the integration has projectSlug present and only environmentSlug is updated', async () => { + // Create the integration + const integration = await integrationService.createIntegration( + user1, + { + name: 'Integration 2', + type: IntegrationType.DISCORD, + metadata: { + webhookUrl: 'DUMMY_URL' + }, + notifyOn: [EventType.WORKSPACE_UPDATED], + projectSlug: project1.slug + }, + workspace1.slug + ) + + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + environmentSlug: environment1.slug + } + }) + + expect(result.statusCode).toEqual(200) + + const updatedIntegration = await prisma.integration.findUnique({ + where: { + id: integration.id + } + }) + + expect(updatedIntegration).toBeDefined() + expect(updatedIntegration!.environmentId).toEqual(environment1.id) + }) + + it('should fail to update if the user does not have access to the environment', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + projectSlug: project1.slug, + environmentSlug: environment2.slug + } + }) + + expect(result.statusCode).toEqual(401) + }) + + it('should fail to update if the environment does not exist', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + projectSlug: project1.slug, + environmentSlug: '999999' + } + }) + + expect(result.statusCode).toEqual(404) + }) + + it('should be able to update the integration', async () => { + // Update the integration + const result = await app.inject({ + method: 'PUT', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + }, + payload: { + name: 'Integration 2' + } + }) - const updatedIntegration = await prisma.integration.findUnique({ - where: { - id: integration.id - } - }) + expect(result.statusCode).toEqual(200) - expect(updatedIntegration).toBeDefined() - expect(updatedIntegration!.environmentId).toEqual(environment1.id) - }) + const updatedIntegration = await prisma.integration.findUnique({ + where: { + id: integration1.id + } + }) - it('should fail to update if the user does not have access to the environment', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - projectId: project1.id, - environmentId: environment2.id - } + expect(updatedIntegration).toBeDefined() + expect(updatedIntegration.name).toEqual('Integration 2') + expect(updatedIntegration.slug).not.toEqual(integration1.slug) }) - - expect(result.statusCode).toEqual(401) }) - it('should fail to update if the environment does not exist', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - projectId: project1.id, - environmentId: '999999' - } - }) - - expect(result.statusCode).toEqual(404) - }) + describe('Get Integration Tests', () => { + it('should be able to fetch an integration', async () => { + const result = await app.inject({ + method: 'GET', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to update the integration', async () => { - // Update the integration - const result = await app.inject({ - method: 'PUT', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - }, - payload: { - name: 'Integration 2' - } + expect(result.statusCode).toEqual(200) + expect(result.json().id).toEqual(integration1.id) }) - expect(result.statusCode).toEqual(200) + it('should not be able to fetch an integration that does not exist', async () => { + const result = await app.inject({ + method: 'GET', + url: `/integration/999999`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const updatedIntegration = await prisma.integration.findUnique({ - where: { - id: integration1.id - } + expect(result.statusCode).toEqual(404) }) - expect(updatedIntegration).toBeDefined() - expect(updatedIntegration!.name).toEqual('Integration 2') - }) + it('should not be able to fetch an integration if the user does not have access to it', async () => { + const result = await app.inject({ + method: 'GET', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should be able to fetch an integration', async () => { - const result = await app.inject({ - method: 'GET', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(result.statusCode).toEqual(401) }) - - expect(result.statusCode).toEqual(200) - expect(result.json().id).toEqual(integration1.id) }) it('should be able to fetch all integrations on first page', async () => { const result = await app.inject({ method: 'GET', - url: `/integration/all/${workspace1.id}?page=0&limit=10`, + url: `/integration/all/${workspace1.slug}?page=0&limit=10`, headers: { 'x-e2e-user-email': user1.email } @@ -618,71 +652,49 @@ describe('Integration Controller Tests', () => { const metadata = result.json().metadata expect(metadata.totalCount).toEqual(1) expect(metadata.links.self).toEqual( - `/integration/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` + `/integration/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(metadata.links.first).toEqual( - `/integration/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` + `/integration/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(metadata.links.previous).toBeNull() expect(metadata.links.next).toBeNull() expect(metadata.links.last).toEqual( - `/integration/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` + `/integration/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` ) }) - it('should not be able to fetch an integration that does not exist', async () => { - const result = await app.inject({ - method: 'GET', - url: `/integration/999999`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(result.statusCode).toEqual(404) - }) - - it('should not be able to fetch an integration if the user does not have access to it', async () => { - const result = await app.inject({ - method: 'GET', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(result.statusCode).toEqual(401) - }) + describe('Delete Integration Tests', () => { + it('should be able to delete an integration', async () => { + const result = await app.inject({ + method: 'DELETE', + url: `/integration/${integration1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to delete an integration', async () => { - const result = await app.inject({ - method: 'DELETE', - url: `/integration/${integration1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + expect(result.statusCode).toEqual(200) - expect(result.statusCode).toEqual(200) + const deletedIntegration = await prisma.integration.findUnique({ + where: { + id: integration1.id + } + }) - const deletedIntegration = await prisma.integration.findUnique({ - where: { - id: integration1.id - } + expect(deletedIntegration).toBeNull() }) - expect(deletedIntegration).toBeNull() - }) + it('should not be able to delete an integration that does not exist', async () => { + const result = await app.inject({ + method: 'DELETE', + url: `/integration/999999`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to delete an integration that does not exist', async () => { - const result = await app.inject({ - method: 'DELETE', - url: `/integration/999999`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(result.statusCode).toEqual(404) }) - - expect(result.statusCode).toEqual(404) }) }) diff --git a/apps/api/src/integration/integration.types.ts b/apps/api/src/integration/integration.types.ts index b40581b7..428a57c2 100644 --- a/apps/api/src/integration/integration.types.ts +++ b/apps/api/src/integration/integration.types.ts @@ -42,3 +42,7 @@ export interface IntegrationMetadata {} export interface DiscordIntegrationMetadata extends IntegrationMetadata { webhookUrl: string } + +export interface IntegrationWithWorkspace extends Integration { + workspace: Workspace +} diff --git a/apps/api/src/integration/service/integration.service.ts b/apps/api/src/integration/service/integration.service.ts index 150cce8b..6528bc52 100644 --- a/apps/api/src/integration/service/integration.service.ts +++ b/apps/api/src/integration/service/integration.service.ts @@ -7,19 +7,22 @@ import { import { PrismaService } from '@/prisma/prisma.service' import { Authority, + Environment, EventSource, EventType, Integration, + Project, User, Workspace } from '@prisma/client' import { CreateIntegration } from '../dto/create.integration/create.integration' import { UpdateIntegration } from '../dto/update.integration/update.integration' import { AuthorityCheckerService } from '@/common/authority-checker.service' -import createEvent from '@/common/create-event' import IntegrationFactory from '../plugins/factory/integration.factory' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { limitMaxItemsPerPage } from '@/common/util' @Injectable() export class IntegrationService { @@ -30,55 +33,77 @@ export class IntegrationService { private readonly authorityCheckerService: AuthorityCheckerService ) {} + /** + * Creates a new integration in the given workspace. The user needs to have + * `CREATE_INTEGRATION` and `READ_WORKSPACE` authority in the workspace. + * + * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` + * authority in the project specified by `projectSlug`. + * + * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` + * authority in the environment specified by `environmentSlug`. + * + * If the integration is of type `PROJECT` and `environmentSlug` is provided, + * the user needs to have `READ_ENVIRONMENT` authority in the environment specified + * by `environmentSlug`. + * + * The integration is created with the given name, slug, type, metadata and + * notifyOn events. The slug is generated using the `name` and a unique + * identifier. + * + * @param user The user creating the integration + * @param dto The integration data + * @param workspaceSlug The slug of the workspace the integration is being + * created in + * @returns The created integration + */ async createIntegration( user: User, dto: CreateIntegration, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ) { - // Check if integration with the same name already exists - await this.existsByNameAndWorkspaceId(dto.name, workspaceId) - // Check if the user is permitted to create integrations in the workspace - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.CREATE_INTEGRATION], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.CREATE_INTEGRATION, Authority.READ_WORKSPACE], + prisma: this.prisma + }) + const workspaceId = workspace.id - // Check if the user has READ authority over the workspace - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_WORKSPACE], - prisma: this.prisma - }) + // Check if integration with the same name already exists + await this.existsByNameAndWorkspaceId(dto.name, workspace) + + let project: Project | null = null + let environment: Environment | null = null // Check if the user has READ authority over the project - if (dto.projectId) { - await this.authorityCheckerService.checkAuthorityOverProject({ + if (dto.projectSlug) { + project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: dto.projectId }, + entity: { slug: dto.projectSlug }, authorities: [Authority.READ_PROJECT], prisma: this.prisma }) } // Check if only environmentId is provided - if (dto.environmentId && !dto.projectId) { + if (dto.environmentSlug && !dto.projectSlug) { throw new BadRequestException( 'Environment can only be provided if project is also provided' ) } // Check if the user has READ authority over the environment - if (dto.environmentId) { - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: dto.environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + if (dto.environmentSlug) { + environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: dto.environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) } // Create the integration object @@ -94,11 +119,12 @@ export class IntegrationService { const integration = await this.prisma.integration.create({ data: { name: dto.name, + slug: await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma), type: dto.type, metadata: dto.metadata, notifyOn: dto.notifyOn, - environmentId: dto.environmentId, - projectId: dto.projectId, + environmentId: environment?.id, + projectId: project?.id, workspaceId } }) @@ -125,49 +151,76 @@ export class IntegrationService { return integration } + /** + * Updates an integration. The user needs to have `UPDATE_INTEGRATION` authority + * over the integration. + * + * If the integration is of type `PROJECT`, the user needs to have `READ_PROJECT` + * authority in the project specified by `projectSlug`. + * + * If the integration is of type `ENVIRONMENT`, the user needs to have `READ_ENVIRONMENT` + * authority in the environment specified by `environmentSlug`. + * + * If the integration is of type `PROJECT` and `environmentSlug` is provided, + * the user needs to have `READ_ENVIRONMENT` authority in the environment specified + * by `environmentSlug`. + * + * The integration is updated with the given name, slug, metadata and + * notifyOn events. + * + * @param user The user updating the integration + * @param dto The integration data + * @param integrationSlug The slug of the integration to update + * @returns The updated integration + */ async updateIntegration( user: User, dto: UpdateIntegration, - integrationId: Integration['id'] + integrationSlug: Integration['slug'] ) { const integration = await this.authorityCheckerService.checkAuthorityOverIntegration({ userId: user.id, - entity: { id: integrationId }, + entity: { slug: integrationSlug }, authorities: [Authority.UPDATE_INTEGRATION], prisma: this.prisma }) + const integrationId = integration.id // Check if the name of the integration is being changed, and if so, check if the new name is unique if (dto.name) { - await this.existsByNameAndWorkspaceId(dto.name, integration.workspaceId) + await this.existsByNameAndWorkspaceId(dto.name, integration.workspace) } + let project: Project | null = null + let environment: Environment | null = null + // If the project is being changed, check if the user has READ authority over the new project - if (dto.projectId) { - await this.authorityCheckerService.checkAuthorityOverProject({ + if (dto.projectSlug) { + project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: dto.projectId }, + entity: { slug: dto.projectSlug }, authorities: [Authority.READ_PROJECT], prisma: this.prisma }) } // Check if only environmentId is provided, or if the integration has no project associated from prior - if (dto.environmentId && !integration.projectId && !dto.projectId) { + if (dto.environmentSlug && !integration.projectId && !dto.projectSlug) { throw new BadRequestException( 'Environment can only be provided if project is also provided' ) } // If the environment is being changed, check if the user has READ authority over the new environment - if (dto.environmentId) { - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: dto.environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + if (dto.environmentSlug) { + environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: dto.environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) } // Create the integration object @@ -186,10 +239,13 @@ export class IntegrationService { where: { id: integrationId }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'INTEGRATION', this.prisma) + : integration.slug, metadata: dto.metadata, notifyOn: dto.notifyOn, - environmentId: dto.environmentId, - projectId: dto.projectId + environmentId: environment?.id, + projectId: project?.id } }) @@ -215,10 +271,18 @@ export class IntegrationService { return updatedIntegration } - async getIntegration(user: User, integrationId: Integration['id']) { + /** + * Retrieves an integration by its slug. The user needs to have `READ_INTEGRATION` + * authority over the integration. + * + * @param user The user retrieving the integration + * @param integrationSlug The slug of the integration to retrieve + * @returns The integration with the given slug + */ + async getIntegration(user: User, integrationSlug: Integration['slug']) { return this.authorityCheckerService.checkAuthorityOverIntegration({ userId: user.id, - entity: { id: integrationId }, + entity: { slug: integrationSlug }, authorities: [Authority.READ_INTEGRATION], prisma: this.prisma }) @@ -226,9 +290,25 @@ export class IntegrationService { /* istanbul ignore next */ // The e2e tests are not working, but the API calls work as expected + /** + * Retrieves all integrations in a workspace that the user has READ authority over. + * + * The user needs to have `READ_INTEGRATION` authority over the workspace. + * + * The results are paginated and can be sorted by name ascending or descending. + * + * @param user The user retrieving the integrations + * @param workspaceSlug The slug of the workspace to retrieve integrations from + * @param page The page number of the results + * @param limit The number of items per page + * @param sort The property to sort the results by (default: name) + * @param order The order to sort the results by (default: ascending) + * @param search The string to search for in the integration names + * @returns A paginated list of integrations in the workspace + */ async getAllIntegrationsOfWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], page: number, limit: number, sort: string, @@ -236,12 +316,14 @@ export class IntegrationService { search: string ) { // Check if the user has READ authority over the workspace - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_INTEGRATION], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_INTEGRATION], + prisma: this.prisma + }) + const workspaceId = workspace.id // We need to return only those integrations that have the following properties: // - belong to the workspace @@ -305,7 +387,7 @@ export class IntegrationService { } }) - //calculate metadata for pagination + // Calculate metadata for pagination const totalCount = await this.prisma.integration.count({ where: { name: { @@ -324,7 +406,7 @@ export class IntegrationService { ] } }) - const metadata = paginate(totalCount, `/integration/all/${workspaceId}`, { + const metadata = paginate(totalCount, `/integration/all/${workspaceSlug}`, { page, limit: limitMaxItemsPerPage(limit), sort, @@ -335,14 +417,23 @@ export class IntegrationService { return { items: integrations, metadata } } - async deleteIntegration(user: User, integrationId: Integration['id']) { + /** + * Deletes an integration by its slug. The user needs to have `DELETE_INTEGRATION` + * authority over the integration. + * + * @param user The user deleting the integration + * @param integrationSlug The slug of the integration to delete + * @returns Nothing + */ + async deleteIntegration(user: User, integrationSlug: Integration['slug']) { const integration = await this.authorityCheckerService.checkAuthorityOverIntegration({ userId: user.id, - entity: { id: integrationId }, + entity: { slug: integrationSlug }, authorities: [Authority.DELETE_INTEGRATION], prisma: this.prisma }) + const integrationId = integration.id await this.prisma.integration.delete({ where: { id: integrationId } @@ -368,10 +459,19 @@ export class IntegrationService { ) } + /** + * Checks if an integration with the same name already exists in the workspace. + * Throws a ConflictException if the integration already exists. + * + * @param name The name of the integration to check + * @param workspace The workspace to check in + */ private async existsByNameAndWorkspaceId( name: Integration['name'], - workspaceId: Workspace['id'] + workspace: Workspace ) { + const workspaceId = workspace.id + if ( (await this.prisma.integration.findUnique({ where: { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 4f11581a..c959c9c6 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,7 +7,7 @@ import { Logger, ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { AppModule } from './app/app.module' -import { QueryTransformPipe } from './common/query.transform.pipe' +import { QueryTransformPipe } from './common/pipes/query.transform.pipe' import * as Sentry from '@sentry/node' import { ProfilingIntegration } from '@sentry/profiling-node' import { RedisIoAdapter } from './socket/redis.adapter' diff --git a/apps/api/src/prisma/migrations/20240908063241_add_slug/migration.sql b/apps/api/src/prisma/migrations/20240908063241_add_slug/migration.sql new file mode 100644 index 00000000..4a206e80 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240908063241_add_slug/migration.sql @@ -0,0 +1,68 @@ +/* + Warnings: + + - A unique constraint covering the columns `[slug]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Environment` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Integration` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Project` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Secret` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Variable` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `Workspace` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[slug]` on the table `WorkspaceRole` will be added. If there are existing duplicate values, this will fail. + - Added the required column `slug` to the `ApiKey` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Environment` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Integration` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Project` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Secret` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Variable` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `Workspace` table without a default value. This is not possible if the table is not empty. + - Added the required column `slug` to the `WorkspaceRole` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ApiKey" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Integration" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Secret" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Variable" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Workspace" ADD COLUMN "slug" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "WorkspaceRole" ADD COLUMN "slug" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_slug_key" ON "ApiKey"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Environment_slug_key" ON "Environment"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Integration_slug_key" ON "Integration"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Secret_slug_key" ON "Secret"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Variable_slug_key" ON "Variable"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "Workspace_slug_key" ON "Workspace"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "WorkspaceRole_slug_key" ON "WorkspaceRole"("slug"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 998bdc59..db9dc1c7 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -214,6 +214,7 @@ model Subscription { model Integration { id String @id @default(cuid()) name String + slug String @unique metadata Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -236,6 +237,7 @@ model Integration { model Environment { id String @id @default(cuid()) name String + slug String @unique description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -257,6 +259,7 @@ model Environment { model Project { id String @id @default(cuid()) name String + slug String @unique description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -298,6 +301,7 @@ model ProjectWorkspaceRoleAssociation { model WorkspaceRole { id String @id @default(cuid()) name String + slug String @unique description String? colorCode String? hasAdminAuthority Boolean @default(false) @@ -358,13 +362,14 @@ model SecretVersion { } model Secret { - id String @id @default(cuid()) - name String - versions SecretVersion[] // Stores the versions of the secret - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - rotateAt DateTime? - note String? + id String @id @default(cuid()) + name String + slug String @unique + versions SecretVersion[] // Stores the versions of the secret + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + rotateAt DateTime? + note String? lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? @@ -394,12 +399,13 @@ model VariableVersion { } model Variable { - id String @id @default(cuid()) - name String - versions VariableVersion[] // Stores the versions of the variable - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - note String? + id String @id @default(cuid()) + name String + slug String @unique + versions VariableVersion[] // Stores the versions of the variable + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + note String? lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull) lastUpdatedById String? @@ -413,6 +419,7 @@ model Variable { model ApiKey { id String @id @default(cuid()) name String + slug String @unique value String @unique expiresAt DateTime? createdAt DateTime @default(now()) @@ -441,6 +448,7 @@ model Otp { model Workspace { id String @id @default(cuid()) name String + slug String @unique description String? isFreeTier Boolean @default(true) createdAt DateTime @default(now()) diff --git a/apps/api/src/project/controller/project.controller.ts b/apps/api/src/project/controller/project.controller.ts index d15d2e2a..337410ab 100644 --- a/apps/api/src/project/controller/project.controller.ts +++ b/apps/api/src/project/controller/project.controller.ts @@ -20,89 +20,89 @@ import { ForkProject } from '../dto/fork.project/fork.project' export class ProjectController { constructor(private readonly service: ProjectService) {} - @Post(':workspaceId') + @Post(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.CREATE_PROJECT) async createProject( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['id'], @Body() dto: CreateProject ) { - return await this.service.createProject(user, workspaceId, dto) + return await this.service.createProject(user, workspaceSlug, dto) } - @Put(':projectId') + @Put(':projectSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT) async updateProject( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'], + @Param('projectSlug') projectSlug: Project['slug'], @Body() dto: UpdateProject ) { - return await this.service.updateProject(user, projectId, dto) + return await this.service.updateProject(user, projectSlug, dto) } - @Delete(':projectId') + @Delete(':projectSlug') @RequiredApiKeyAuthorities(Authority.DELETE_PROJECT) async deleteProject( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'] + @Param('projectSlug') projectSlug: Project['slug'] ) { - return await this.service.deleteProject(user, projectId) + return await this.service.deleteProject(user, projectSlug) } - @Get(':projectId') + @Get(':projectSlug') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getProject( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'] + @Param('projectSlug') projectSlug: Project['slug'] ) { - return await this.service.getProjectById(user, projectId) + return await this.service.getProject(user, projectSlug) } - @Post(':projectId/fork') + @Post(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.CREATE_PROJECT) async forkProject( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'], + @Param('projectSlug') projectSlug: Project['slug'], @Body() forkMetadata: ForkProject ) { - return await this.service.forkProject(user, projectId, forkMetadata) + return await this.service.forkProject(user, projectSlug, forkMetadata) } - @Put(':projectId/fork') + @Put(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.READ_PROJECT, Authority.UPDATE_PROJECT) async syncFork( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'], + @Param('projectSlug') projectSlug: Project['slug'], @Param('hardSync') hardSync: boolean = false ) { - return await this.service.syncFork(user, projectId, hardSync) + return await this.service.syncFork(user, projectSlug, hardSync) } - @Delete(':projectId/fork') + @Delete(':projectSlug/fork') @RequiredApiKeyAuthorities(Authority.UPDATE_PROJECT) async unlinkFork( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'] + @Param('projectSlug') projectSlug: Project['slug'] ) { - return await this.service.unlinkParentOfFork(user, projectId) + return await this.service.unlinkParentOfFork(user, projectSlug) } - @Get(':projectId/forks') + @Get(':projectSlug/forks') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getForks( @CurrentUser() user: User, - @Param('projectId') projectId: Project['id'], + @Param('projectSlug') projectSlug: Project['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10 ) { - return await this.service.getAllProjectForks(user, projectId, page, limit) + return await this.service.getAllProjectForks(user, projectSlug, page, limit) } - @Get('/all/:workspaceId') + @Get('/all/:workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_PROJECT) async getAllProjects( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['id'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -111,7 +111,7 @@ export class ProjectController { ) { return await this.service.getProjectsOfWorkspace( user, - workspaceId, + workspaceSlug, page, limit, sort, diff --git a/apps/api/src/project/dto/fork.project/fork.project.ts b/apps/api/src/project/dto/fork.project/fork.project.ts index f8814a69..9089491f 100644 --- a/apps/api/src/project/dto/fork.project/fork.project.ts +++ b/apps/api/src/project/dto/fork.project/fork.project.ts @@ -4,7 +4,7 @@ import { IsOptional, IsString } from 'class-validator' export class ForkProject { @IsString() @IsOptional() - workspaceId?: Workspace['id'] + workspaceSlug?: Workspace['slug'] @IsString() @IsOptional() diff --git a/apps/api/src/project/project.e2e.spec.ts b/apps/api/src/project/project.e2e.spec.ts index 4bde56ea..a25fb9a3 100644 --- a/apps/api/src/project/project.e2e.spec.ts +++ b/apps/api/src/project/project.e2e.spec.ts @@ -22,7 +22,6 @@ import { Variable, Workspace } from '@prisma/client' -import fetchEvents from '@/common/fetch-events' import { EventService } from '@/event/service/event.service' import { EventModule } from '@/event/event.module' import { ProjectService } from './service/project.service' @@ -38,7 +37,8 @@ import { VariableService } from '@/variable/service/variable.service' import { VariableModule } from '@/variable/variable.module' import { SecretModule } from '@/secret/secret.module' import { EnvironmentModule } from '@/environment/environment.module' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' describe('Project Controller Tests', () => { let app: NestFastifyApplication @@ -119,19 +119,19 @@ describe('Project Controller Tests', () => { user1 = createUser1 user2 = createUser2 - project1 = (await projectService.createProject(user1, workspace1.id, { + project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', description: 'Project 1 description', storePrivateKey: true })) as Project - project2 = (await projectService.createProject(user2, workspace2.id, { + project2 = (await projectService.createProject(user2, workspace2.slug, { name: 'Project 2', description: 'Project 2 description', storePrivateKey: false })) as Project - project3 = (await projectService.createProject(user1, workspace1.id, { + project3 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project for fork', description: 'Project for fork', storePrivateKey: true, @@ -157,388 +157,368 @@ describe('Project Controller Tests', () => { expect(variableService).toBeDefined() }) - it('should allow workspace member to create a project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/project/${workspace1.id}`, - payload: { - name: 'Project 3', - description: 'Project 3 description', - storePrivateKey: true - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + describe('Create Project Tests', () => { + it('should allow workspace member to create a project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/${workspace1.slug}`, + payload: { + name: 'Project 3', + description: 'Project 3 description', + storePrivateKey: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(201) - expect(response.json().id).toBeDefined() - expect(response.json().name).toBe('Project 3') - expect(response.json().description).toBe('Project 3 description') - expect(response.json().storePrivateKey).toBe(true) - expect(response.json().workspaceId).toBe(workspace1.id) - expect(response.json().lastUpdatedById).toBe(user1.id) - expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) - expect(response.json().publicKey).toBeDefined() - expect(response.json().privateKey).toBeDefined() - expect(response.json().createdAt).toBeDefined() - expect(response.json().updatedAt).toBeDefined() - }) + expect(response.statusCode).toBe(201) + expect(response.json().id).toBeDefined() + expect(response.json().name).toBe('Project 3') + expect(response.json().slug).toBeDefined() + expect(response.json().description).toBe('Project 3 description') + expect(response.json().storePrivateKey).toBe(true) + expect(response.json().workspaceId).toBe(workspace1.id) + expect(response.json().lastUpdatedById).toBe(user1.id) + expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) + expect(response.json().publicKey).toBeDefined() + expect(response.json().privateKey).toBeDefined() + expect(response.json().createdAt).toBeDefined() + expect(response.json().updatedAt).toBeDefined() + }) + + it('should have created a default environment', async () => { + const environments = await prisma.environment.findMany({ + where: { + projectId: project1.id + } + }) - it('should have created a default environment', async () => { - const environments = await prisma.environment.findMany({ - where: { - projectId: project1.id - } + expect(environments).toHaveLength(1) }) - expect(environments).toHaveLength(1) - }) - - it('should not allow workspace member to create a project with the same name', async () => { - const response = await app.inject({ - method: 'POST', - url: `/project/${workspace1.id}`, - payload: { - name: 'Project 1', - description: 'Project 1 description', - storePrivateKey: true - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + it('should not allow workspace member to create a project with the same name', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/${workspace1.slug}`, + payload: { + name: 'Project 1', + description: 'Project 1 description', + storePrivateKey: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: `Project with this name **Project 1** already exists` + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: `Project with this name **Project 1** already exists` + }) }) - }) - it('should have created a PROJECT_CREATED event', async () => { - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.PROJECT - ) + it('should have created a PROJECT_CREATED event', async () => { + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.PROJECT + ) - const event = response.items[0] + const event = response.items[0] - expect(event.source).toBe(EventSource.PROJECT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.PROJECT_CREATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + expect(event.source).toBe(EventSource.PROJECT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.PROJECT_CREATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) - it('should have added the project to the admin role of the workspace', async () => { - const adminRole = await prisma.workspaceRole.findUnique({ - where: { - workspaceId_name: { - workspaceId: workspace1.id, - name: 'Admin' - } - }, - select: { - projects: { - select: { - projectId: true + it('should have added the project to the admin role of the workspace', async () => { + const adminRole = await prisma.workspaceRole.findUnique({ + where: { + workspaceId_name: { + workspaceId: workspace1.id, + name: 'Admin' + } + }, + select: { + projects: { + select: { + projectId: true + } } } - } - }) - - expect(adminRole).toBeDefined() - expect(adminRole.projects).toHaveLength(2) - expect(adminRole.projects[0].projectId).toBe(project1.id) - }) + }) - it('should not let non-member create a project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/project/${workspace1.id}`, - payload: { - name: 'Project 2', - description: 'Project 2 description', - storePrivateKey: true - }, - headers: { - 'x-e2e-user-email': user2.email - } + expect(adminRole).toBeDefined() + expect(adminRole.projects).toHaveLength(2) + expect(adminRole.projects[0].projectId).toBe(project1.id) }) - expect(response.statusCode).toBe(401) - }) + it('should not let non-member create a project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/${workspace1.slug}`, + payload: { + name: 'Project 2', + description: 'Project 2 description', + storePrivateKey: true + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be able to add project to a non existing workspace', async () => { - const response = await app.inject({ - method: 'POST', - url: `/project/123`, - payload: { - name: 'Project 3', - description: 'Project 3 description', - storePrivateKey: true - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Workspace with id 123 not found` - }) - }) + it('should not be able to add project to a non existing workspace', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/123`, + payload: { + name: 'Project 3', + description: 'Project 3 description', + storePrivateKey: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to update the name and description of a project', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/${project1.id}`, - payload: { - name: 'Project 1 Updated', - description: 'Project 1 description updated' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Workspace 123 not found` + }) }) - - expect(response.statusCode).toBe(200) - expect(response.json().id).toBe(project1.id) - expect(response.json().name).toBe('Project 1 Updated') - expect(response.json().description).toBe('Project 1 description updated') - expect(response.json().storePrivateKey).toBe(true) - expect(response.json().workspaceId).toBe(workspace1.id) - expect(response.json().lastUpdatedById).toBe(user1.id) - expect(response.json().isDisabled).toBe(false) - expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) - expect(response.json().publicKey).toBe(project1.publicKey) }) - it('should not be able to update the name of a project to an existing name', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/${project1.id}`, - payload: { - name: 'Project 1' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + describe('Update Project Tests', () => { + it('should be able to update the name and description of a project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${project1.slug}`, + payload: { + name: 'Project 1 Updated', + description: 'Project 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: `Project with this name **Project 1** already exists` - }) - }) + expect(response.statusCode).toBe(200) + expect(response.json().id).toBe(project1.id) + expect(response.json().name).toBe('Project 1 Updated') + expect(response.json().slug).not.toBe(project1.slug) + expect(response.json().description).toBe('Project 1 description updated') + expect(response.json().storePrivateKey).toBe(true) + expect(response.json().workspaceId).toBe(workspace1.id) + expect(response.json().lastUpdatedById).toBe(user1.id) + expect(response.json().isDisabled).toBe(false) + expect(response.json().accessLevel).toBe(ProjectAccessLevel.PRIVATE) + expect(response.json().publicKey).toBe(project1.publicKey) + }) + + it('should not be able to update the name of a project to an existing name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${project1.slug}`, + payload: { + name: 'Project 1' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to update a non existing project', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/123`, - payload: { - name: 'Project 1 Updated', - description: 'Project 1 description updated' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: `Project with this name **Project 1** already exists` + }) }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Project with id 123 not found` - }) - }) + it('should not be able to update a non existing project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/123`, + payload: { + name: 'Project 1 Updated', + description: 'Project 1 description updated' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to update a project if the user is not a member of the workspace', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/${project1.id}`, - payload: { - name: 'Project 1 Updated', - description: 'Project 1 description updated' - }, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Project 123 not found` + }) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to update a project if the user is not a member of the workspace', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${project1.slug}`, + payload: { + name: 'Project 1 Updated', + description: 'Project 1 description updated' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should have created a PROJECT_UPDATED event', async () => { - await projectService.updateProject(user1, project1.id, { - name: 'Project 1 Updated', - description: 'Project 1 description' + expect(response.statusCode).toBe(401) }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.PROJECT - ) - - const event = response.items[0] + it('should have created a PROJECT_UPDATED event', async () => { + await projectService.updateProject(user1, project1.slug, { + name: 'Project 1 Updated', + description: 'Project 1 description' + }) - expect(event.source).toBe(EventSource.PROJECT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.PROJECT_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(project1.id) - }) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.PROJECT + ) - it('should be able to fetch a project by its id', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/${project1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + const event = response.items[0] - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - ...project1, - lastUpdatedById: user1.id, - createdAt: expect.any(String), - updatedAt: expect.any(String) + expect(event.source).toBe(EventSource.PROJECT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.PROJECT_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(project1.id) }) }) - it('should not be able to fetch a non existing project', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/123`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Project with id 123 not found` - }) - }) + describe('Get Project Tests', () => { + it('should be able to fetch a project by its slug', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/${project1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch a project if the user is not a member of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/${project1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + ...project1, + lastUpdatedById: user1.id, + createdAt: expect.any(String), + updatedAt: expect.any(String) + }) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to fetch a non existing project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to fetch all projects of a workspace', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/all/${workspace1.id}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Project 123 not found` + }) }) - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toEqual(2) + it('should not be able to fetch a project if the user is not a member of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/${project1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(2) - expect(metadata.links.self).toBe( - `/project/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toBe( - `/project/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toEqual(null) - expect(metadata.links.next).toEqual(null) - expect(metadata.links.last).toBe( - `/project/all/${workspace1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) + expect(response.statusCode).toBe(401) + }) }) - it('should not be able to fetch all projects of a non existing workspace', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/all/123`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + describe('Get All Projects Tests', () => { + it('should be able to fetch all projects of a workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/all/${workspace1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Workspace with id 123 not found` - }) - }) + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toEqual(2) - it('should not be able to fetch all projects of a workspace if the user is not a member of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - url: `/project/all/${workspace1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(2) + expect(metadata.links.self).toBe( + `/project/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toBe( + `/project/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toEqual(null) + expect(metadata.links.next).toEqual(null) + expect(metadata.links.last).toBe( + `/project/all/${workspace1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) }) - expect(response.statusCode).toBe(401) - }) - - // --------------------------------------------------------- + it('should not be able to fetch all projects of a non existing workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/all/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not store the private key if storePrivateKey is false', async () => { - const response = await app.inject({ - method: 'POST', - url: `/project/${workspace1.id}`, - payload: { - name: 'Project 2', - description: 'Project 2 description', - storePrivateKey: false - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Workspace 123 not found` + }) }) - expect(response.statusCode).toBe(201) - - const projectId = response.json().id + it('should not be able to fetch all projects of a workspace if the user is not a member of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/project/all/${workspace1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - project2 = await prisma.project.findUnique({ - where: { - id: projectId - } + expect(response.statusCode).toBe(401) }) - - expect(project2).toBeDefined() - expect(project2.privateKey).toBeNull() }) it('should create environments if provided', async () => { const response = await app.inject({ method: 'POST', - url: `/project/${workspace1.id}`, + url: `/project/${workspace1.slug}`, payload: { name: 'Project 3', description: 'Project 3 description', @@ -576,137 +556,169 @@ describe('Project Controller Tests', () => { expect(environments).toHaveLength(3) }) - it('should generate new key-pair if regenerateKeyPair is true and and the project stores the private key or a private key is specified', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/${project1.id}`, - payload: { - regenerateKeyPair: true - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + describe('Key Tests', () => { + it('should not store the private key if storePrivateKey is false', async () => { + const response = await app.inject({ + method: 'POST', + url: `/project/${workspace1.slug}`, + payload: { + name: 'Project 2', + description: 'Project 2 description', + storePrivateKey: false + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(200) - expect(response.json().publicKey).not.toBeNull() - expect(response.json().privateKey).not.toBeNull() - }) + expect(response.statusCode).toBe(201) - it('should not regenerate key-pair if regenerateKeyPair is true and the project does not store the private key and a private key is not specified', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/project/${project2.id}`, - payload: { - regenerateKeyPair: true - }, - headers: { - 'x-e2e-user-email': user2.email - } + const projectId = response.json().id + + project2 = await prisma.project.findUnique({ + where: { + id: projectId + } + }) + + expect(project2).toBeDefined() + expect(project2.privateKey).toBeNull() }) - expect(response.statusCode).toBe(400) - }) + it('should generate new key-pair if regenerateKeyPair is true and and the project stores the private key or a private key is specified', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${project1.slug}`, + payload: { + regenerateKeyPair: true + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to delete a project', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/project/${project1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) + expect(response.json().publicKey).not.toBeNull() + expect(response.json().privateKey).not.toBeNull() }) - expect(response.statusCode).toBe(200) + it('should not regenerate key-pair if regenerateKeyPair is true and the project does not store the private key and a private key is not specified', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/project/${project2.slug}`, + payload: { + regenerateKeyPair: true + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) + + expect(response.statusCode).toBe(400) + }) }) - it('should have created a PROJECT_DELETED event', async () => { - await projectService.deleteProject(user1, project1.id) + describe('Delete Project Tests', () => { + it('should be able to delete a project', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/project/${project1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.PROJECT - ) + expect(response.statusCode).toBe(200) + }) - const event = response.items[0] + it('should have created a PROJECT_DELETED event', async () => { + await projectService.deleteProject(user1, project1.slug) - expect(event.source).toBe(EventSource.PROJECT) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.PROJECT_DELETED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(project1.id) - }) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.PROJECT + ) - it('should have removed all environments of the project', async () => { - await projectService.deleteProject(user1, project1.id) + const event = response.items[0] - const environments = await prisma.environment.findMany({ - where: { - projectId: project1.id - } + expect(event.source).toBe(EventSource.PROJECT) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.PROJECT_DELETED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(project1.id) }) - expect(environments).toHaveLength(0) - }) - - it('should have removed the project from the admin role of the workspace', async () => { - await projectService.deleteProject(user1, project1.id) + it('should have removed all environments of the project', async () => { + await projectService.deleteProject(user1, project1.slug) - const adminRole = await prisma.workspaceRole.findUnique({ - where: { - workspaceId_name: { - workspaceId: workspace1.id, - name: 'Admin' + const environments = await prisma.environment.findMany({ + where: { + projectId: project1.id } - }, - select: { - projects: true - } + }) + + expect(environments).toHaveLength(0) }) - expect(adminRole).toBeDefined() - expect(adminRole.projects).toHaveLength(1) - }) + it('should have removed the project from the admin role of the workspace', async () => { + await projectService.deleteProject(user1, project1.slug) - it('should not be able to delete a non existing project', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/project/123`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + const adminRole = await prisma.workspaceRole.findUnique({ + where: { + workspaceId_name: { + workspaceId: workspace1.id, + name: 'Admin' + } + }, + select: { + projects: true + } + }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Project with id 123 not found` + expect(adminRole).toBeDefined() + expect(adminRole.projects).toHaveLength(1) }) - }) - it('should not be able to delete a project if the user is not a member of the workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/project/${project2.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + it('should not be able to delete a non existing project', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/project/123`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Project 123 not found` + }) }) - expect(response.statusCode).toBe(401) + it('should not be able to delete a project if the user is not a member of the workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/project/${project2.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(401) + }) }) - describe('Project Controller tests for access levels', () => { + describe('Project Access Level Tests', () => { let globalProject: Project, internalProject: Project beforeEach(async () => { globalProject = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Global Project', description: 'Global Project description', @@ -717,7 +729,7 @@ describe('Project Controller Tests', () => { internalProject = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Internal Project', description: 'Internal Project description', @@ -735,7 +747,7 @@ describe('Project Controller Tests', () => { it('should allow any user to access a global project', async () => { const response = await app.inject({ method: 'GET', - url: `/project/${globalProject.id}`, + url: `/project/${globalProject.slug}`, headers: { 'x-e2e-user-email': user2.email // user2 is not a member of workspace1 } @@ -753,7 +765,7 @@ describe('Project Controller Tests', () => { it('should allow workspace members with READ_PROJECT to access an internal project', async () => { const response = await app.inject({ method: 'GET', - url: `/project/${internalProject.id}`, + url: `/project/${internalProject.slug}`, headers: { 'x-e2e-user-email': user1.email // user1 is a member of workspace1 } @@ -771,7 +783,7 @@ describe('Project Controller Tests', () => { it('should not allow non-members to access an internal project', async () => { const response = await app.inject({ method: 'GET', - url: `/project/${internalProject.id}`, + url: `/project/${internalProject.slug}`, headers: { 'x-e2e-user-email': user2.email // user2 is not a member of workspace1 } @@ -783,7 +795,7 @@ describe('Project Controller Tests', () => { it('should not allow outsiders to update a GLOBAL project', async () => { const response = await app.inject({ method: 'PUT', - url: `/project/${globalProject.id}`, + url: `/project/${globalProject.slug}`, payload: { name: 'Global Project Updated' }, @@ -798,7 +810,7 @@ describe('Project Controller Tests', () => { it('should store private key even if specified not to in a global project', async () => { const project = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Global Project 2', description: 'Global Project description', @@ -826,7 +838,7 @@ describe('Project Controller Tests', () => { // Create a member role for the workspace const role = await workspaceRoleService.createWorkspaceRole( user1, - workspace1.id, + workspace1.slug, { name: 'Member', authorities: [Authority.READ_PROJECT] @@ -834,20 +846,20 @@ describe('Project Controller Tests', () => { ) // Add user to workspace as a member - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ { email: johnny.email, - roleIds: [role.id] + roleSlugs: [role.slug] } ]) // Accept the invitation on behalf of the user - await workspaceService.acceptInvitation(johnny, workspace1.id) + await workspaceService.acceptInvitation(johnny, workspace1.slug) // Update the access level of the project const response = await app.inject({ method: 'PUT', - url: `/project/${internalProject.id}`, + url: `/project/${internalProject.slug}`, payload: { accessLevel: ProjectAccessLevel.INTERNAL }, @@ -863,7 +875,7 @@ describe('Project Controller Tests', () => { // Create a project with access level INTERNAL const project = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Internal Project 2', description: 'Internal Project description', @@ -875,7 +887,7 @@ describe('Project Controller Tests', () => { // Update the access level of the project to GLOBAL const updatedProject = (await projectService.updateProject( user1, - project.id, + project.slug, { accessLevel: ProjectAccessLevel.GLOBAL } @@ -890,7 +902,7 @@ describe('Project Controller Tests', () => { it('should throw an error while setting access level to GLOBAL if private key is not specified and project does not store private key', async () => { const project = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Internal Project 2', description: 'Internal Project description', @@ -901,7 +913,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'PUT', - url: `/project/${project.id}`, + url: `/project/${project.slug}`, payload: { accessLevel: ProjectAccessLevel.GLOBAL }, @@ -921,7 +933,7 @@ describe('Project Controller Tests', () => { it('should regenerate key-pair if access level of GLOBAL project is updated to INTERNAL or PRIVATE', async () => { const project = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Global Project 2', description: 'Global Project description', @@ -932,7 +944,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'PUT', - url: `/project/${project.id}`, + url: `/project/${project.slug}`, payload: { accessLevel: ProjectAccessLevel.INTERNAL }, @@ -951,7 +963,7 @@ describe('Project Controller Tests', () => { it('should allow users with sufficient access to access a private project', async () => { const privateProject = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Private Project', description: 'Private Project description', @@ -962,7 +974,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'GET', - url: `/project/${privateProject.id}`, + url: `/project/${privateProject.slug}`, headers: { 'x-e2e-user-email': user1.email } @@ -980,7 +992,7 @@ describe('Project Controller Tests', () => { it('should not allow users without sufficient access to access a private project', async () => { const privateProject = (await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Private Project', description: 'Private Project description', @@ -991,7 +1003,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'GET', - url: `/project/${privateProject.id}`, + url: `/project/${privateProject.slug}`, headers: { 'x-e2e-user-email': user2.email // user2 is not a member of workspace1 } @@ -1000,11 +1012,11 @@ describe('Project Controller Tests', () => { expect(response.statusCode).toBe(401) }) - describe('Project Controller tests for forking', () => { + describe('Project Fork Tests', () => { it('should be able to fork a project', async () => { const forkedProject = (await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1053,14 +1065,14 @@ describe('Project Controller Tests', () => { expect(response.json()).toEqual({ statusCode: 404, error: 'Not Found', - message: `Project with id 123 not found` + message: `Project 123 not found` }) }) it('should not be able to fork a project that is not GLOBAL', async () => { const response = await app.inject({ method: 'POST', - url: `/project/${project2.id}/fork`, + url: `/project/${project2.slug}/fork`, payload: { name: 'Forked Project' }, @@ -1072,10 +1084,10 @@ describe('Project Controller Tests', () => { expect(response.statusCode).toBe(401) }) - it('should fork the project in the default workspace if workspace id is not specified', async () => { + it('should fork the project in the default workspace if workspace slug is not specified', async () => { const forkedProject = (await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1084,17 +1096,17 @@ describe('Project Controller Tests', () => { expect(forkedProject.workspaceId).toBe(workspace2.id) }) - it('should fork the project in the specific workspace if the ID is provided in the payload', async () => { + it('should fork the project in the specific workspace if the slug is provided in the payload', async () => { const newWorkspace = (await workspaceService.createWorkspace(user2, { name: 'New Workspace' })) as Workspace const forkedProject = (await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project', - workspaceId: newWorkspace.id + workspaceSlug: newWorkspace.slug } )) as Project @@ -1102,7 +1114,7 @@ describe('Project Controller Tests', () => { }) it('should not be able to create a fork with the same name in a workspace', async () => { - await projectService.createProject(user2, workspace2.id, { + await projectService.createProject(user2, workspace2.slug, { name: 'Forked Project', description: 'Forked Project description', storePrivateKey: true, @@ -1111,7 +1123,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'POST', - url: `/project/${project3.id}/fork`, + url: `/project/${project3.slug}/fork`, payload: { name: 'Forked Project' }, @@ -1135,7 +1147,7 @@ describe('Project Controller Tests', () => { { name: 'Dev' }, - project3.id + project3.slug )) as Environment // Add two secrets @@ -1146,11 +1158,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'some_key', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug )) as Secret const secret2 = (await secretService.createSecret( @@ -1160,11 +1172,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'password', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug )) as Secret // Add two variables @@ -1175,11 +1187,11 @@ describe('Project Controller Tests', () => { entries: [ { value: '8080', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug )) as Variable const variable2 = (await variableService.createVariable( @@ -1189,17 +1201,17 @@ describe('Project Controller Tests', () => { entries: [ { value: '3600', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug )) as Variable // Try forking the project const forkedProject = await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1262,7 +1274,7 @@ describe('Project Controller Tests', () => { { name: 'Dev' }, - project3.id + project3.slug )) as Environment // Add two secrets @@ -1273,11 +1285,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'some_key', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) await secretService.createSecret( @@ -1287,11 +1299,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'password', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) // Add two variables @@ -1302,11 +1314,11 @@ describe('Project Controller Tests', () => { entries: [ { value: '8080', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) await variableService.createVariable( @@ -1316,17 +1328,17 @@ describe('Project Controller Tests', () => { entries: [ { value: '3600', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) // Try forking the project const forkedProject = await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1339,7 +1351,7 @@ describe('Project Controller Tests', () => { { name: 'Prod' }, - project3.id + project3.slug )) as Environment // Add a new secret to the original project @@ -1350,11 +1362,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_secret', - environmentId: newEnvironmentOriginal.id + environmentSlug: newEnvironmentOriginal.slug } ] }, - project3.id + project3.slug ) // Add a new variable to the original project @@ -1365,11 +1377,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_variable', - environmentId: newEnvironmentOriginal.id + environmentSlug: newEnvironmentOriginal.slug } ] }, - project3.id + project3.slug ) // Add a new environment to the forked project @@ -1378,7 +1390,7 @@ describe('Project Controller Tests', () => { { name: 'Stage' }, - forkedProject.id + forkedProject.slug )) as Environment // Add a new secret to the forked project @@ -1389,11 +1401,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_secret', - environmentId: newEnvironmentForked.id + environmentSlug: newEnvironmentForked.slug } ] }, - forkedProject.id + forkedProject.slug ) // Add a new variable to the forked project @@ -1404,17 +1416,17 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_variable', - environmentId: newEnvironmentForked.id + environmentSlug: newEnvironmentForked.slug } ] }, - forkedProject.id + forkedProject.slug ) // Sync the fork await app.inject({ method: 'PUT', - url: `/project/${forkedProject.id}/fork`, + url: `/project/${forkedProject.slug}/fork`, headers: { 'x-e2e-user-email': user2.email } @@ -1441,9 +1453,9 @@ describe('Project Controller Tests', () => { } }) - expect(forkedEnvironments).toHaveLength(4) expect(forkedSecrets).toHaveLength(4) expect(forkedVariables).toHaveLength(4) + expect(forkedEnvironments).toHaveLength(4) }) it('should only replace environments, secrets and variables if sync is hard', async () => { @@ -1452,7 +1464,7 @@ describe('Project Controller Tests', () => { { name: 'Dev' }, - project3.id + project3.slug )) as Environment // Add two secrets @@ -1463,11 +1475,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'some_key', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) await secretService.createSecret( @@ -1477,11 +1489,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'password', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) // Add two variables @@ -1492,11 +1504,11 @@ describe('Project Controller Tests', () => { entries: [ { value: '8080', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) await variableService.createVariable( @@ -1506,17 +1518,17 @@ describe('Project Controller Tests', () => { entries: [ { value: '3600', - environmentId: environment.id + environmentSlug: environment.slug } ] }, - project3.id + project3.slug ) // Try forking the project const forkedProject = await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1529,7 +1541,7 @@ describe('Project Controller Tests', () => { { name: 'Prod' }, - project3.id + project3.slug )) as Environment // Add a new secret to the original project @@ -1540,11 +1552,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_secret', - environmentId: newEnvironmentOriginal.id + environmentSlug: newEnvironmentOriginal.slug } ] }, - project3.id + project3.slug ) // Add a new variable to the original project @@ -1555,11 +1567,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_variable', - environmentId: newEnvironmentOriginal.id + environmentSlug: newEnvironmentOriginal.slug } ] }, - project3.id + project3.slug ) // Add a new environment to the forked project @@ -1568,7 +1580,7 @@ describe('Project Controller Tests', () => { { name: 'Prod' }, - forkedProject.id + forkedProject.slug )) as Environment // Add a new secret to the forked project @@ -1579,11 +1591,11 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_secret', - environmentId: newEnvironmentForked.id + environmentSlug: newEnvironmentForked.slug } ] }, - forkedProject.id + forkedProject.slug ) // Add a new variable to the forked project @@ -1594,17 +1606,17 @@ describe('Project Controller Tests', () => { entries: [ { value: 'new_variable', - environmentId: newEnvironmentForked.id + environmentSlug: newEnvironmentForked.slug } ] }, - forkedProject.id + forkedProject.slug ) // Sync the fork await app.inject({ method: 'PUT', - url: `/project/${forkedProject.id}/fork?hardSync=true`, + url: `/project/${forkedProject.slug}/fork?hardSync=true`, headers: { 'x-e2e-user-email': user2.email } @@ -1639,7 +1651,7 @@ describe('Project Controller Tests', () => { it('should not be able to sync a project that is not forked', async () => { const response = await app.inject({ method: 'PUT', - url: `/project/${project3.id}/fork`, + url: `/project/${project3.slug}/fork`, headers: { 'x-e2e-user-email': user1.email } @@ -1649,14 +1661,14 @@ describe('Project Controller Tests', () => { expect(response.json()).toEqual({ statusCode: 400, error: 'Bad Request', - message: `Project with id ${project3.id} is not a forked project` + message: `Project ${project3.slug} is not a forked project` }) }) it('should be able to unlink a forked project', async () => { const forkedProject = await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Forked Project' } @@ -1664,7 +1676,7 @@ describe('Project Controller Tests', () => { const response = await app.inject({ method: 'DELETE', - url: `/project/${forkedProject.id}/fork`, + url: `/project/${forkedProject.slug}/fork`, headers: { 'x-e2e-user-email': user2.email } @@ -1684,13 +1696,13 @@ describe('Project Controller Tests', () => { }) it('should be able to fetch all forked projects of a project', async () => { - await projectService.forkProject(user2, project3.id, { + await projectService.forkProject(user2, project3.slug, { name: 'Forked Project' }) const response = await app.inject({ method: 'GET', - url: `/project/${project3.id}/forks`, + url: `/project/${project3.slug}/forks`, headers: { 'x-e2e-user-email': user2.email } @@ -1701,15 +1713,15 @@ describe('Project Controller Tests', () => { //check metadata const metadata = response.json().metadata expect(metadata.links.self).toBe( - `/project/${project3.id}/forks?page=0&limit=10` + `/project/${project3.slug}/forks?page=0&limit=10` ) expect(metadata.links.first).toBe( - `/project/${project3.id}/forks?page=0&limit=10` + `/project/${project3.slug}/forks?page=0&limit=10` ) expect(metadata.links.previous).toEqual(null) expect(metadata.links.next).toEqual(null) expect(metadata.links.last).toBe( - `/project/${project3.id}/forks?page=0&limit=10` + `/project/${project3.slug}/forks?page=0&limit=10` ) }) @@ -1717,23 +1729,23 @@ describe('Project Controller Tests', () => { // Make a hidden fork const hiddenProject = await projectService.forkProject( user2, - project3.id, + project3.slug, { name: 'Hidden Forked Project' } ) - await projectService.updateProject(user2, hiddenProject.id, { + await projectService.updateProject(user2, hiddenProject.slug, { accessLevel: ProjectAccessLevel.INTERNAL }) // Make a public fork - await projectService.forkProject(user2, project3.id, { + await projectService.forkProject(user2, project3.slug, { name: 'Forked Project' }) const response = await app.inject({ method: 'GET', - url: `/project/${project3.id}/forks`, + url: `/project/${project3.slug}/forks`, headers: { 'x-e2e-user-email': user1.email } diff --git a/apps/api/src/project/service/project.service.ts b/apps/api/src/project/service/project.service.ts index 1e8f227d..421d2832 100644 --- a/apps/api/src/project/service/project.service.ts +++ b/apps/api/src/project/service/project.service.ts @@ -2,9 +2,7 @@ import { BadRequestException, ConflictException, Injectable, - Logger, - NotFoundException, - UnauthorizedException + Logger } from '@nestjs/common' import { Authority, @@ -21,18 +19,16 @@ import { } from '@prisma/client' import { CreateProject } from '../dto/create.project/create.project' import { UpdateProject } from '../dto/update.project/update.project' -import { createKeyPair } from '@/common/create-key-pair' -import { excludeFields } from '@/common/exclude-fields' import { PrismaService } from '@/prisma/prisma.service' -import { decrypt } from '@/common/decrypt' -import { encrypt } from '@/common/encrypt' import { v4 } from 'uuid' -import createEvent from '@/common/create-event' import { ProjectWithSecrets } from '../project.types' import { AuthorityCheckerService } from '@/common/authority-checker.service' import { ForkProject } from '../dto/fork.project/fork.project' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import { createKeyPair, decrypt, encrypt } from '@/common/cryptography' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { excludeFields, limitMaxItemsPerPage } from '@/common/util' @Injectable() export class ProjectService { @@ -43,19 +39,28 @@ export class ProjectService { private readonly authorityCheckerService: AuthorityCheckerService ) {} + /** + * Creates a new project in a workspace + * + * @param user The user who is creating the project + * @param workspaceSlug The slug of the workspace where the project will be created + * @param dto The data for the new project + * @returns The newly created project + */ async createProject( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], dto: CreateProject ) { // Check if the workspace exists or not const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.CREATE_PROJECT], prisma: this.prisma }) + const workspaceId = workspace.id // Check if project with this name already exists for the user if (await this.projectExists(dto.name, workspaceId)) @@ -68,6 +73,7 @@ export class ProjectService { const data: any = { name: dto.name, + slug: await generateEntitySlug(dto.name, 'PROJECT', this.prisma), description: dto.description, storePrivateKey: dto.accessLevel === ProjectAccessLevel.GLOBAL @@ -140,6 +146,11 @@ export class ProjectService { this.prisma.environment.create({ data: { name: environment.name, + slug: await generateEntitySlug( + environment.name, + 'ENVIRONMENT', + this.prisma + ), description: environment.description, projectId: newProjectId, lastUpdatedById: user.id @@ -152,6 +163,11 @@ export class ProjectService { this.prisma.environment.create({ data: { name: 'Default', + slug: await generateEntitySlug( + 'Default', + 'ENVIRONMENT', + this.prisma + ), description: 'Default environment for the project', projectId: newProjectId, lastUpdatedById: user.id @@ -193,9 +209,20 @@ export class ProjectService { return newProject } + /** + * Updates a project. + * + * @param user The user who is updating the project + * @param projectSlug The slug of the project to update + * @param dto The data to update the project with + * @returns The updated project + * + * @throws ConflictException If a project with the same name already exists for the user + * @throws BadRequestException If the private key is required but not supplied + */ async updateProject( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], dto: UpdateProject ) { // Check if the user has the authority to update the project @@ -207,7 +234,7 @@ export class ProjectService { const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [authority], prisma: this.prisma }) @@ -254,6 +281,9 @@ export class ProjectService { const data: Partial = { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'PROJECT', this.prisma) + : project.slug, description: dto.description, storePrivateKey: dto.storePrivateKey, privateKey: dto.storePrivateKey ? dto.privateKey : null, @@ -334,42 +364,42 @@ export class ProjectService { } } + /** + * Forks a project. + * + * @param user The user who is creating the new project + * @param projectSlug The slug of the project to fork + * @param forkMetadata The metadata for the new project + * @returns The newly forked project + * + * @throws ConflictException If a project with the same name already exists for the user + * @throws BadRequestException If the private key is required but not supplied + */ async forkProject( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], forkMetadata: ForkProject ) { - const project = await this.prisma.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - name: true, - description: true, - storePrivateKey: true, - accessLevel: true, - privateKey: true - } - }) - - if (!project) { - throw new NotFoundException(`Project with id ${projectId} not found`) - } - - if (project.accessLevel !== ProjectAccessLevel.GLOBAL) { - throw new UnauthorizedException( - `User with id ${user.id} does not have the authority in the project with id ${project.id}` - ) - } - - let workspaceId = forkMetadata.workspaceId - - if (workspaceId) { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.CREATE_PROJECT], + entity: { slug: projectSlug }, + authorities: [Authority.READ_PROJECT], prisma: this.prisma }) + + let workspaceId = null + + if (forkMetadata.workspaceSlug) { + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: forkMetadata.workspaceSlug }, + authorities: [Authority.CREATE_PROJECT], + prisma: this.prisma + }) + + workspaceId = workspace.id } else { const defaultWorkspace = await this.prisma.workspaceMember.findFirst({ where: { @@ -405,6 +435,7 @@ export class ProjectService { data: { id: newProjectId, name: newProjectName, + slug: await generateEntitySlug(newProjectName, 'PROJECT', this.prisma), description: project.description, storePrivateKey: forkMetadata.storePrivateKey || project.storePrivateKey, @@ -480,13 +511,25 @@ export class ProjectService { return newProject } - async unlinkParentOfFork(user: User, projectId: Project['id']) { - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: projectId }, - authorities: [Authority.UPDATE_PROJECT], - prisma: this.prisma - }) + /** + * Unlinks a forked project from its parent project. + * + * @param user The user who is unlinking the project + * @param projectSlug The slug of the project to unlink + * @returns The updated project + * + * @throws BadRequestException If the project is not a forked project + * @throws UnauthorizedException If the user does not have the authority to update the project + */ + async unlinkParentOfFork(user: User, projectSlug: Project['slug']) { + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: projectSlug }, + authorities: [Authority.UPDATE_PROJECT], + prisma: this.prisma + }) + const projectId = project.id await this.prisma.project.update({ where: { @@ -499,25 +542,45 @@ export class ProjectService { }) } - async syncFork(user: User, projectId: Project['id'], hardSync: boolean) { + /** + * Syncs a forked project with its parent project. + * + * @param user The user who is syncing the project + * @param projectSlug The slug of the project to sync + * @param hardSync Whether to do a hard sync or not. If true, all items in the + * forked project will be replaced with the items from the parent project. If + * false, only items that are not present in the forked project will be added + * from the parent project. + * + * @throws BadRequestException If the project is not a forked project + * @throws UnauthorizedException If the user does not have the authority to update the project + */ + async syncFork(user: User, projectSlug: Project['slug'], hardSync: boolean) { const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.UPDATE_PROJECT], prisma: this.prisma }) + const projectId = project.id if (!project.isForked || project.forkedFromId == null) { throw new BadRequestException( - `Project with id ${projectId} is not a forked project` + `Project ${projectSlug} is not a forked project` ) } + const forkedFromProject = await this.prisma.project.findUnique({ + where: { + id: project.forkedFromId + } + }) + const parentProject = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: project.forkedFromId }, + entity: { slug: forkedFromProject.slug }, authorities: [Authority.READ_PROJECT], prisma: this.prisma }) @@ -538,11 +601,18 @@ export class ProjectService { await this.prisma.$transaction(copyProjectOp) } - async deleteProject(user: User, projectId: Project['id']) { + /** + * Deletes a project. + * @param user The user who is deleting the project + * @param projectSlug The slug of the project to delete + * + * @throws UnauthorizedException If the user does not have the authority to delete the project + */ + async deleteProject(user: User, projectSlug: Project['slug']) { const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.DELETE_PROJECT], prisma: this.prisma }) @@ -592,18 +662,31 @@ export class ProjectService { this.log.debug(`Deleted project ${project}`) } + /** + * Gets all the forks of a project. + * + * @param user The user who is requesting the forks + * @param projectSlug The slug of the project to get forks for + * @param page The page number to get the forks for + * @param limit The number of forks to get per page + * @returns An object with two properties: `items` and `metadata`. + * `items` is an array of project objects that are forks of the given project, + * and `metadata` is the pagination metadata for the forks. + */ async getAllProjectForks( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], page: number, limit: number ) { - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: projectId }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) + const project = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: projectSlug }, + authorities: [Authority.READ_PROJECT], + prisma: this.prisma + }) + const projectId = project.id const forks = await this.prisma.project.findMany({ where: { @@ -615,7 +698,7 @@ export class ProjectService { const allowed = (await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: fork.id }, + entity: { slug: fork.slug }, authorities: [Authority.READ_PROJECT], prisma: this.prisma })) != null @@ -624,10 +707,11 @@ export class ProjectService { }) const items = forksAllowed.slice(page * limit, (page + 1) * limit) - //calculate metadata + + // Calculate metadata const metadata = paginate( forksAllowed.length, - `/project/${projectId}/forks`, + `/project/${projectSlug}/forks`, { page, limit: limitMaxItemsPerPage(limit) @@ -637,11 +721,20 @@ export class ProjectService { return { items, metadata } } - async getProjectById(user: User, projectId: Project['id']) { + /** + * Gets a project by slug. + * + * @param user The user who is requesting the project + * @param projectSlug The slug of the project to get + * @returns The project with secrets removed + * + * @throws UnauthorizedException If the user does not have the authority to read the project + */ + async getProject(user: User, projectSlug: Project['slug']) { const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.READ_PROJECT], prisma: this.prisma }) @@ -651,28 +744,43 @@ export class ProjectService { return project } + /** + * Gets all the projects in a workspace that the user has access to. + * + * @param user The user who is requesting the projects + * @param workspaceSlug The slug of the workspace to get the projects from + * @param page The page number to get the projects for + * @param limit The number of projects to get per page + * @param sort The field to sort the projects by + * @param order The order to sort the projects in + * @param search The search string to filter the projects by + * @returns An object with two properties: `items` and `metadata`. + * `items` is an array of project objects that match the given criteria, + * and `metadata` is an object with pagination metadata. + */ async getProjectsOfWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], page: number, limit: number, sort: string, order: string, search: string ) { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_PROJECT], + prisma: this.prisma + }) + const workspaceId = workspace.id //fetch projects with required properties const items = ( await this.prisma.project.findMany({ skip: page * limit, take: limitMaxItemsPerPage(limit), - orderBy: { [sort]: order }, @@ -727,7 +835,7 @@ export class ProjectService { } }) - const metadata = paginate(totalCount, `/project/all/${workspaceId}`, { + const metadata = paginate(totalCount, `/project/all/${workspaceSlug}`, { page, limit, sort, @@ -738,6 +846,13 @@ export class ProjectService { return { items, metadata } } + /** + * Checks if a project with a given name exists in a workspace. + * + * @param projectName The name of the project to check + * @param workspaceId The ID of the workspace to check in + * @returns true if the project exists, false otherwise + */ private async projectExists( projectName: string, workspaceId: Workspace['id'] @@ -758,6 +873,16 @@ export class ProjectService { ) } + /** + * Copies the project data from one project to another project. + * + * @param user The user who is performing the copy operation + * @param fromProject The project from which the data is being copied + * @param toProject The project to which the data is being copied + * @param hardCopy If true, replace all the data in the toProject with the fromProject, + * otherwise, only add the items in the fromProject that are not already present in the toProject. + * @returns An array of database operations that need to be performed to copy the data. + */ private async copyProjectData( user: User, fromProject: { @@ -845,6 +970,11 @@ export class ProjectService { data: { id: newEnvironmentId, name: environment.name, + slug: await generateEntitySlug( + environment.name, + 'ENVIRONMENT', + this.prisma + ), description: environment.description, projectId: toProject.id, lastUpdatedById: user.id @@ -892,6 +1022,7 @@ export class ProjectService { this.prisma.secret.create({ data: { name: secret.name, + slug: await generateEntitySlug(secret.name, 'SECRET', this.prisma), projectId: toProject.id, lastUpdatedById: user.id, note: secret.note, @@ -944,6 +1075,11 @@ export class ProjectService { this.prisma.variable.create({ data: { name: variable.name, + slug: await generateEntitySlug( + variable.name, + 'VARIABLE', + this.prisma + ), projectId: toProject.id, lastUpdatedById: user.id, note: variable.note, @@ -963,6 +1099,18 @@ export class ProjectService { return [...createEnvironmentOps, ...createSecretOps, ...createVariableOps] } + /** + * Updates the key pair of a project. + * + * @param project The project to update + * @param oldPrivateKey The old private key of the project + * @param storePrivateKey Whether to store the new private key in the database + * + * @returns An object with three properties: + * - `txs`: an array of database operations that need to be performed to update the project + * - `newPrivateKey`: the new private key of the project + * - `newPublicKey`: the new public key of the project + */ private async updateProjectKeyPair( project: ProjectWithSecrets, oldPrivateKey: string, diff --git a/apps/api/src/secret/controller/secret.controller.ts b/apps/api/src/secret/controller/secret.controller.ts index eab0b86e..341c3564 100644 --- a/apps/api/src/secret/controller/secret.controller.ts +++ b/apps/api/src/secret/controller/secret.controller.ts @@ -19,56 +19,56 @@ import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authori export class SecretController { constructor(private readonly secretService: SecretService) {} - @Post(':projectId') + @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_SECRET) async createSecret( @CurrentUser() user: User, - @Param('projectId') projectId: string, + @Param('projectSlug') projectSlug: string, @Body() dto: CreateSecret ) { - return await this.secretService.createSecret(user, dto, projectId) + return await this.secretService.createSecret(user, dto, projectSlug) } - @Put(':secretId') + @Put(':secretSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_SECRET) async updateSecret( @CurrentUser() user: User, - @Param('secretId') secretId: string, + @Param('secretSlug') secretSlug: string, @Body() dto: UpdateSecret ) { - return await this.secretService.updateSecret(user, secretId, dto) + return await this.secretService.updateSecret(user, secretSlug, dto) } - @Put(':secretId/rollback/:rollbackVersion') + @Put(':secretSlug/rollback/:rollbackVersion') @RequiredApiKeyAuthorities(Authority.UPDATE_SECRET) async rollbackSecret( @CurrentUser() user: User, - @Param('secretId') secretId: string, - @Query('environmentId') environmentId: string, + @Param('secretSlug') secretSlug: string, + @Query('environmentSlug') environmentSlug: string, @Param('rollbackVersion') rollbackVersion: number ) { return await this.secretService.rollbackSecret( user, - secretId, - environmentId, + secretSlug, + environmentSlug, rollbackVersion ) } - @Delete(':secretId') + @Delete(':secretSlug') @RequiredApiKeyAuthorities(Authority.DELETE_SECRET) async deleteSecret( @CurrentUser() user: User, - @Param('secretId') secretId: string + @Param('secretSlug') secretSlug: string ) { - return await this.secretService.deleteSecret(user, secretId) + return await this.secretService.deleteSecret(user, secretSlug) } - @Get('/:projectId') + @Get('/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getAllSecretsOfProject( @CurrentUser() user: User, - @Param('projectId') projectId: string, + @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -78,7 +78,7 @@ export class SecretController { ) { return await this.secretService.getAllSecretsOfProject( user, - projectId, + projectSlug, decryptValue, page, limit, @@ -88,34 +88,34 @@ export class SecretController { ) } - @Get('/:projectId/:environmentId') + @Get('/:projectSlug/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getAllSecretsOfEnvironment( @CurrentUser() user: User, - @Param('projectId') projectId: string, - @Param('environmentId') environmentId: string + @Param('projectSlug') projectSlug: string, + @Param('environmentSlug') environmentSlug: string ) { return await this.secretService.getAllSecretsOfProjectAndEnvironment( user, - projectId, - environmentId + projectSlug, + environmentSlug ) } - @Get(':secretId/revisions/:environmentId') + @Get(':secretSlug/revisions/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_SECRET) async getRevisionsOfSecret( @CurrentUser() user: User, - @Param('secretId') secretId: string, - @Param('environmentId') environmentId: string, + @Param('secretSlug') secretSlug: string, + @Param('environmentSlug') environmentSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, - @Query('order') order: string = 'desc' + @Query('order') order: 'asc' | 'desc' = 'desc' ) { return await this.secretService.getRevisionsOfSecret( user, - secretId, - environmentId, + secretSlug, + environmentSlug, page, limit, order diff --git a/apps/api/src/secret/dto/create.secret/create.secret.ts b/apps/api/src/secret/dto/create.secret/create.secret.ts index ba4c1548..c31e67e9 100644 --- a/apps/api/src/secret/dto/create.secret/create.secret.ts +++ b/apps/api/src/secret/dto/create.secret/create.secret.ts @@ -31,7 +31,7 @@ export class CreateSecret { class Entry { @IsString() @Transform(({ value }) => value.trim()) - environmentId: string + environmentSlug: string @IsString() @Transform(({ value }) => value.trim()) diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index b9e71dfc..b0f31988 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -28,7 +28,6 @@ import { SecretModule } from './secret.module' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { EnvironmentService } from '@/environment/service/environment.service' -import fetchEvents from '@/common/fetch-events' import { SecretService } from './service/secret.service' import { EventService } from '@/event/service/event.service' import { REDIS_CLIENT } from '@/provider/redis.provider' @@ -36,7 +35,8 @@ import { RedisClientType } from 'redis' import { mockDeep } from 'jest-mock-extended' import { UserService } from '@/user/service/user.service' import { UserModule } from '@/user/user.module' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' describe('Secret Controller Tests', () => { let app: NestFastifyApplication @@ -109,7 +109,7 @@ describe('Secret Controller Tests', () => { user1 = createUser1 user2 = createUser2 - project1 = (await projectService.createProject(user1, workspace1.id, { + project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', description: 'Project 1 description', storePrivateKey: true, @@ -126,7 +126,7 @@ describe('Secret Controller Tests', () => { ] })) as Project - project2 = (await projectService.createProject(user1, workspace1.id, { + project2 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 2', description: 'Project 2 description', storePrivateKey: false, @@ -154,12 +154,12 @@ describe('Secret Controller Tests', () => { note: 'Secret 1 note', entries: [ { - environmentId: environment1.id, + environmentSlug: environment1.slug, value: 'Secret 1 value' } ] }, - project1.id + project1.slug )) as Secret }) @@ -178,825 +178,836 @@ describe('Secret Controller Tests', () => { expect(environmentService).toBeDefined() }) - it('should be able to create a secret', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.id}`, - payload: { - name: 'Secret 2', - note: 'Secret 2 note', - entries: [ - { - value: 'Secret 2 value', - environmentId: environment1.id - } - ], - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(201) + describe('Create Secret Tests', () => { + it('should be able to create a secret', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 2', + note: 'Secret 2 note', + entries: [ + { + value: 'Secret 2 value', + environmentSlug: environment1.slug + } + ], + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const body = response.json() + expect(response.statusCode).toBe(201) - expect(body).toBeDefined() - expect(body.name).toBe('Secret 2') - expect(body.note).toBe('Secret 2 note') - expect(body.projectId).toBe(project1.id) - expect(body.versions.length).toBe(1) - expect(body.versions[0].value).not.toBe('Secret 2 value') - }) + const body = response.json() - it('should have created a secret version', async () => { - const secretVersion = await prisma.secretVersion.findFirst({ - where: { - secretId: secret1.id - } + expect(body).toBeDefined() + expect(body.name).toBe('Secret 2') + expect(body.note).toBe('Secret 2 note') + expect(body.projectId).toBe(project1.id) + expect(body.versions.length).toBe(1) + expect(body.versions[0].value).not.toBe('Secret 2 value') }) - expect(secretVersion).toBeDefined() - expect(secretVersion.value).not.toBe('Secret 1 value') - expect(secretVersion.version).toBe(1) - expect(secretVersion.environmentId).toBe(environment1.id) - }) + it('should have created a secret version', async () => { + const secretVersion = await prisma.secretVersion.findFirst({ + where: { + secretId: secret1.id + } + }) + + expect(secretVersion).toBeDefined() + expect(secretVersion.value).not.toBe('Secret 1 value') + expect(secretVersion.version).toBe(1) + expect(secretVersion.environmentId).toBe(environment1.id) + }) + + it('should not be able to create a secret with a non-existing environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 3', + rotateAfter: '24', + entries: [ + { + value: 'Secret 3 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to create a secret with a non-existing environment', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.id}`, - payload: { - name: 'Secret 3', - rotateAfter: '24', - entries: [ - { - value: 'Secret 3 value', - environmentId: 'non-existing-environment-id' - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) }) - expect(response.statusCode).toBe(404) - }) + it('should not be able to create a secret if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 3', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be able to create a secret if the user has no access to the project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.id}`, - payload: { - name: 'Secret 3', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to create a duplicate secret in the same project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/secret/${project1.slug}`, + payload: { + name: 'Secret 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to create a duplicate secret in the same project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/secret/${project1.id}`, - payload: { - name: 'Secret 1', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Secret already exists: Secret 1 in project ${project1.slug}` + ) }) - expect(response.statusCode).toBe(409) - expect(response.json().message).toEqual( - `Secret already exists: Secret 1 in project ${project1.id}` - ) - }) + it('should have created a SECRET_ADDED event', async () => { + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) - it('should have created a SECRET_ADDED event', async () => { - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.SECRET - ) - - const event = response.items[0] + const event = response.items[0] - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_ADDED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - - it('should not be able to update a non-existing secret', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/non-existing-secret-id`, - payload: { - name: 'Updated Secret 1', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_ADDED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) - - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - 'Secret with id non-existing-secret-id not found' - ) }) - it('should be able to update the secret name and note without creating a new version', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}`, - payload: { - name: 'Updated Secret 1', - note: 'Updated Secret 1 note' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().secret.name).toEqual('Updated Secret 1') - expect(response.json().secret.note).toEqual('Updated Secret 1 note') - expect(response.json().updatedVersions.length).toBe(0) + describe('Update Secret Tests', () => { + it('should not be able to update a non-existing secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/non-existing-secret-slug`, + payload: { + name: 'Updated Secret 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret non-existing-secret-slug not found' + ) }) - expect(secretVersion.length).toBe(1) - }) - - it('should create a new version if the value is updated', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}`, - payload: { - entries: [ - { - value: 'Updated Secret 1 value', - environmentId: environment1.id - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + it('should be able to update the secret name and note without creating a new version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + name: 'Updated Secret 1', + note: 'Updated Secret 1 note' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(200) - expect(response.json().updatedVersions.length).toBe(1) + expect(response.statusCode).toBe(200) + expect(response.json().secret.name).toEqual('Updated Secret 1') + expect(response.json().secret.note).toEqual('Updated Secret 1 note') + expect(response.json().updatedVersions.length).toBe(0) - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id, - environmentId: environment1.id - } - }) + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) + + expect(secretVersion.length).toBe(1) + }) + + it('should create a new version if the value is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(secretVersion.length).toBe(2) - }) + expect(response.statusCode).toBe(200) + expect(response.json().updatedVersions.length).toBe(1) - it('should fail to create a new version if the environment does not exist', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}`, - payload: { - entries: [ - { - value: 'Updated Secret 1 value', - environmentId: 'non-existing-environment-id' - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(404) - }) + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id, + environmentId: environment1.id + } + }) + + expect(secretVersion.length).toBe(2) + }) + + it('should fail to create a new version if the environment does not exist', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}`, + payload: { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should have created a SECRET_UPDATED event', async () => { - // Update a secret - await secretService.updateSecret(user1, secret1.id, { - name: 'Updated Secret 1' + expect(response.statusCode).toBe(404) }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.SECRET - ) + it('should have created a SECRET_UPDATED event', async () => { + // Update a secret + await secretService.updateSecret(user1, secret1.slug, { + name: 'Updated Secret 1' + }) - const event = response.items[0] + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(secret1.id) - }) + const event = response.items[0] - it('should not be able to roll back a non-existing secret', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/non-existing-secret-id/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) }) - - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - 'Secret with id non-existing-secret-id not found' - ) }) - it('should not be able to roll back a secret it does not have access to', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) + describe('Rollback Tests', () => { + it('should not be able to roll back a non-existing secret', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/non-existing-secret-slug/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to roll back to a non-existing version', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}/rollback/2?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret non-existing-secret-slug not found' + ) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `Invalid rollback version: 2 for secret: ${secret1.id}` - ) - }) - - it('should not be able to roll back if the secret has no versions', async () => { - await prisma.secretVersion.deleteMany({ - where: { - secretId: secret1.id - } - }) + it('should not be able to roll back a secret it does not have access to', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `No versions found for environment: ${environment1.id} for secret: ${secret1.id}` - ) - }) - - it('should not create a secret version entity if value-environmentId is not provided during creation', async () => { - const secret = await secretService.createSecret( - user1, - { - name: 'Secret 4', - note: 'Secret 4 note', - rotateAfter: '24' - }, - project1.id - ) + it('should not be able to roll back to a non-existing version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/2?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const secretVersion = await prisma.secretVersion.findMany({ - where: { - secretId: secret.id - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Invalid rollback version: 2 for secret: ${secret1.slug}` + ) }) - expect(secretVersion.length).toBe(0) - }) - - it('should be able to roll back a secret', async () => { - // Creating a few versions first - await secretService.updateSecret(user1, secret1.id, { - entries: [ - { - value: 'Updated Secret 1 value', - environmentId: environment1.id + it('should not be able to roll back if the secret has no versions', async () => { + await prisma.secretVersion.deleteMany({ + where: { + secretId: secret1.id } - ] - }) + }) - await secretService.updateSecret(user1, secret1.id, { - entries: [ - { - value: 'Updated Secret 1 value 2', - environmentId: environment1.id + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email } - ] - }) - - let versions: SecretVersion[] + }) - // eslint-disable-next-line prefer-const - versions = await prisma.secretVersion.findMany({ - where: { - secretId: secret1.id - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `No versions found for environment: ${environment1.slug} for secret: ${secret1.slug}` + ) }) - expect(versions.length).toBe(3) - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const response = await app.inject({ - method: 'PUT', - url: `/secret/${secret1.id}/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - // expect(response.statusCode).toBe(200) - // expect(response.json().count).toEqual(2) - - // versions = await prisma.secretVersion.findMany({ - // where: { - // secretId: secret1.id - // } - // }) + it('should not create a secret version entity if value-environmentSlug is not provided during creation', async () => { + const secret = await secretService.createSecret( + user1, + { + name: 'Secret 4', + note: 'Secret 4 note', + rotateAfter: '24' + }, + project1.slug + ) - // expect(versions.length).toBe(1) - }) + const secretVersion = await prisma.secretVersion.findMany({ + where: { + secretId: secret.id + } + }) - it('should not be able to fetch decrypted secrets if the project does not store the private key', async () => { - // Fetch the environment of the project - const environment = await prisma.environment.findFirst({ - where: { - projectId: project2.id - } + expect(secretVersion.length).toBe(0) }) - await secretService.createSecret( - user1, - { - name: 'Secret 20', + it('should be able to roll back a secret', async () => { + // Creating a few versions first + await secretService.updateSecret(user1, secret1.slug, { entries: [ { - environmentId: environment.id, - value: 'Secret 20 value' + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug } - ], - rotateAfter: '24', - note: 'Secret 20 note' - }, - project2.id - ) + ] + }) - const response = await app.inject({ - method: 'GET', - url: `/secret/${project2.id}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value 2', + environmentSlug: environment1.slug + } + ] + }) - expect(response.statusCode).toBe(400) - expect(response.json().message).toEqual( - `Cannot decrypt secret values as the project does not store the private key` - ) - }) + let versions: SecretVersion[] - it('should be able to fetch all secrets', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + // eslint-disable-next-line prefer-const + versions = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) + expect(versions.length).toBe(3) - const { secret, values } = response.json().items[0] - expect(secret.id).toBeDefined() - expect(secret.name).toBeDefined() - expect(secret.note).toBeDefined() - expect(secret.projectId).toBeDefined() - expect(values.length).toBe(1) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const response = await app.inject({ + method: 'PUT', + url: `/secret/${secret1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const value = values[0] - expect(value.environment).toBeDefined() - expect(value.value).not.toEqual('Secret 1 value') + expect(response.statusCode).toBe(200) + expect(response.json().count).toEqual(2) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/secret/${project1.id}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/secret/${project1.id}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/secret/${project1.id}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` - ) - }) + versions = await prisma.secretVersion.findMany({ + where: { + secretId: secret1.id + } + }) - it('should be able to fetch all secrets decrypted', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}?decryptValue=true&page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(versions.length).toBe(1) }) + }) - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) - - const { secret, values } = response.json().items[0] - expect(secret.id).toBeDefined() - expect(secret.name).toBeDefined() - expect(secret.note).toBeDefined() - expect(secret.projectId).toBeDefined() - expect(values.length).toBe(1) - - const value = values[0] - expect(value.environment).toBeDefined() - expect(value.value).toEqual('Secret 1 value') + describe('Get All Secrets By Project Tests', () => { + it('should not be able to fetch decrypted secrets if the project does not store the private key', async () => { + // Fetch the environment of the project + const environment = await prisma.environment.findFirst({ + where: { + projectId: project2.id + } + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/secret/${project1.id}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/secret/${project1.id}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/secret/${project1.id}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` - ) - }) + await secretService.createSecret( + user1, + { + name: 'Secret 20', + entries: [ + { + environmentSlug: environment.slug, + value: 'Secret 20 value' + } + ], + rotateAfter: '24', + note: 'Secret 20 note' + }, + project2.slug + ) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${project2.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all secrets decrypted if the project does not store the private key', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project2.id}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Cannot decrypt secret values as the project does not store the private key` + ) }) - expect(response.statusCode).toBe(400) - expect(response.json().message).toEqual( - `Cannot decrypt secret values as the project does not store the private key` - ) - }) + it('should be able to fetch all secrets', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { secret, values } = response.json().items[0] + expect(secret.id).toBeDefined() + expect(secret.name).toBeDefined() + expect(secret.note).toBeDefined() + expect(secret.projectId).toBeDefined() + expect(values.length).toBe(1) + + const value = values[0] + expect(value.environment).toBeDefined() + expect(value.value).not.toEqual('Secret 1 value') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/secret/${project1.slug}?decryptValue=false&page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should be able to fetch all secrets decrypted', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?decryptValue=true&page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { secret, values } = response.json().items[0] + expect(secret.id).toBeDefined() + expect(secret.name).toBeDefined() + expect(secret.note).toBeDefined() + expect(secret.projectId).toBeDefined() + expect(values.length).toBe(1) + + const value = values[0] + expect(value.environment).toBeDefined() + expect(value.value).toEqual('Secret 1 value') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/secret/${project1.slug}?decryptValue=true&page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + it('should not be able to fetch all secrets decrypted if the project does not store the private key', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project2.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all secrets decrypted if somehow the project does not have a private key even though it stores it (hypothetical)', async () => { - await prisma.project.update({ - where: { - id: project1.id - }, - data: { - storePrivateKey: true, - privateKey: null - } + expect(response.statusCode).toBe(400) + expect(response.json().message).toEqual( + `Cannot decrypt secret values as the project does not store the private key` + ) }) - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}?decryptValue=true`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + it('should not be able to fetch all secrets decrypted if somehow the project does not have a private key even though it stores it (hypothetical)', async () => { + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + storePrivateKey: true, + privateKey: null + } + }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `Cannot decrypt secret values as the project does not have a private key` - ) + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}?decryptValue=true`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - await prisma.project.update({ - where: { - id: project1.id - }, - data: { - privateKey: project1.privateKey - } - }) - }) + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Cannot decrypt secret values as the project does not have a private key` + ) - it('should not be able to fetch all secrets if the user has no access to the project', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + await prisma.project.update({ + where: { + id: project1.id + }, + data: { + privateKey: project1.privateKey + } + }) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to fetch all secrets if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be able to fetch all secrets if the project does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/non-existing-project-id`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(401) }) - 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 fetch all secrets if the project does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/non-existing-project-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to fetch all secrets by project and environment', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project non-existing-project-slug not found' + ) }) + }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(1) + describe('Fetch All Secrets By Project And Environment Tests', () => { + it('should be able to fetch all secrets by project and environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const secret = response.json()[0] - expect(secret.name).toBe('Secret 1') - expect(secret.value).toBe('Secret 1 value') - expect(secret.isPlaintext).toBe(true) - }) + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) - it('should not be able to fetch all secrets by project and environment if project does not exists', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/non-existing-project-id/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + const secret = response.json()[0] + expect(secret.name).toBe('Secret 1') + expect(secret.value).toBe('Secret 1 value') + expect(secret.isPlaintext).toBe(true) }) - 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 fetch all secrets by project and environment if project does not exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/non-existing-project-slug/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all secrets by project and environment if environment does not exists', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}/non-existing-environment-id`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project non-existing-project-slug not found' + ) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - 'Environment with id non-existing-environment-id not found' - ) - }) + it('should not be able to fetch all secrets by project and environment if environment does not exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/non-existing-environment-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all secrets by project and environment if the user has no access to the project', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${project1.id}/${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Environment non-existing-environment-slug not found' + ) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to fetch all secrets by project and environment if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be sending the plaintext secret if project does not store the private key', async () => { - // Get the first environment of project 2 - const environment = await prisma.environment.findFirst({ - where: { - projectId: project2.id - } + expect(response.statusCode).toBe(401) }) - // Create a secret in project 2 - await secretService.createSecret( - user1, - { - name: 'Secret 20', - entries: [ - { - environmentId: environment.id, - value: 'Secret 20 value' - } - ], - rotateAfter: '24', - note: 'Secret 20 note' - }, - project2.id - ) + it('should not be sending the plaintext secret if project does not store the private key', async () => { + // Get the first environment of project 2 + const environment = await prisma.environment.findFirst({ + where: { + projectId: project2.id + } + }) - const response = await app.inject({ - method: 'GET', - url: `/secret/${project2.id}/${environment.id}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + // Create a secret in project 2 + await secretService.createSecret( + user1, + { + name: 'Secret 20', + entries: [ + { + environmentSlug: environment.slug, + value: 'Secret 20 value' + } + ], + rotateAfter: '24', + note: 'Secret 20 note' + }, + project2.slug + ) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${project2.slug}/${environment.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(1) + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) - const secret = response.json()[0] - expect(secret.name).toBe('Secret 20') - expect(secret.value).not.toBe('Secret 20 value') - expect(secret.isPlaintext).toBe(false) + const secret = response.json()[0] + expect(secret.name).toBe('Secret 20') + expect(secret.value).not.toBe('Secret 20 value') + expect(secret.isPlaintext).toBe(false) + }) }) - it('should not be able to delete a non-existing secret', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/secret/non-existing-secret-id`, - headers: { - 'x-e2e-user-email': user1.email - } + describe('Delete Secret Tests', () => { + it('should not be able to delete a non-existing secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/non-existing-secret-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Secret non-existing-secret-slug not found' + ) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - 'Secret with id non-existing-secret-id not found' - ) - }) + it('should not be able to delete a secret it does not have access to', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/${secret1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be able to delete a secret it does not have access to', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/secret/${secret1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should be able to delete a secret', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/secret/${secret1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should be able to delete a secret', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/secret/${secret1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) }) - expect(response.statusCode).toBe(200) - }) + it('should have created a SECRET_DELETED event', async () => { + // Delete a secret + await secretService.deleteSecret(user1, secret1.slug) - it('should have created a SECRET_DELETED event', async () => { - // Delete a secret - await secretService.deleteSecret(user1, secret1.id) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.SECRET + ) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.SECRET - ) + const event = response.items[0] - const event = response.items[0] - - expect(event.source).toBe(EventSource.SECRET) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.SECRET_DELETED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBe(secret1.id) + expect(event.source).toBe(EventSource.SECRET) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.SECRET_DELETED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBe(secret1.id) + }) }) - //revisions test - it('should be able to fetch all revisions of secrets', async () => { - // create two more entries,totalling three versions - // checks if its able to fetch multiple revisions - await secretService.updateSecret(user1, secret1.id, { - entries: [ - { - value: 'Updated Secret 1 value', - environmentId: environment1.id - } - ] - }) + describe('Revisions Tests', () => { + it('should be able to fetch all revisions of secrets', async () => { + // create two more entries,totalling three versions + // checks if its able to fetch multiple revisions + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value', + environmentSlug: environment1.slug + } + ] + }) - await secretService.updateSecret(user1, secret1.id, { - entries: [ - { - value: 'Updated Secret 1 value 2', - environmentId: environment1.id + await secretService.updateSecret(user1, secret1.slug, { + entries: [ + { + value: 'Updated Secret 1 value 2', + environmentSlug: environment1.slug + } + ] + }) + + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email } - ] - }) + }) - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(3) }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(3) - }) + it('should return [] if the secret has no revision', async () => { + //returns [] if secret has no revision + await prisma.secretVersion.deleteMany({ + where: { + secretId: secret1.id + } + }) - it('should return [] if the secret has no revision', async () => { - //returns [] if secret has no revision - await prisma.secretVersion.deleteMany({ - where: { - secretId: secret1.id - } - }) + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(0) }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(0) - }) + it('should return error if secret does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/9999/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should return error if secret does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/9999/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual(`Secret 9999 not found`) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual(`Secret with id 9999 not found`) - }) + it('should return error if environment does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/9999`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should return error if environment does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.id}/revisions/9999`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual(`Environment 9999 not found`) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `Environment with id 9999 not found` - ) - }) + it('returns error if secret is not accessible', async () => { + const response = await app.inject({ + method: 'GET', + url: `/secret/${secret1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('returns error if secret is not accessible', async () => { - const response = await app.inject({ - method: 'GET', - url: `/secret/${secret1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(401) }) }) diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index c5da4647..4d84fd50 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -18,11 +18,7 @@ import { } from '@prisma/client' import { CreateSecret } from '../dto/create.secret/create.secret' import { UpdateSecret } from '../dto/update.secret/update.secret' -import { decrypt } from '@/common/decrypt' import { PrismaService } from '@/prisma/prisma.service' -import { addHoursToDate } from '@/common/add-hours-to-date' -import { encrypt } from '@/common/encrypt' -import createEvent from '@/common/create-event' import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' import { CHANGE_NOTIFIER_RSC } from '@/socket/change-notifier.socket' @@ -32,7 +28,11 @@ import { ChangeNotificationEvent } from 'src/socket/socket.types' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import { addHoursToDate, limitMaxItemsPerPage } from '@/common/util' +import generateEntitySlug from '@/common/slug-generator' +import { decrypt, encrypt } from '@/common/cryptography' +import { createEvent } from '@/common/event' +import { getEnvironmentIdToSlugMap } from '@/common/environment' @Injectable() export class SecretService { @@ -50,48 +50,49 @@ export class SecretService { this.redis = redisClient.publisher } - async createSecret(user: User, dto: CreateSecret, projectId: Project['id']) { + /** + * Creates a new secret in a project + * @param user the user creating the secret + * @param dto the secret data + * @param projectSlug the slug of the project + * @returns the created secret + */ + async createSecret( + user: User, + dto: CreateSecret, + projectSlug: Project['slug'] + ) { // Fetch the project const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.CREATE_SECRET], prisma: this.prisma }) + const projectId = project.id // Check if the secret with the same name already exists in the project - await this.secretExists(dto.name, projectId) + await this.secretExists(dto.name, project) const shouldCreateRevisions = dto.entries && dto.entries.length > 0 // Check if the user has access to the environments - if (shouldCreateRevisions) { - const environmentIds = dto.entries.map((entry) => entry.environmentId) - await Promise.all( - environmentIds.map(async (environmentId) => { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - // Check if the environment belongs to the project - if (environment.projectId !== projectId) { - throw new BadRequestException( - `Environment: ${environmentId} does not belong to project: ${projectId}` - ) - } - }) - ) - } + const environmentSlugToIdMap = shouldCreateRevisions + ? await getEnvironmentIdToSlugMap( + dto, + user, + project, + this.prisma, + this.authorityCheckerService + ) + : new Map() // Create the secret const secret = await this.prisma.secret.create({ data: { name: dto.name, + slug: await generateEntitySlug(dto.name, 'SECRET', this.prisma), note: dto.note, rotateAt: addHoursToDate(dto.rotateAfter), versions: shouldCreateRevisions && { @@ -101,7 +102,7 @@ export class SecretService { value: await encrypt(project.publicKey, entry.value), version: 1, createdById: user.id, - environmentId: entry.environmentId + environmentId: environmentSlugToIdMap.get(entry.environmentSlug) })) ) } @@ -155,10 +156,21 @@ export class SecretService { return secret } - async updateSecret(user: User, secretId: Secret['id'], dto: UpdateSecret) { + /** + * Updates a secret in a project + * @param user the user performing the action + * @param secretSlug the slug of the secret to update + * @param dto the new secret data + * @returns the updated secret and the updated versions + */ + async updateSecret( + user: User, + secretSlug: Secret['slug'], + dto: UpdateSecret + ) { const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ userId: user.id, - entity: { id: secretId }, + entity: { slug: secretSlug }, authorities: [Authority.UPDATE_SECRET], prisma: this.prisma }) @@ -166,30 +178,18 @@ export class SecretService { const shouldCreateRevisions = dto.entries && dto.entries.length > 0 // Check if the secret with the same name already exists in the project - dto.name && (await this.secretExists(dto.name, secret.projectId)) + dto.name && (await this.secretExists(dto.name, secret.project)) // Check if the user has access to the environments - if (shouldCreateRevisions) { - const environmentIds = dto.entries.map((entry) => entry.environmentId) - await Promise.all( - environmentIds.map(async (environmentId) => { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - // Check if the environment belongs to the project - if (environment.projectId !== secret.projectId) { - throw new BadRequestException( - `Environment: ${environmentId} does not belong to project: ${secret.projectId}` - ) - } - }) - ) - } + const environmentSlugToIdMap = shouldCreateRevisions + ? await getEnvironmentIdToSlugMap( + dto, + user, + secret.project, + this.prisma, + this.authorityCheckerService + ) + : new Map() const op = [] @@ -203,6 +203,9 @@ export class SecretService { }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'SECRET', this.prisma) + : undefined, note: dto.note, rotateAt: dto.rotateAfter ? addHoursToDate(dto.rotateAfter) @@ -212,7 +215,8 @@ export class SecretService { select: { id: true, name: true, - note: true + note: true, + slug: true } }) ) @@ -225,7 +229,7 @@ export class SecretService { const latestVersion = await this.prisma.secretVersion.findFirst({ where: { secretId: secret.id, - environmentId: entry.environmentId + environmentId: environmentSlugToIdMap.get(entry.environmentSlug) }, select: { version: true @@ -243,7 +247,7 @@ export class SecretService { value: await encrypt(secret.project.publicKey, entry.value), version: latestVersion ? latestVersion.version + 1 : 1, createdById: user.id, - environmentId: entry.environmentId, + environmentId: environmentSlugToIdMap.get(entry.environmentSlug), secretId: secret.id }, select: { @@ -274,7 +278,7 @@ export class SecretService { await this.redis.publish( CHANGE_NOTIFIER_RSC, JSON.stringify({ - environmentId: entry.environmentId, + environmentId: environmentSlugToIdMap.get(entry.environmentSlug), name: updatedSecret.name, value: entry.value, isPlaintext: true @@ -309,20 +313,38 @@ export class SecretService { return result } + /** + * Rollback a secret to a specific version + * @param user the user performing the action + * @param secretSlug the slug of the secret to rollback + * @param environmentSlug the slug of the environment to rollback + * @param rollbackVersion the version to rollback to + * @returns the deleted secret versions + */ async rollbackSecret( user: User, - secretId: Secret['id'], - environmentId: Environment['id'], + secretSlug: Secret['slug'], + environmentSlug: Environment['slug'], rollbackVersion: SecretVersion['version'] ) { // Fetch the secret const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ userId: user.id, - entity: { id: secretId }, + entity: { slug: secretSlug }, authorities: [Authority.UPDATE_SECRET], prisma: this.prisma }) + // Fetch the environment + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.UPDATE_SECRET], + prisma: this.prisma + }) + const environmentId = environment.id + const project = secret.project // Filter the secret versions by the environment @@ -332,7 +354,7 @@ export class SecretService { if (secret.versions.length === 0) { throw new NotFoundException( - `No versions found for environment: ${environmentId} for secret: ${secretId}` + `No versions found for environment: ${environmentSlug} for secret: ${secretSlug}` ) } @@ -342,7 +364,7 @@ export class SecretService { // Check if the rollback version is valid if (rollbackVersion < 1 || rollbackVersion >= maxVersion) { throw new NotFoundException( - `Invalid rollback version: ${rollbackVersion} for secret: ${secretId}` + `Invalid rollback version: ${rollbackVersion} for secret: ${secretSlug}` ) } @@ -395,11 +417,17 @@ export class SecretService { return result } - async deleteSecret(user: User, secretId: Secret['id']) { + /** + * Deletes a secret from a project + * @param user the user performing the action + * @param secretSlug the slug of the secret to delete + * @returns void + */ + async deleteSecret(user: User, secretSlug: Secret['slug']) { // Check if the user has the required role const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ userId: user.id, - entity: { id: secretId }, + entity: { slug: secretSlug }, authorities: [Authority.DELETE_SECRET], prisma: this.prisma }) @@ -429,27 +457,39 @@ export class SecretService { this.logger.log(`User ${user.id} deleted secret ${secret.id}`) } + /** + * Gets all secrets of a project and environment + * @param user the user performing the action + * @param projectSlug the slug of the project + * @param environmentSlug the slug of the environment + * @returns an array of objects with the secret name and value + * @throws {NotFoundException} if the project or environment does not exist + * @throws {BadRequestException} if the user does not have the required role + */ async getAllSecretsOfProjectAndEnvironment( user: User, - projectId: Project['id'], - environmentId: Environment['id'] + projectSlug: Project['slug'], + environmentSlug: Environment['slug'] ) { // Fetch the project const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.READ_SECRET], prisma: this.prisma }) + const projectId = project.id // Check access to the environment - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) + const environmentId = environment.id const secrets = await this.prisma.secret.findMany({ where: { @@ -493,33 +533,46 @@ export class SecretService { return response } + + /** + * Gets all revisions of a secret in an environment + * @param user the user performing the action + * @param secretSlug the slug of the secret + * @param environmentSlug the slug of the environment + * @param page the page of items to return + * @param limit the number of items to return per page + * @param order the order of the items. Default is 'desc' + * @returns an object with the items and the pagination metadata + */ async getRevisionsOfSecret( user: User, - secretId: Secret['id'], - environmentId: Environment['id'], + secretSlug: Secret['slug'], + environmentSlug: Environment['slug'], page: number, limit: number, - order: string + order: 'asc' | 'desc' = 'desc' ) { - // assign order to variable dynamically - const sortOrder = order === 'asc' ? 'asc' : 'desc' - //check access to secret - await this.authorityCheckerService.checkAuthorityOverSecret({ + // Fetch the secret + const secret = await this.authorityCheckerService.checkAuthorityOverSecret({ userId: user.id, - entity: { id: secretId }, + entity: { slug: secretSlug }, authorities: [Authority.READ_SECRET], prisma: this.prisma }) + const secretId = secret.id - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + // Fetch the environment + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) + const environmentId = environment.id - // get the revisions - const revisions = await this.prisma.secretVersion.findMany({ + // Get the revisions + const items = await this.prisma.secretVersion.findMany({ where: { secretId: secretId, environmentId: environmentId @@ -527,15 +580,41 @@ export class SecretService { skip: page * limit, take: limitMaxItemsPerPage(limit), orderBy: { - version: sortOrder + version: order + } + }) + + const totalCount = await this.prisma.secretVersion.count({ + where: { + secretId: secretId, + environmentId: environmentId } }) - return revisions + + const metadata = paginate(totalCount, `/secret/${secretSlug}`, { + page, + limit: limitMaxItemsPerPage(limit), + order + }) + + return { items, metadata } } + /** + * Gets all secrets of a project + * @param user the user performing the action + * @param projectSlug the slug of the project + * @param decryptValue whether to decrypt the secret values or not + * @param page the page of items to return + * @param limit the number of items to return per page + * @param sort the field to sort the results by + * @param order the order of the results + * @param search the search query + * @returns an object with the items and the pagination metadata + */ async getAllSecretsOfProject( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], decryptValue: boolean, page: number, limit: number, @@ -547,10 +626,11 @@ export class SecretService { const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.READ_SECRET], prisma: this.prisma }) + const projectId = project.id // Check if the secret values can be decrypted await this.checkAutoDecrypt(decryptValue, project) @@ -659,7 +739,7 @@ export class SecretService { const items = Array.from(secretsWithEnvironmentalValues.values()) - //Calculate pagination metadata + // Calculate pagination metadata const totalCount = await this.prisma.secret.count({ where: { projectId, @@ -671,7 +751,7 @@ export class SecretService { const metadata = paginate( totalCount, - `/secret/${projectId}`, + `/secret/${projectSlug}`, { page, limit: limitMaxItemsPerPage(limit), @@ -685,24 +765,34 @@ export class SecretService { return { items, metadata } } - private async secretExists( - secretName: Secret['name'], - projectId: Project['id'] - ) { + /** + * Checks if a secret with a given name already exists in the project + * @throws {ConflictException} if the secret already exists + * @param secretName the name of the secret to check + * @param project the project to check the secret in + */ + private async secretExists(secretName: Secret['name'], project: Project) { if ( (await this.prisma.secret.findFirst({ where: { name: secretName, - projectId + projectId: project.id } })) !== null ) { throw new ConflictException( - `Secret already exists: ${secretName} in project ${projectId}` + `Secret already exists: ${secretName} in project ${project.slug}` ) } } + /** + * Checks if the project is allowed to decrypt secret values + * @param decryptValue whether to decrypt the secret values or not + * @param project the project to check + * @throws {BadRequestException} if the project does not store the private key and decryptValue is true + * @throws {NotFoundException} if the project does not have a private key and decryptValue is true + */ private async checkAutoDecrypt(decryptValue: boolean, project: Project) { // Check if the project is allowed to store the private key if (decryptValue && !project.storePrivateKey) { diff --git a/apps/api/src/socket/change-notifier.socket.ts b/apps/api/src/socket/change-notifier.socket.ts index 54d98fb2..ec58ace5 100644 --- a/apps/api/src/socket/change-notifier.socket.ts +++ b/apps/api/src/socket/change-notifier.socket.ts @@ -94,74 +94,76 @@ export default class ChangeNotifier ) @UseGuards(AuthGuard, ApiKeyGuard) @SubscribeMessage('register-client-app') + /** + * This event is emitted from the CLI to register + * itself with our services so that it can receive live updates. + * + * The CLI will send a `ChangeNotifierRegistration` object + * as the message body, containing the workspace slug, project slug, + * and environment slug that the client app wants to receive updates for. + * + * We will then check if the user has access to the workspace, + * project, and environment, and if so, add the client to the + * list of connected clients for that environment. + * + * Finally, we will send an ACK to the client with a status code of 200. + */ async handleRegister( @ConnectedSocket() client: Socket, @MessageBody() data: ChangeNotifierRegistration, @CurrentUser() user: User ) { - // Check if the user has access to the workspace - const workspace = await this.prisma.workspace.findFirst({ - where: { - name: data.workspaceName, - members: { - some: { - userId: user.id - } - } - } - }) - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspace.id }, - authorities: [Authority.READ_WORKSPACE], - prisma: this.prisma - }) + try { + // Check if the user has access to the workspace + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: data.workspaceSlug }, + authorities: [ + Authority.READ_WORKSPACE, + Authority.READ_VARIABLE, + Authority.READ_SECRET + ], + prisma: this.prisma + }) + + // Check if the user has access to the project + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: data.projectSlug }, + authorities: [Authority.READ_PROJECT], + prisma: this.prisma + }) + + // Check if the user has access to the environment + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: data.environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) + + // Add the client to the environment + await this.addClientToEnvironment(client, environment.id) + + // Send ACK to client + client.emit('client-registered', { + success: true, + message: 'Registration Successful' + }) - // Check if the user has access to the project - const project = await this.prisma.project.findFirst({ - where: { - name: data.projectName, - workspaceId: workspace.id - } - }) - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: project.id }, - authorities: [Authority.READ_PROJECT], - prisma: this.prisma - }) - - // Check if the user has access to the environment - const environment = await this.prisma.environment.findFirst({ - where: { - name: data.environmentName, - projectId: project.id - } - }) - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environment.id }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - // Add the client to the environment - await this.addClientToEnvironment(client, environment.id) - - const clientRegisteredResponse = { - workspaceId: workspace.id, - projectId: project.id, - environmentId: environment.id + this.logger.log( + `Client registered: ${client.id} for configuration: ${JSON.stringify( + data + )}` + ) + } catch (error) { + this.logger.error(error) + client.emit('client-registered', { + success: false, + message: error as string + }) } - - // Send ACK to client - client.emit('client-registered', clientRegisteredResponse) - - this.logger.log( - `Client registered: ${client.id} for configuration: ${JSON.stringify( - clientRegisteredResponse - )}` - ) } private async addClientToEnvironment(client: Socket, environmentId: string) { diff --git a/apps/api/src/socket/socket.types.ts b/apps/api/src/socket/socket.types.ts index 87d4b3f8..aab24558 100644 --- a/apps/api/src/socket/socket.types.ts +++ b/apps/api/src/socket/socket.types.ts @@ -1,13 +1,7 @@ export interface ChangeNotifierRegistration { - workspaceName: string - projectName: string - environmentName: string -} - -export interface ClientRegisteredResponse { - workspaceId: string - projectId: string - environmentId: string + workspaceSlug: string + projectSlug: string + environmentSlug: string } export interface ChangeNotification { diff --git a/apps/api/src/user/controller/user.controller.spec.ts b/apps/api/src/user/controller/user.controller.spec.ts index baa78132..0a4d8d21 100644 --- a/apps/api/src/user/controller/user.controller.spec.ts +++ b/apps/api/src/user/controller/user.controller.spec.ts @@ -5,6 +5,8 @@ import { PrismaService } from '@/prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' +import { CacheService } from '@/cache/cache.service' +import { REDIS_CLIENT } from '@/provider/redis.provider' describe('UserController', () => { let controller: UserController @@ -15,7 +17,9 @@ describe('UserController', () => { providers: [ UserService, PrismaService, - { provide: MAIL_SERVICE, useValue: MockMailService } + { provide: MAIL_SERVICE, useValue: MockMailService }, + { provide: REDIS_CLIENT, useValue: null }, + CacheService ] }) .overrideProvider(PrismaService) diff --git a/apps/api/src/user/controller/user.controller.ts b/apps/api/src/user/controller/user.controller.ts index 873bcd2e..084aad18 100644 --- a/apps/api/src/user/controller/user.controller.ts +++ b/apps/api/src/user/controller/user.controller.ts @@ -16,54 +16,17 @@ import { Authority, User } from '@prisma/client' import { UpdateUserDto } from '../dto/update.user/update.user' import { AdminGuard } from '@/auth/guard/admin/admin.guard' import { CreateUserDto } from '../dto/create.user/create.user' -import { - ApiBearerAuth, - ApiCreatedResponse, - ApiForbiddenResponse, - ApiNoContentResponse, - ApiOkResponse, - ApiOperation, - ApiSecurity, - ApiTags -} from '@nestjs/swagger' import { BypassOnboarding } from '@/decorators/bypass-onboarding.decorator' import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authorities.decorator' import { ForbidApiKey } from '@/decorators/forbid-api-key.decorator' -import { invalidAuthenticationResponse } from '@/common/static' - -const userSchema = { - type: 'object', - properties: { - id: { type: 'string' }, - email: { type: 'string' }, - name: { type: 'string' }, - profilePictureUrl: { type: 'string' }, - isAdmin: { type: 'boolean' }, - isActive: { type: 'boolean' }, - isOnboardingFinished: { type: 'boolean' } - } -} -@ApiTags('User Controller') @Controller('user') -@ApiBearerAuth() -@ApiSecurity('api_key') export class UserController { constructor(private readonly userService: UserService) {} @Get() @BypassOnboarding() @RequiredApiKeyAuthorities(Authority.READ_SELF) - @ApiOperation({ - summary: 'Get current user', - description: - 'This endpoint returns the details of the currently logged in user' - }) - @ApiOkResponse({ - description: 'User details', - schema: userSchema - }) - @ApiForbiddenResponse(invalidAuthenticationResponse) async getCurrentUser(@CurrentUser() user: User) { return this.userService.getSelf(user) } @@ -71,40 +34,19 @@ export class UserController { @Put() @BypassOnboarding() @RequiredApiKeyAuthorities(Authority.UPDATE_SELF) - @ApiOperation({ - summary: 'Update current user', - description: - 'This endpoint updates the details of the currently logged in user' - }) - @ApiOkResponse({ - description: 'Updated user details', - schema: userSchema - }) - @ApiForbiddenResponse(invalidAuthenticationResponse) async updateSelf(@CurrentUser() user: User, @Body() dto: UpdateUserDto) { return await this.userService.updateSelf(user, dto) } @Delete() - @ApiNoContentResponse() @HttpCode(204) @ForbidApiKey() - @ApiOperation({ - summary: 'Delete current user', - description: - 'This endpoint deletes the details of the currently logged in user' - }) - @ApiForbiddenResponse(invalidAuthenticationResponse) - @ApiNoContentResponse({ - description: 'User deleted successfully' - }) async deleteSelf(@CurrentUser() user: User) { await this.userService.deleteSelf(user) } @Delete(':userId') @UseGuards(AdminGuard) - @ApiNoContentResponse() @HttpCode(204) async deleteUser(@Param('userId') userId: string) { await this.userService.deleteUser(userId) @@ -139,7 +81,6 @@ export class UserController { @Post() @UseGuards(AdminGuard) - @ApiCreatedResponse() async createUser(@Body() dto: CreateUserDto) { return await this.userService.createUser(dto) } diff --git a/apps/api/src/user/service/user.service.spec.ts b/apps/api/src/user/service/user.service.spec.ts index dea0a0b7..a300c1d0 100644 --- a/apps/api/src/user/service/user.service.spec.ts +++ b/apps/api/src/user/service/user.service.spec.ts @@ -3,6 +3,8 @@ import { UserService } from './user.service' import { PrismaService } from '@/prisma/prisma.service' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' +import { CacheService } from '@/cache/cache.service' +import { REDIS_CLIENT } from '@/provider/redis.provider' describe('UserService', () => { let service: UserService @@ -12,7 +14,9 @@ describe('UserService', () => { providers: [ UserService, PrismaService, - { provide: MAIL_SERVICE, useClass: MockMailService } + CacheService, + { provide: MAIL_SERVICE, useClass: MockMailService }, + { provide: REDIS_CLIENT, useValue: null } ] }).compile() diff --git a/apps/api/src/user/service/user.service.ts b/apps/api/src/user/service/user.service.ts index 7b1a3093..ac4247cd 100644 --- a/apps/api/src/user/service/user.service.ts +++ b/apps/api/src/user/service/user.service.ts @@ -10,10 +10,10 @@ import { AuthProvider, User, Workspace } from '@prisma/client' import { PrismaService } from '@/prisma/prisma.service' import { CreateUserDto } from '../dto/create.user/create.user' import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' -import createUser from '@/common/create-user' -import generateOtp from '@/common/generate-otp' import { EnvSchema } from '@/common/env/env.schema' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import { generateOtp, limitMaxItemsPerPage } from '@/common/util' +import { createUser } from '@/common/user' +import { CacheService } from '@/cache/cache.service' @Injectable() export class UserService { @@ -21,6 +21,7 @@ export class UserService { constructor( private readonly prisma: PrismaService, + private readonly cache: CacheService, @Inject(MAIL_SERVICE) private readonly mailService: IMailService ) {} @@ -77,13 +78,17 @@ export class UserService { await this.mailService.sendEmailChangedOtp(dto.email, otp.code) } - this.log.log(`Updating user ${user.id} with data ${dto}`) - return this.prisma.user.update({ + const updatedUser = await this.prisma.user.update({ where: { id: user.id }, data }) + await this.cache.setUser(updatedUser) + + this.log.log(`Updated user ${user.id} with data ${dto}`) + + return updatedUser } async updateUser(userId: string, dto: UpdateUserDto) { diff --git a/apps/api/src/variable/controller/variable.controller.ts b/apps/api/src/variable/controller/variable.controller.ts index 31560935..cf531046 100644 --- a/apps/api/src/variable/controller/variable.controller.ts +++ b/apps/api/src/variable/controller/variable.controller.ts @@ -19,56 +19,56 @@ import { UpdateVariable } from '../dto/update.variable/update.variable' export class VariableController { constructor(private readonly variableService: VariableService) {} - @Post(':projectId') + @Post(':projectSlug') @RequiredApiKeyAuthorities(Authority.CREATE_VARIABLE) async createVariable( @CurrentUser() user: User, - @Param('projectId') projectId: string, + @Param('projectSlug') projectSlug: string, @Body() dto: CreateVariable ) { - return await this.variableService.createVariable(user, dto, projectId) + return await this.variableService.createVariable(user, dto, projectSlug) } - @Put(':variableId') + @Put(':variableSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) async updateVariable( @CurrentUser() user: User, - @Param('variableId') variableId: string, + @Param('variableSlug') variableSlug: string, @Body() dto: UpdateVariable ) { - return await this.variableService.updateVariable(user, variableId, dto) + return await this.variableService.updateVariable(user, variableSlug, dto) } - @Put(':variableId/rollback/:rollbackVersion') + @Put(':variableSlug/rollback/:rollbackVersion') @RequiredApiKeyAuthorities(Authority.UPDATE_VARIABLE) async rollbackVariable( @CurrentUser() user: User, - @Param('variableId') variableId: string, - @Query('environmentId') environmentId: string, + @Param('variableSlug') variableSlug: string, + @Query('environmentSlug') environmentSlug: string, @Param('rollbackVersion') rollbackVersion: number ) { return await this.variableService.rollbackVariable( user, - variableId, - environmentId, + variableSlug, + environmentSlug, rollbackVersion ) } - @Delete(':variableId') + @Delete(':variableSlug') @RequiredApiKeyAuthorities(Authority.DELETE_VARIABLE) async deleteVariable( @CurrentUser() user: User, - @Param('variableId') variableId: string + @Param('variableSlug') variableSlug: string ) { - return await this.variableService.deleteVariable(user, variableId) + return await this.variableService.deleteVariable(user, variableSlug) } - @Get('/:projectId') + @Get('/:projectSlug') @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) async getAllVariablesOfProject( @CurrentUser() user: User, - @Param('projectId') projectId: string, + @Param('projectSlug') projectSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -77,7 +77,7 @@ export class VariableController { ) { return await this.variableService.getAllVariablesOfProject( user, - projectId, + projectSlug, page, limit, sort, @@ -86,34 +86,34 @@ export class VariableController { ) } - @Get('/:projectId/:environmentId') + @Get('/:projectSlug/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) async getAllVariablesOfEnvironment( @CurrentUser() user: User, - @Param('projectId') projectId: string, - @Param('environmentId') environmentId: string + @Param('projectSlug') projectSlug: string, + @Param('environmentSlug') environmentSlug: string ) { return await this.variableService.getAllVariablesOfProjectAndEnvironment( user, - projectId, - environmentId + projectSlug, + environmentSlug ) } - @Get('/:variableId/revisions/:environmentId') + @Get('/:variableSlug/revisions/:environmentSlug') @RequiredApiKeyAuthorities(Authority.READ_VARIABLE) async getRevisionsOfVariable( @CurrentUser() user: User, - @Param('variableId') variableId: string, - @Param('environmentId') environmentId: string, + @Param('variableSlug') variableSlug: string, + @Param('environmentSlug') environmentSlug: string, @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('order') order: 'asc' | 'desc' = 'desc' ) { return await this.variableService.getRevisionsOfVariable( user, - variableId, - environmentId, + variableSlug, + environmentSlug, page, limit, order diff --git a/apps/api/src/variable/dto/create.variable/create.variable.ts b/apps/api/src/variable/dto/create.variable/create.variable.ts index 20502a4f..60cd37f8 100644 --- a/apps/api/src/variable/dto/create.variable/create.variable.ts +++ b/apps/api/src/variable/dto/create.variable/create.variable.ts @@ -29,7 +29,7 @@ export class CreateVariable { class Entry { @IsString() @Transform(({ value }) => value.trim()) - environmentId: string + environmentSlug: string @IsString() @Transform(({ value }) => value.trim()) diff --git a/apps/api/src/variable/service/variable.service.ts b/apps/api/src/variable/service/variable.service.ts index e50fbf37..50592b66 100644 --- a/apps/api/src/variable/service/variable.service.ts +++ b/apps/api/src/variable/service/variable.service.ts @@ -1,5 +1,4 @@ import { - BadRequestException, ConflictException, Inject, Injectable, @@ -18,7 +17,6 @@ import { VariableVersion } from '@prisma/client' import { CreateVariable } from '../dto/create.variable/create.variable' -import createEvent from '@/common/create-event' import { UpdateVariable } from '../dto/update.variable/update.variable' import { RedisClientType } from 'redis' import { REDIS_CLIENT } from '@/provider/redis.provider' @@ -29,7 +27,10 @@ import { ChangeNotificationEvent } from 'src/socket/socket.types' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import { getEnvironmentIdToSlugMap } from '@/common/environment' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { limitMaxItemsPerPage } from '@/common/util' @Injectable() export class VariableService { @@ -47,59 +48,56 @@ export class VariableService { this.redis = redisClient.publisher } + /** + * Creates a new variable in a project + * @param user the user performing the action + * @param dto the variable to create + * @param projectSlug the slug of the project to create the variable in + * @returns the newly created variable + */ async createVariable( user: User, dto: CreateVariable, - projectId: Project['id'] + projectSlug: Project['slug'] ) { // Fetch the project const project = await this.authorityCheckerService.checkAuthorityOverProject({ userId: user.id, - entity: { id: projectId }, + entity: { slug: projectSlug }, authorities: [Authority.CREATE_VARIABLE], prisma: this.prisma }) + const projectId = project.id // Check if a variable with the same name already exists in the project - await this.variableExists(dto.name, projectId) + await this.variableExists(dto.name, project) const shouldCreateRevisions = dto.entries && dto.entries.length > 0 // Check if the user has access to the environments - if (shouldCreateRevisions) { - const environmentIds = dto.entries.map((entry) => entry.environmentId) - await Promise.all( - environmentIds.map(async (environmentId) => { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - // Check if the environment belongs to the project - if (environment.projectId !== projectId) { - throw new BadRequestException( - `Environment: ${environmentId} does not belong to project: ${projectId}` - ) - } - }) - ) - } + const environmentSlugToIdMap = shouldCreateRevisions + ? await getEnvironmentIdToSlugMap( + dto, + user, + project, + this.prisma, + this.authorityCheckerService + ) + : new Map() // Create the variable const variable = await this.prisma.variable.create({ data: { name: dto.name, + slug: await generateEntitySlug(dto.name, 'VARIABLE', this.prisma), note: dto.note, versions: shouldCreateRevisions && { createMany: { data: dto.entries.map((entry) => ({ value: entry.value, createdById: user.id, - environmentId: entry.environmentId + environmentId: environmentSlugToIdMap.get(entry.environmentSlug) })) } }, @@ -152,46 +150,41 @@ export class VariableService { return variable } + /** + * Updates a variable in a project + * @param user the user performing the action + * @param variableSlug the slug of the variable to update + * @param dto the data to update the variable with + * @returns the updated variable and its new versions + */ async updateVariable( user: User, - variableId: Variable['id'], + variableSlug: Variable['slug'], dto: UpdateVariable ) { const variable = await this.authorityCheckerService.checkAuthorityOverVariable({ userId: user.id, - entity: { id: variableId }, + entity: { slug: variableSlug }, authorities: [Authority.UPDATE_VARIABLE], prisma: this.prisma }) // Check if the variable already exists in the project - dto.name && (await this.variableExists(dto.name, variable.projectId)) + dto.name && (await this.variableExists(dto.name, variable.project)) const shouldCreateRevisions = dto.entries && dto.entries.length > 0 // Check if the user has access to the environments - if (shouldCreateRevisions) { - const environmentIds = dto.entries.map((entry) => entry.environmentId) - await Promise.all( - environmentIds.map(async (environmentId) => { - const environment = - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) - - // Check if the environment belongs to the project - if (environment.projectId !== variable.projectId) { - throw new BadRequestException( - `Environment: ${environmentId} does not belong to project: ${variable.projectId}` - ) - } - }) - ) - } + const environmentSlugToIdMap = shouldCreateRevisions + ? await getEnvironmentIdToSlugMap( + dto, + user, + variable.project, + this.prisma, + this.authorityCheckerService + ) + : new Map() const op = [] @@ -205,13 +198,17 @@ export class VariableService { }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'VARIABLE', this.prisma) + : undefined, note: dto.note, lastUpdatedById: user.id }, select: { id: true, name: true, - note: true + note: true, + slug: true } }) ) @@ -224,7 +221,7 @@ export class VariableService { const latestVersion = await this.prisma.variableVersion.findFirst({ where: { variableId: variable.id, - environmentId: entry.environmentId + environmentId: environmentSlugToIdMap.get(entry.environmentSlug) }, select: { version: true @@ -242,7 +239,7 @@ export class VariableService { value: entry.value, version: latestVersion ? latestVersion.version + 1 : 1, createdById: user.id, - environmentId: entry.environmentId, + environmentId: environmentSlugToIdMap.get(entry.environmentSlug), variableId: variable.id }, select: { @@ -272,7 +269,7 @@ export class VariableService { await this.redis.publish( CHANGE_NOTIFIER_RSC, JSON.stringify({ - environmentId: entry.environmentId, + environmentId: environmentSlugToIdMap.get(entry.environmentSlug), name: updatedVariable.name, value: entry.value, isPlaintext: true @@ -309,16 +306,35 @@ export class VariableService { return result } + /** + * Rollback a variable to a specific version in a given environment. + * + * Throws a NotFoundException if the variable does not exist or if the version is invalid. + * @param user the user performing the action + * @param variableSlug the slug of the variable to rollback + * @param environmentSlug the slug of the environment to rollback in + * @param rollbackVersion the version to rollback to + * @returns the deleted variable versions + */ async rollbackVariable( user: User, - variableId: Variable['id'], - environmentId: Environment['id'], + variableSlug: Variable['slug'], + environmentSlug: Environment['slug'], rollbackVersion: VariableVersion['version'] ) { + const environment = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.UPDATE_VARIABLE], + prisma: this.prisma + }) + const environmentId = environment.id + const variable = await this.authorityCheckerService.checkAuthorityOverVariable({ userId: user.id, - entity: { id: variableId }, + entity: { slug: variableSlug }, authorities: [Authority.UPDATE_VARIABLE], prisma: this.prisma }) @@ -330,7 +346,7 @@ export class VariableService { if (variable.versions.length === 0) { throw new NotFoundException( - `No versions found for environment: ${environmentId} for variable: ${variableId}` + `No versions found for environment: ${environmentSlug} for variable: ${variableSlug}` ) } @@ -340,7 +356,7 @@ export class VariableService { // Check if the rollback version is valid if (rollbackVersion < 1 || rollbackVersion >= maxVersion) { throw new NotFoundException( - `Invalid rollback version: ${rollbackVersion} for variable: ${variableId}` + `Invalid rollback version: ${rollbackVersion} for variable: ${variableSlug}` ) } @@ -393,11 +409,19 @@ export class VariableService { return result } - async deleteVariable(user: User, variableId: Variable['id']) { + /** + * Deletes a variable from a project. + * @param user the user performing the action + * @param variableSlug the slug of the variable to delete + * @returns nothing + * @throws `NotFoundException` if the variable does not exist + * @throws `ForbiddenException` if the user does not have the required authority + */ + async deleteVariable(user: User, variableSlug: Variable['slug']) { const variable = await this.authorityCheckerService.checkAuthorityOverVariable({ userId: user.id, - entity: { id: variableId }, + entity: { slug: variableSlug }, authorities: [Authority.DELETE_VARIABLE], prisma: this.prisma }) @@ -430,26 +454,37 @@ export class VariableService { this.logger.log(`User ${user.id} deleted variable ${variable.id}`) } + /** + * Gets all variables of a project and environment. + * @param user the user performing the action + * @param projectSlug the slug of the project to get the variables from + * @param environmentSlug the slug of the environment to get the variables from + * @returns an array of objects containing the name, value and whether the value is a plaintext + * @throws `NotFoundException` if the project or environment does not exist + * @throws `ForbiddenException` if the user does not have the required authority + */ async getAllVariablesOfProjectAndEnvironment( user: User, - projectId: Project['id'], - environmentId: Environment['id'] + projectSlug: Project['slug'], + environmentSlug: Environment['slug'] ) { // Check if the user has the required authorities in the project - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: projectId }, - authorities: [Authority.READ_VARIABLE], - prisma: this.prisma - }) + const { id: projectId } = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: projectSlug }, + authorities: [Authority.READ_VARIABLE], + prisma: this.prisma + }) // Check if the user has the required authorities in the environment - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + const { id: environmentId } = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) const variables = await this.prisma.variable.findMany({ where: { @@ -489,9 +524,22 @@ export class VariableService { ) } + /** + * Gets all variables of a project, paginated, sorted and filtered by search query. + * @param user the user performing the action + * @param projectSlug the slug of the project to get the variables from + * @param page the page number to fetch + * @param limit the number of items per page + * @param sort the field to sort by + * @param order the order to sort in + * @param search the search query to filter by + * @returns a paginated list of variables with their latest versions for each environment + * @throws `NotFoundException` if the project does not exist + * @throws `ForbiddenException` if the user does not have the required authority + */ async getAllVariablesOfProject( user: User, - projectId: Project['id'], + projectSlug: Project['slug'], page: number, limit: number, sort: string, @@ -499,12 +547,13 @@ export class VariableService { search: string ) { // Check if the user has the required authorities in the project - await this.authorityCheckerService.checkAuthorityOverProject({ - userId: user.id, - entity: { id: projectId }, - authorities: [Authority.READ_VARIABLE], - prisma: this.prisma - }) + const { id: projectId } = + await this.authorityCheckerService.checkAuthorityOverProject({ + userId: user.id, + entity: { slug: projectSlug }, + authorities: [Authority.READ_VARIABLE], + prisma: this.prisma + }) const variables = await this.prisma.variable.findMany({ where: { @@ -616,7 +665,7 @@ export class VariableService { } }) - const metadata = paginate(totalCount, `/variable/${projectId}`, { + const metadata = paginate(totalCount, `/variable/${projectSlug}`, { page, limit: limitMaxItemsPerPage(limit), sort, @@ -627,29 +676,45 @@ export class VariableService { return { items, metadata } } + /** + * Gets all revisions of a variable in a given environment. + * + * The response is paginated and sorted by the version in the given order. + * @param user the user performing the action + * @param variableSlug the slug of the variable + * @param environmentSlug the slug of the environment + * @param page the page number to fetch + * @param limit the number of items per page + * @param order the order to sort in + * @returns a paginated list of variable versions with metadata + * @throws `NotFoundException` if the variable or environment does not exist + * @throws `ForbiddenException` if the user does not have the required authority + */ async getRevisionsOfVariable( user: User, - variableId: Variable['id'], - environmentId: Environment['id'], + variableSlug: Variable['slug'], + environmentSlug: Environment['slug'], page: number, limit: number, - order: 'asc' | 'desc' + order: 'asc' | 'desc' = 'desc' ) { - await this.authorityCheckerService.checkAuthorityOverVariable({ - userId: user.id, - entity: { id: variableId }, - authorities: [Authority.READ_VARIABLE], - prisma: this.prisma - }) + const { id: variableId } = + await this.authorityCheckerService.checkAuthorityOverVariable({ + userId: user.id, + entity: { slug: variableSlug }, + authorities: [Authority.READ_VARIABLE], + prisma: this.prisma + }) - await this.authorityCheckerService.checkAuthorityOverEnvironment({ - userId: user.id, - entity: { id: environmentId }, - authorities: [Authority.READ_ENVIRONMENT], - prisma: this.prisma - }) + const { id: environmentId } = + await this.authorityCheckerService.checkAuthorityOverEnvironment({ + userId: user.id, + entity: { slug: environmentSlug }, + authorities: [Authority.READ_ENVIRONMENT], + prisma: this.prisma + }) - const revisions = await this.prisma.variableVersion.findMany({ + const items = await this.prisma.variableVersion.findMany({ where: { variableId: variableId, environmentId: environmentId @@ -662,23 +727,44 @@ export class VariableService { } }) - return revisions + const total = await this.prisma.variableVersion.count({ + where: { + variableId: variableId, + environmentId: environmentId + } + }) + + const metadata = paginate(total, `/variable/${variableSlug}`, { + page, + limit: limitMaxItemsPerPage(limit), + order + }) + + return { items, metadata } } + /** + * Checks if a variable with a given name already exists in a project. + * Throws a ConflictException if the variable already exists. + * @param variableName the name of the variable to check for + * @param project the project to check in + * @returns nothing + * @throws `ConflictException` if the variable already exists + */ private async variableExists( variableName: Variable['name'], - projectId: Project['id'] + project: Project ) { if ( (await this.prisma.variable.findFirst({ where: { name: variableName, - projectId + projectId: project.id } })) !== null ) { throw new ConflictException( - `Variable already exists: ${variableName} in project ${projectId}` + `Variable already exists: ${variableName} in project ${project.slug}` ) } } diff --git a/apps/api/src/variable/variable.e2e.spec.ts b/apps/api/src/variable/variable.e2e.spec.ts index 1285e09b..a74c6d4a 100644 --- a/apps/api/src/variable/variable.e2e.spec.ts +++ b/apps/api/src/variable/variable.e2e.spec.ts @@ -28,7 +28,6 @@ import { VariableModule } from './variable.module' import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { EnvironmentService } from '@/environment/service/environment.service' -import fetchEvents from '@/common/fetch-events' import { VariableService } from './service/variable.service' import { EventService } from '@/event/service/event.service' import { REDIS_CLIENT } from '@/provider/redis.provider' @@ -36,7 +35,8 @@ import { mockDeep } from 'jest-mock-extended' import { RedisClientType } from 'redis' import { UserService } from '@/user/service/user.service' import { UserModule } from '@/user/user.module' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' describe('Variable Controller Tests', () => { let app: NestFastifyApplication @@ -111,7 +111,7 @@ describe('Variable Controller Tests', () => { user1 = createUser1 user2 = createUser2 - project1 = (await projectService.createProject(user1, workspace1.id, { + project1 = (await projectService.createProject(user1, workspace1.slug, { name: 'Project 1', description: 'Project 1 description', storePrivateKey: true, @@ -148,12 +148,12 @@ describe('Variable Controller Tests', () => { name: 'Variable 1', entries: [ { - environmentId: environment1.id, + environmentSlug: environment1.slug, value: 'Variable 1 value' } ] }, - project1.id + project1.slug )) as Variable }) @@ -172,676 +172,689 @@ describe('Variable Controller Tests', () => { expect(environmentService).toBeDefined() }) - it('should be able to create a variable', async () => { - const response = await app.inject({ - method: 'POST', - url: `/variable/${project1.id}`, - payload: { - name: 'Variable 3', - note: 'Variable 3 note', - rotateAfter: '24', - entries: [ - { - value: 'Variable 3 value', - environmentId: environment2.id - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(201) + describe('Create Variable Tests', () => { + it('should be able to create a variable', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.slug}`, + payload: { + name: 'Variable 3', + note: 'Variable 3 note', + rotateAfter: '24', + entries: [ + { + value: 'Variable 3 value', + environmentId: environment2.id + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const body = response.json() + expect(response.statusCode).toBe(201) - expect(body).toBeDefined() - expect(body.name).toBe('Variable 3') - expect(body.note).toBe('Variable 3 note') - expect(body.projectId).toBe(project1.id) - expect(body.versions.length).toBe(1) - expect(body.versions[0].value).toBe('Variable 3 value') + const body = response.json() - const variable = await prisma.variable.findUnique({ - where: { - id: body.id - } - }) + expect(body).toBeDefined() + expect(body.name).toBe('Variable 3') + expect(body.slug).toBeDefined() + expect(body.note).toBe('Variable 3 note') + expect(body.projectId).toBe(project1.id) + expect(body.versions.length).toBe(1) + expect(body.versions[0].value).toBe('Variable 3 value') - expect(variable).toBeDefined() - }) + const variable = await prisma.variable.findUnique({ + where: { + id: body.id + } + }) - it('should have created a variable version', async () => { - const variableVersion = await prisma.variableVersion.findFirst({ - where: { - variableId: variable1.id - } + expect(variable).toBeDefined() }) - expect(variableVersion).toBeDefined() - expect(variableVersion.value).toBe('Variable 1 value') - expect(variableVersion.version).toBe(1) - }) + 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 not be able to create a variable with a non-existing environment', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.slug}`, + payload: { + name: 'Variable 3', + rotateAfter: '24', + entries: [ + { + value: 'Variable 3 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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: { - name: 'Variable 3', - rotateAfter: '24', - entries: [ - { - value: 'Variable 3 value', - environmentId: 'non-existing-environment-id' - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) }) - 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.slug}`, + payload: { + name: 'Variable 3', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user2.email + } + }) - 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', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to create a duplicate variable in the same project', async () => { + const response = await app.inject({ + method: 'POST', + url: `/variable/${project1.slug}`, + payload: { + name: 'Variable 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to create a duplicate variable in the same project', async () => { - const response = await app.inject({ - method: 'POST', - url: `/variable/${project1.id}`, - payload: { - name: 'Variable 1', - 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 project ${project1.slug}` + ) }) - expect(response.statusCode).toBe(409) - expect(response.json().message).toEqual( - `Variable already exists: Variable 1 in project ${project1.id}` - ) - }) - - it('should have created a VARIABLE_ADDED event', async () => { - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.VARIABLE - ) + it('should have created a VARIABLE_ADDED event', async () => { + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.VARIABLE + ) - const event = response.items[0] + const event = response.items[0] - expect(event.source).toBe(EventSource.VARIABLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.VARIABLE_ADDED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) - - 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', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(event.source).toBe(EventSource.VARIABLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.VARIABLE_ADDED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) - 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 project', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}`, - payload: { - name: 'Variable 1', - rotateAfter: '24' - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + it('should not create a variable version entity if value-environmentSlug is not provided during creation', async () => { + const variable = await variableService.createVariable( + user1, + { + name: 'Var 3', + note: 'Var 3 note' + }, + project1.slug + ) - expect(response.statusCode).toBe(409) - expect(response.json().message).toEqual( - `Variable already exists: Variable 1 in project ${project1.id}` - ) - }) + const variableVersions = await prisma.variableVersion.findMany({ + where: { + variableId: variable.id + } + }) - it('should be able to update the variable name and note without creating a new version', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}`, - payload: { - name: 'Updated Variable 1', - note: 'Updated Variable 1 note' - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(variableVersions.length).toBe(0) }) + }) - expect(response.statusCode).toBe(200) - expect(response.json().variable.name).toEqual('Updated Variable 1') - expect(response.json().variable.note).toEqual('Updated Variable 1 note') - expect(response.json().updatedVersions.length).toEqual(0) + describe('Update Variable Tests', () => { + it('should not be able to update a non-existing variable', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/non-existing-variable-slug`, + payload: { + name: 'Updated Variable 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const variableVersion = await prisma.variableVersion.findMany({ - where: { - variableId: variable1.id - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable non-existing-variable-slug not found' + ) }) - expect(variableVersion.length).toBe(1) - }) + it('should not be able to update a variable with same name in the same project', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}`, + payload: { + name: 'Variable 1', + rotateAfter: '24' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should create a new version if the value is updated', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}`, - payload: { - entries: [ - { - value: 'Updated Variable 1 value', - environmentId: environment1.id - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(409) + expect(response.json().message).toEqual( + `Variable already exists: Variable 1 in project ${project1.slug}` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json().updatedVersions.length).toEqual(1) + it('should be able to update the variable name and note without creating a new version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}`, + payload: { + name: 'Updated Variable 1', + note: 'Updated Variable 1 note' + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const variableVersion = await prisma.variableVersion.findMany({ - where: { - variableId: variable1.id, - environmentId: environment1.id - } - }) + expect(response.statusCode).toBe(200) + expect(response.json().variable.name).toEqual('Updated Variable 1') + expect(response.json().variable.note).toEqual('Updated Variable 1 note') + expect(response.json().slug).not.toBe(variable1.slug) + expect(response.json().updatedVersions.length).toEqual(0) - expect(variableVersion.length).toBe(2) - }) + const variableVersion = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) + + expect(variableVersion.length).toBe(1) + }) + + it('should create a new version if the value is updated', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}`, + payload: { + entries: [ + { + value: 'Updated Variable 1 value', + environmentSlug: environment1.slug + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should fail to create a new version if the environment does not exist', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}`, - payload: { - entries: [ - { - value: 'Updated Variable 1 value', - environmentId: 'non-existing-environment-id' - } - ] - }, - headers: { - 'x-e2e-user-email': user1.email - } - }) + expect(response.statusCode).toBe(200) + expect(response.json().updatedVersions.length).toEqual(1) - expect(response.statusCode).toBe(404) - }) + const variableVersion = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id, + environmentId: environment1.id + } + }) + + expect(variableVersion.length).toBe(2) + }) + + it('should fail to create a new version if the environment does not exist', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}`, + payload: { + entries: [ + { + value: 'Updated Variable 1 value', + environmentSlug: 'non-existing-environment-slug' + } + ] + }, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should have created a VARIABLE_UPDATED event', async () => { - // Update a variable - await variableService.updateVariable(user1, variable1.id, { - name: 'Updated Variable 1' + expect(response.statusCode).toBe(404) }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.VARIABLE - ) + it('should have created a VARIABLE_UPDATED event', async () => { + // Update a variable + await variableService.updateVariable(user1, variable1.slug, { + name: 'Updated Variable 1' + }) - const event = response.items[0] + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.VARIABLE + ) - expect(event.source).toBe(EventSource.VARIABLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.VARIABLE_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + const event = response.items[0] - 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?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(event.source).toBe(EventSource.VARIABLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.VARIABLE_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) - - 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?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } - }) - - expect(response.statusCode).toBe(401) - }) + describe('Rollback Tests', () => { + 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-slug/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable non-existing-variable-slug not found' + ) }) - 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, { - entries: [ - { - value: 'Updated Variable 1 value', - environmentId: environment1.id + 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.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email } - ] + }) + + expect(response.statusCode).toBe(401) }) - await variableService.updateVariable(user1, variable1.id, { - entries: [ - { - value: 'Updated Variable 1 value 2', - environmentId: environment1.id + it('should not be able to roll back to a non-existing version', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}/rollback/2?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email } - ] + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `Invalid rollback version: 2 for variable: ${variable1.slug}` + ) }) - let versions: VariableVersion[] + it('should be able to roll back a variable', async () => { + // Creating a few versions first + await variableService.updateVariable(user1, variable1.slug, { + entries: [ + { + value: 'Updated Variable 1 value', + environmentSlug: environment1.slug + } + ] + }) - versions = await prisma.variableVersion.findMany({ - where: { - variableId: variable1.id - } - }) + await variableService.updateVariable(user1, variable1.slug, { + entries: [ + { + value: 'Updated Variable 1 value 2', + environmentSlug: environment1.slug + } + ] + }) - expect(versions.length).toBe(3) + let versions: VariableVersion[] - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + versions = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) - expect(response.statusCode).toBe(200) - expect(response.json().count).toEqual(2) + expect(versions.length).toBe(3) - versions = await prisma.variableVersion.findMany({ - where: { - variableId: variable1.id - } - }) + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - expect(versions.length).toBe(1) - }) + expect(response.statusCode).toBe(200) + expect(response.json().count).toEqual(2) - it('should not be able to roll back if the variable has no versions', async () => { - await prisma.variableVersion.deleteMany({ - where: { - variableId: variable1.id - } - }) + versions = await prisma.variableVersion.findMany({ + where: { + variableId: variable1.id + } + }) - const response = await app.inject({ - method: 'PUT', - url: `/variable/${variable1.id}/rollback/1?environmentId=${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(versions.length).toBe(1) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `No versions found for environment: ${environment1.id} for variable: ${variable1.id}` - ) - }) + it('should not be able to roll back if the variable has no versions', async () => { + await prisma.variableVersion.deleteMany({ + where: { + variableId: variable1.id + } + }) - it('should not create a variable version entity if value-environmentId is not provided during creation', async () => { - const variable = await variableService.createVariable( - user1, - { - name: 'Var 3', - note: 'Var 3 note' - }, - project1.id - ) + const response = await app.inject({ + method: 'PUT', + url: `/variable/${variable1.slug}/rollback/1?environmentSlug=${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const variableVersions = await prisma.variableVersion.findMany({ - where: { - variableId: variable.id - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + `No versions found for environment: ${environment1.slug} for variable: ${variable1.slug}` + ) }) - - expect(variableVersions.length).toBe(0) }) - it('should be able to fetch all variables', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/${project1.id}?page=0&limit=10`, - headers: { - 'x-e2e-user-email': user1.email - } - }) - - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toBe(1) + describe('Get All Variables Of Project Tests', () => { + it('should be able to fetch all variables', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}?page=0&limit=10`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toBe(1) + + const { variable, values } = response.json().items[0] + expect(variable).toBeDefined() + expect(values).toBeDefined() + expect(values.length).toBe(1) + expect(values[0].value).toBe('Variable 1 value') + expect(values[0].environment.id).toBe(environment1.id) + expect(variable.id).toBe(variable1.id) + expect(variable.name).toBe('Variable 1') + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/variable/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/variable/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/variable/${project1.slug}?page=0&limit=10&sort=name&order=asc&search=` + ) + }) + + 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/${project1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - const { variable, values } = response.json().items[0] - expect(variable).toBeDefined() - expect(values).toBeDefined() - expect(values.length).toBe(1) - expect(values[0].value).toBe('Variable 1 value') - expect(values[0].environment.id).toBe(environment1.id) - expect(variable.id).toBe(variable1.id) - expect(variable.name).toBe('Variable 1') + expect(response.statusCode).toBe(401) + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/variable/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/variable/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/variable/${project1.id}?page=0&limit=10&sort=name&order=asc&search=` - ) - }) + 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/non-existing-project-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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/${project1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project non-existing-project-slug not found' + ) }) - - expect(response.statusCode).toBe(401) }) - 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/non-existing-project-id`, - headers: { - 'x-e2e-user-email': user1.email - } - }) + describe('Get All Variables By Project And Environment Tests', () => { + it('should be able to fetch all variables by project and environment', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/${environment1.slug}`, + 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' - ) - }) + expect(response.statusCode).toBe(200) + expect(response.json().length).toBe(1) - it('should be able to fetch all variables by project and environment', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/${project1.id}/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + const variable = response.json()[0] + expect(variable.name).toBe('Variable 1') + expect(variable.value).toBe('Variable 1 value') + expect(variable.isPlaintext).toBe(true) }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(1) - - const variable = response.json()[0] - expect(variable.name).toBe('Variable 1') - expect(variable.value).toBe('Variable 1 value') - expect(variable.isPlaintext).toBe(true) - }) + it('should not be able to fetch all variables by project and environment if the user has no access to the project', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('should not be able to fetch all variables by project and environment if the user has no access to the project', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/${project1.id}/${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to fetch all variables by project and environment if the project does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/non-existing-project-slug/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all variables by project and environment if the project does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/non-existing-project-id/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Project non-existing-project-slug not found' + ) }) - 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 fetch all variables by project and environment if the environment does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${project1.slug}/non-existing-environment-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should not be able to fetch all variables by project and environment if the environment does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/${project1.id}/non-existing-environment-id`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) }) - - expect(response.statusCode).toBe(404) }) - 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 - } + describe('Delete Variable Tests', () => { + it('should not be able to delete a non-existing variable', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/variable/non-existing-variable-slug`, + headers: { + 'x-e2e-user-email': user1.email + } + }) + + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual( + 'Variable non-existing-variable-slug not found' + ) }) - 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.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - 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.statusCode).toBe(401) - }) + it('should be able to delete a variable', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/variable/${variable1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - 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) }) - expect(response.statusCode).toBe(200) - }) - - it('should have created a VARIABLE_DELETED event', async () => { - // Delete a variable - await variableService.deleteVariable(user1, variable1.id) + it('should have created a VARIABLE_DELETED event', async () => { + // Delete a variable + await variableService.deleteVariable(user1, variable1.slug) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.VARIABLE - ) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.VARIABLE + ) - const event = response.items[0] + const event = response.items[0] - expect(event.source).toBe(EventSource.VARIABLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.VARIABLE_DELETED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() + expect(event.source).toBe(EventSource.VARIABLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.VARIABLE_DELETED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) }) - //revisions test - it('should be able to fetch all revisions of variables', async () => { - // create two more entries,totalling three versions - // checks if its able to fetch multiple revisions - await variableService.updateVariable(user1, variable1.id, { - entries: [ - { - value: 'Updated Variable 1 value', - environmentId: environment1.id - } - ] - }) + describe('Revision Tests', () => { + it('should be able to fetch all revisions of variables', async () => { + // create two more entries,totalling three versions + // checks if its able to fetch multiple revisions + await variableService.updateVariable(user1, variable1.slug, { + entries: [ + { + value: 'Updated Variable 1 value', + environmentSlug: environment1.slug + } + ] + }) - await variableService.updateVariable(user1, variable1.id, { - entries: [ - { - value: 'Updated variable 1 value 2', - environmentId: environment1.id + await variableService.updateVariable(user1, variable1.slug, { + entries: [ + { + value: 'Updated variable 1 value 2', + environmentSlug: environment1.slug + } + ] + }) + + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email } - ] - }) + }) - const response = await app.inject({ - method: 'GET', - url: `/variable/${variable1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(3) }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(3) - }) + it('should return [] if the variable has no revision', async () => { + //returns [] if variable has no revision + await prisma.variableVersion.deleteMany({ + where: { + variableId: variable1.id + } + }) - it('should return [] if the variable has no revision', async () => { - //returns [] if variable has no revision - await prisma.variableVersion.deleteMany({ - where: { - variableId: variable1.id - } - }) + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - const response = await app.inject({ - method: 'GET', - url: `/variable/${variable1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(0) }) - expect(response.statusCode).toBe(200) - expect(response.json().length).toBe(0) - }) + it('should return error if variable doesnt exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/9999/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should return error if variable doesnt exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/9999/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual(`Variable 9999 not found`) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual(`Variable with id 9999 not found`) - }) + it('should return error if environment does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.slug}/revisions/9999`, + headers: { + 'x-e2e-user-email': user1.email + } + }) - it('should return error if environment does not exist', async () => { - const response = await app.inject({ - method: 'GET', - url: `/variable/${variable1.id}/revisions/9999`, - headers: { - 'x-e2e-user-email': user1.email - } + expect(response.statusCode).toBe(404) + expect(response.json().message).toEqual(`Environment 9999 not found`) }) - expect(response.statusCode).toBe(404) - expect(response.json().message).toEqual( - `Environment with id 9999 not found` - ) - }) + it('returns error if variable is not accessible', async () => { + //return error if user has no access to variable + const response = await app.inject({ + method: 'GET', + url: `/variable/${variable1.slug}/revisions/${environment1.slug}`, + headers: { + 'x-e2e-user-email': user2.email + } + }) - it('returns error if variable is not accessible', async () => { - //return error if user has no access to variable - const response = await app.inject({ - method: 'GET', - url: `/variable/${variable1.id}/revisions/${environment1.id}`, - headers: { - 'x-e2e-user-email': user2.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(401) }) }) diff --git a/apps/api/src/workspace-role/controller/workspace-role.controller.ts b/apps/api/src/workspace-role/controller/workspace-role.controller.ts index 20ccc5f0..478ba424 100644 --- a/apps/api/src/workspace-role/controller/workspace-role.controller.ts +++ b/apps/api/src/workspace-role/controller/workspace-role.controller.ts @@ -19,79 +19,79 @@ import { RequiredApiKeyAuthorities } from '@/decorators/required-api-key-authori export class WorkspaceRoleController { constructor(private readonly workspaceRoleService: WorkspaceRoleService) {} - @Post(':workspaceId') + @Post(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.CREATE_WORKSPACE_ROLE) async createWorkspaceRole( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() dto: CreateWorkspaceRole ) { return await this.workspaceRoleService.createWorkspaceRole( user, - workspaceId, + workspaceSlug, dto ) } - @Put(':workspaceRoleId') + @Put(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_WORKSPACE_ROLE) async updateWorkspaceRole( @CurrentUser() user: User, - @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'], + @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'], @Body() dto: UpdateWorkspaceRole ) { return await this.workspaceRoleService.updateWorkspaceRole( user, - workspaceRoleId, + workspaceRoleSlug, dto ) } - @Delete(':workspaceRoleId') + @Delete(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.DELETE_WORKSPACE_ROLE) async deleteWorkspaceRole( @CurrentUser() user: User, - @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'] + @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'] ) { return await this.workspaceRoleService.deleteWorkspaceRole( user, - workspaceRoleId + workspaceRoleSlug ) } - @Get(':workspaceId/exists/:workspaceRoleName') + @Get(':workspaceSlug/exists/:workspaceRoleName') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async checkWorkspaceRoleExists( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('workspaceRoleName') name: WorkspaceRole['name'] ) { return { exists: await this.workspaceRoleService.checkWorkspaceRoleExists( user, - workspaceId, + workspaceSlug, name ) } } - @Get(':workspaceRoleId') + @Get(':workspaceRoleSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async getWorkspaceRole( @CurrentUser() user: User, - @Param('workspaceRoleId') workspaceRoleId: WorkspaceRole['id'] + @Param('workspaceRoleSlug') workspaceRoleSlug: WorkspaceRole['slug'] ) { return await this.workspaceRoleService.getWorkspaceRole( user, - workspaceRoleId + workspaceRoleSlug ) } - @Get(':workspaceId/all') + @Get(':workspaceSlug/all') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE_ROLE) async getAllWorkspaceRolesOfWorkspace( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -100,7 +100,7 @@ export class WorkspaceRoleController { ) { return await this.workspaceRoleService.getWorkspaceRolesOfWorkspace( user, - workspaceId, + workspaceSlug, page, limit, sort, diff --git a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts index c7bfa14f..2e51a4ea 100644 --- a/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts +++ b/apps/api/src/workspace-role/dto/create-workspace-role/create-workspace-role.ts @@ -19,5 +19,5 @@ export class CreateWorkspaceRole { @IsArray() @IsOptional() - readonly projectIds?: string[] + readonly projectSlugs?: string[] } diff --git a/apps/api/src/workspace-role/service/workspace-role.service.ts b/apps/api/src/workspace-role/service/workspace-role.service.ts index 020729f7..2b5367b7 100644 --- a/apps/api/src/workspace-role/service/workspace-role.service.ts +++ b/apps/api/src/workspace-role/service/workspace-role.service.ts @@ -15,15 +15,16 @@ import { WorkspaceRole } from '@prisma/client' import { CreateWorkspaceRole } from '../dto/create-workspace-role/create-workspace-role' -import getCollectiveWorkspaceAuthorities from '@/common/get-collective-workspace-authorities' import { UpdateWorkspaceRole } from '../dto/update-workspace-role/update-workspace-role' import { PrismaService } from '@/prisma/prisma.service' -import createEvent from '@/common/create-event' import { WorkspaceRoleWithProjects } from '../workspace-role.types' import { v4 } from 'uuid' import { AuthorityCheckerService } from '@/common/authority-checker.service' import { paginate, PaginatedMetadata } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import generateEntitySlug from '@/common/slug-generator' +import { createEvent } from '@/common/event' +import { getCollectiveWorkspaceAuthorities } from '@/common/collective-authorities' +import { limitMaxItemsPerPage } from '@/common/util' @Injectable() export class WorkspaceRoleService { @@ -34,9 +35,18 @@ export class WorkspaceRoleService { private readonly authorityCheckerService: AuthorityCheckerService ) {} + /** + * Creates a new workspace role + * @throws {BadRequestException} if the role has workspace admin authority + * @throws {ConflictException} if a workspace role with the same name already exists + * @param user the user that is creating the workspace role + * @param workspaceSlug the slug of the workspace + * @param dto the data for the new workspace role + * @returns the newly created workspace role + */ async createWorkspaceRole( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], dto: CreateWorkspaceRole ) { if ( @@ -51,12 +61,13 @@ export class WorkspaceRoleService { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.CREATE_WORKSPACE_ROLE], prisma: this.prisma }) + const workspaceId = workspace.id - if (await this.checkWorkspaceRoleExists(user, workspaceId, dto.name)) { + if (await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) { throw new ConflictException( 'Workspace role with the same name already exists' ) @@ -72,6 +83,7 @@ export class WorkspaceRoleService { data: { id: workspaceRoleId, name: dto.name, + slug: await generateEntitySlug(dto.name, 'API_KEY', this.prisma), description: dto.description, colorCode: dto.colorCode, authorities: dto.authorities ?? [], @@ -93,12 +105,16 @@ export class WorkspaceRoleService { ) // Create the project associations - if (dto.projectIds && dto.projectIds.length > 0) { + const projectSlugToIdMap = await this.getProjectSlugToIdMap( + dto.projectSlugs + ) + + if (dto.projectSlugs && dto.projectSlugs.length > 0) { op.push( this.prisma.projectWorkspaceRoleAssociation.createMany({ - data: dto.projectIds.map((projectId) => ({ + data: dto.projectSlugs.map((projectSlug) => ({ roleId: workspaceRoleId, - projectId + projectId: projectSlugToIdMap.get(projectSlug) })) }) ) @@ -125,15 +141,24 @@ export class WorkspaceRoleService { ) this.logger.log( - `User with id ${user.id} created workspace role with id ${workspaceRole.id}` + `${user.email} created workspace role ${workspaceRole.slug}` ) return workspaceRole } + /** + * Updates a workspace role + * @throws {BadRequestException} if the role has workspace admin authority + * @throws {ConflictException} if a workspace role with the same name already exists + * @param user the user that is updating the workspace role + * @param workspaceRoleSlug the slug of the workspace role to be updated + * @param dto the data for the updated workspace role + * @returns the updated workspace role + */ async updateWorkspaceRole( user: User, - workspaceRoleId: WorkspaceRole['id'], + workspaceRoleSlug: WorkspaceRole['slug'], dto: UpdateWorkspaceRole ) { if ( @@ -145,19 +170,25 @@ export class WorkspaceRoleService { ) } - let workspaceRole = (await this.getWorkspaceRoleWithAuthority( + const workspaceRole = (await this.getWorkspaceRoleWithAuthority( user.id, - workspaceRoleId, + workspaceRoleSlug, Authority.UPDATE_WORKSPACE_ROLE )) as WorkspaceRoleWithProjects + const workspaceRoleId = workspaceRole.id + + const { slug: workspaceSlug } = await this.prisma.workspace.findUnique({ + where: { + id: workspaceRole.workspaceId + }, + select: { + slug: true + } + }) if ( dto.name && - ((await this.checkWorkspaceRoleExists( - user, - workspaceRole.workspaceId, - dto.name - )) || + ((await this.checkWorkspaceRoleExists(user, workspaceSlug, dto.name)) || dto.name === workspaceRole.name) ) { throw new ConflictException( @@ -165,22 +196,26 @@ export class WorkspaceRoleService { ) } - if (dto.projectIds) { + if (dto.projectSlugs) { await this.prisma.projectWorkspaceRoleAssociation.deleteMany({ where: { roleId: workspaceRoleId } }) + const projectSlugToIdMap = await this.getProjectSlugToIdMap( + dto.projectSlugs + ) + await this.prisma.projectWorkspaceRoleAssociation.createMany({ - data: dto.projectIds.map((projectId) => ({ + data: dto.projectSlugs.map((projectSlug) => ({ roleId: workspaceRoleId, - projectId + projectId: projectSlugToIdMap.get(projectSlug) })) }) } - workspaceRole = await this.prisma.workspaceRole.update({ + const updatedWorkspaceRole = await this.prisma.workspaceRole.update({ where: { id: workspaceRoleId }, @@ -216,19 +251,27 @@ export class WorkspaceRoleService { this.prisma ) - this.logger.log( - `User with id ${user.id} updated workspace role with id ${workspaceRoleId}` - ) + this.logger.log(`${user.email} updated workspace role ${workspaceRoleSlug}`) - return workspaceRole + return updatedWorkspaceRole } - async deleteWorkspaceRole(user: User, workspaceRoleId: WorkspaceRole['id']) { + /** + * Deletes a workspace role + * @throws {UnauthorizedException} if the role has administrative authority + * @param user the user that is deleting the workspace role + * @param workspaceRoleSlug the slug of the workspace role to be deleted + */ + async deleteWorkspaceRole( + user: User, + workspaceRoleSlug: WorkspaceRole['slug'] + ) { const workspaceRole = await this.getWorkspaceRoleWithAuthority( user.id, - workspaceRoleId, + workspaceRoleSlug, Authority.DELETE_WORKSPACE_ROLE ) + const workspaceRoleId = workspaceRole.id if (workspaceRole.hasAdminAuthority) { throw new UnauthorizedException( @@ -259,22 +302,30 @@ export class WorkspaceRoleService { this.prisma ) - this.logger.log( - `User with id ${user.id} deleted workspace role with id ${workspaceRoleId}` - ) + this.logger.log(`${user.email} deleted workspace role ${workspaceRoleSlug}`) } + /** + * Checks if a workspace role with the given name exists + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the check + * @param workspaceSlug the slug of the workspace + * @param name the name of the workspace role to check + * @returns true if a workspace role with the given name exists, false otherwise + */ async checkWorkspaceRoleExists( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], name: string ) { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_WORKSPACE_ROLE], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE_ROLE], + prisma: this.prisma + }) + const workspaceId = workspace.id return ( (await this.prisma.workspaceRole.count({ @@ -286,32 +337,52 @@ export class WorkspaceRoleService { ) } + /** + * Gets a workspace role by its slug + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the request + * @param workspaceRoleSlug the slug of the workspace role to get + * @returns the workspace role with the given slug + */ async getWorkspaceRole( user: User, - workspaceRoleId: WorkspaceRole['id'] + workspaceRoleSlug: WorkspaceRole['slug'] ): Promise { return await this.getWorkspaceRoleWithAuthority( user.id, - workspaceRoleId, + workspaceRoleSlug, Authority.READ_WORKSPACE_ROLE ) } + /** + * Gets all workspace roles of a workspace, with pagination and optional filtering by name + * @throws {UnauthorizedException} if the user does not have the required authority + * @param user the user performing the request + * @param workspaceSlug the slug of the workspace + * @param page the page to get (0-indexed) + * @param limit the maximum number of items to return + * @param sort the field to sort the results by (e.g. "name", "slug", etc.) + * @param order the order to sort the results in (e.g. "asc", "desc") + * @param search an optional search string to filter the results by + * @returns a PaginatedMetadata object containing the items and metadata + */ async getWorkspaceRolesOfWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], page: number, limit: number, sort: string, order: string, search: string ): Promise<{ items: WorkspaceRole[]; metadata: PaginatedMetadata }> { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_WORKSPACE_ROLE], - prisma: this.prisma - }) + const { id: workspaceId } = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_WORKSPACE_ROLE], + prisma: this.prisma + }) //get workspace roles of a workspace for given page and limit const items = await this.prisma.workspaceRole.findMany({ where: { @@ -340,7 +411,7 @@ export class WorkspaceRoleService { const metadata = paginate( totalCount, - `/workspace-role/${workspaceId}/all`, + `/workspace-role/${workspaceSlug}/all`, { page, limit: limitMaxItemsPerPage(limit), @@ -353,14 +424,23 @@ export class WorkspaceRoleService { return { items, metadata } } + /** + * Gets a workspace role by its slug, with additional authorities check + * @throws {NotFoundException} if the workspace role does not exist + * @throws {UnauthorizedException} if the user does not have the required authority + * @param userId the user that is performing the request + * @param workspaceRoleSlug the slug of the workspace role to get + * @param authorities the authorities to check against + * @returns the workspace role with the given slug + */ private async getWorkspaceRoleWithAuthority( userId: User['id'], - workspaceRoleId: Workspace['id'], + workspaceRoleSlug: Workspace['slug'], authorities: Authority ) { const workspaceRole = (await this.prisma.workspaceRole.findUnique({ where: { - id: workspaceRoleId + slug: workspaceRoleSlug }, include: { projects: true @@ -369,7 +449,7 @@ export class WorkspaceRoleService { if (!workspaceRole) { throw new NotFoundException( - `Workspace role with id ${workspaceRoleId} not found` + `Workspace role ${workspaceRoleSlug} not found` ) } @@ -390,4 +470,23 @@ export class WorkspaceRoleService { return workspaceRole } + + /** + * Given an array of project slugs, returns a Map of slug to id for all projects + * found in the database. + * + * @param projectSlugs the array of project slugs + * @returns a Map of project slug to id + */ + private async getProjectSlugToIdMap(projectSlugs: string[]) { + const projects = await this.prisma.project.findMany({ + where: { + slug: { + in: projectSlugs + } + } + }) + + return new Map(projects.map((project) => [project.slug, project.id])) + } } diff --git a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts index f779c6fe..e3e89ef2 100644 --- a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -20,13 +20,13 @@ import { MAIL_SERVICE } from '@/mail/services/interface.service' import { MockMailService } from '@/mail/services/mock.service' import { Test } from '@nestjs/testing' import { v4 } from 'uuid' -import fetchEvents from '@/common/fetch-events' import { EventService } from '@/event/service/event.service' import { EventModule } from '@/event/event.module' import { WorkspaceRoleService } from './service/workspace-role.service' import { UserService } from '@/user/service/user.service' import { UserModule } from '@/user/user.module' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' +import { fetchEvents } from '@/common/event' describe('Workspace Role Controller Tests', () => { let app: NestFastifyApplication @@ -116,7 +116,7 @@ describe('Workspace Role Controller Tests', () => { } }) - await workspaceRoleService.createWorkspaceRole(alice, workspaceAlice.id, { + await workspaceRoleService.createWorkspaceRole(alice, workspaceAlice.slug, { name: 'Member', description: 'Member Role', colorCode: '#0000FF', @@ -155,6 +155,7 @@ describe('Workspace Role Controller Tests', () => { prisma.project.create({ data: { name: 'Project 1', + slug: 'project-1', description: 'Project 1 Description', workspaceId: workspaceAlice.id, publicKey: v4() @@ -163,6 +164,7 @@ describe('Workspace Role Controller Tests', () => { prisma.project.create({ data: { name: 'Project 2', + slug: 'project-2', description: 'Project 2 Description', workspaceId: workspaceAlice.id, publicKey: v4() @@ -186,149 +188,190 @@ describe('Workspace Role Controller Tests', () => { expect(userService).toBeDefined() }) - it('should be able to get the auto generated admin role', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': alice.email - }, - url: `/workspace-role/${adminRole1.id}` - }) - - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - ...adminRole1, - createdAt: expect.any(String), - projects: [], - updatedAt: expect.any(String) - }) - }) + describe('Auto Generated Admin Role Tests', () => { + it('should be able to get the auto generated admin role', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': alice.email + }, + url: `/workspace-role/${adminRole1.slug}` + }) - it('should not be able to get the auto generated admin role of other workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': alice.email - }, - url: `/workspace-role/${adminRole2.id}` + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + ...adminRole1, + createdAt: expect.any(String), + projects: [], + updatedAt: expect.any(String) + }) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to get the auto generated admin role of other workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': alice.email + }, + url: `/workspace-role/${adminRole2.slug}` + }) - it('should be able to create workspace role', async () => { - const response = await app.inject({ - method: 'POST', - url: `/workspace-role/${workspaceAlice.id}`, - payload: { - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(401) }) + }) - expect(response.statusCode).toBe(201) - expect(response.json()).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE], - workspaceId: workspaceAlice.id, - projects: [] + describe('Create Workspace Role Tests', () => { + it('should be able to create workspace role', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.slug}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } }) - ) - }) - it('should have created a WORKSPACE_ROLE_CREATED event', async () => { - const response = await fetchEvents( - eventService, - alice, - workspaceAlice.id, - EventSource.WORKSPACE_ROLE - ) + expect(response.statusCode).toBe(201) + expect(response.json()).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ], + workspaceId: workspaceAlice.id, + projects: [] + }) + ) + }) - const event = response.items[0] + it('should have created a WORKSPACE_ROLE_CREATED event', async () => { + const response = await fetchEvents( + eventService, + alice, + workspaceAlice.slug, + EventSource.WORKSPACE_ROLE + ) - expect(event.source).toBe(EventSource.WORKSPACE_ROLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_ROLE_CREATED) - expect(event.workspaceId).toBe(workspaceAlice.id) - expect(event.itemId).toBeDefined() - }) + const event = response.items[0] + + expect(event.source).toBe(EventSource.WORKSPACE_ROLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_ROLE_CREATED) + expect(event.workspaceId).toBe(workspaceAlice.id) + expect(event.itemId).toBeDefined() + }) + + it('should not be able to create a workspace role for other workspace', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceBob.slug}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should not be able to create a workspace role for other workspace', async () => { - const response = await app.inject({ - method: 'POST', - url: `/workspace-role/${workspaceBob.id}`, - payload: { - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to create a workspace role with WORKSPACE_ADMIN authority', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.slug}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.WORKSPACE_ADMIN] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should not be able to create a workspace role with WORKSPACE_ADMIN authority', async () => { - const response = await app.inject({ - method: 'POST', - url: `/workspace-role/${workspaceAlice.id}`, - payload: { - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.WORKSPACE_ADMIN] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(400) }) - expect(response.statusCode).toBe(400) - }) + it('should not be able to create a workspace role with the same name', async () => { + // Create a role with the same name + await workspaceRoleService.createWorkspaceRole( + alice, + workspaceAlice.slug, + { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + } + ) - it('should not be able to create a workspace role with the same name', async () => { - // Create a role with the same name - await workspaceRoleService.createWorkspaceRole(alice, workspaceAlice.id, { - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }) + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.slug}`, + payload: { + name: 'Test Role', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - const response = await app.inject({ - method: 'POST', - url: `/workspace-role/${workspaceAlice.id}`, - payload: { - name: 'Test Role', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(409) }) - expect(response.statusCode).toBe(409) + it('should not be able to create workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'POST', + url: `/workspace-role/${workspaceAlice.slug}`, + payload: { + name: 'Test Role 2', + description: 'Test Role Description', + colorCode: '#0000FF', + authorities: [Authority.READ_WORKSPACE_ROLE] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) + + expect(response.statusCode).toBe(401) + }) }) it('should be able to read workspace role with READ_WORKSPACE_ROLE authority', async () => { const response = await app.inject({ method: 'GET', - url: `/workspace-role/${adminRole1.id}`, + url: `/workspace-role/${adminRole1.slug}`, headers: { 'x-e2e-user-email': charlie.email } @@ -343,594 +386,606 @@ describe('Workspace Role Controller Tests', () => { }) }) - it('should not be able to create workspace role with READ_WORKSPACE_ROLE authority', async () => { - const response = await app.inject({ - method: 'POST', - url: `/workspace-role/${workspaceAlice.id}`, - payload: { - name: 'Test Role 2', - description: 'Test Role Description', - colorCode: '#0000FF', - authorities: [Authority.READ_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': charlie.email - } - }) + describe('Update Workspace Role Tests', () => { + it('should only be able to update color code, name, description of admin authority role', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00' + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - expect(response.statusCode).toBe(401) - }) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: adminRole1.id, + name: 'Updated Admin', + slug: expect.any(String), + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [Authority.WORKSPACE_ADMIN], + workspaceId: workspaceAlice.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasAdminAuthority: true, + projects: [] + }) + }) - it('should only be able to update color code, name, description of admin authority role', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { + it('should have created a WORKSPACE_ROLE_UPDATED event', async () => { + // Update the workspace role + await workspaceRoleService.updateWorkspaceRole(alice, adminRole1.slug, { name: 'Updated Admin', description: 'Updated Description', colorCode: '#00FF00' - }, - headers: { - 'x-e2e-user-email': alice.email - } - }) + }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - id: adminRole1.id, - name: 'Updated Admin', - description: 'Updated Description', - colorCode: '#00FF00', - authorities: [Authority.WORKSPACE_ADMIN], - workspaceId: workspaceAlice.id, - createdAt: expect.any(String), - updatedAt: expect.any(String), - hasAdminAuthority: true, - projects: [] - }) + const response = await fetchEvents( + eventService, + alice, + workspaceAlice.slug, + EventSource.WORKSPACE_ROLE + ) - adminRole1 = response.json() - }) + const event = response.items[0] - it('should have created a WORKSPACE_ROLE_UPDATED event', async () => { - // Update the workspace role - await workspaceRoleService.updateWorkspaceRole(alice, adminRole1.id, { - name: 'Updated Admin', - description: 'Updated Description', - colorCode: '#00FF00' + expect(event.source).toBe(EventSource.WORKSPACE_ROLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_ROLE_UPDATED) + expect(event.workspaceId).toBe(workspaceAlice.id) + expect(event.itemId).toBeDefined() }) - const response = await fetchEvents( - eventService, - alice, - workspaceAlice.id, - EventSource.WORKSPACE_ROLE - ) - - const event = response.items[0] - - expect(event.source).toBe(EventSource.WORKSPACE_ROLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_ROLE_UPDATED) - expect(event.workspaceId).toBe(workspaceAlice.id) - expect(event.itemId).toBeDefined() - }) + it('should not be able to add WORKSPACE_ADMIN authority to the role', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + authorities: [Authority.WORKSPACE_ADMIN] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should not be able to add WORKSPACE_ADMIN authority to the role', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - authorities: [Authority.WORKSPACE_ADMIN] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(400) }) - expect(response.statusCode).toBe(400) - }) + it('should not be able to update workspace role of other workspace', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole2.slug}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should not be able to update workspace role of other workspace', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole2.id}`, - payload: { - name: 'Updated Admin', - description: 'Updated Description', - colorCode: '#00FF00', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to update workspace role with the same name', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + name: 'Admin', + description: 'Description', + colorCode: '#00FF00', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should not be able to update workspace role with the same name', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - name: 'Admin', - description: 'Description', - colorCode: '#00FF00', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(409) }) - expect(response.statusCode).toBe(409) - }) + it('should not be able to update the workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + name: 'Updated Admin', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - it('should not be able to update the workspace role with READ_WORKSPACE_ROLE authority', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - name: 'Updated Admin', - description: 'Updated Description', - colorCode: '#00FF00', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should be able to update the workspace role with UPDATE_WORKSPACE_ROLE authority', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } + }, + data: { + authorities: { + set: [ + Authority.UPDATE_WORKSPACE_ROLE, + Authority.READ_WORKSPACE_ROLE + ] + } + } + }) - it('should be able to update the workspace role with UPDATE_WORKSPACE_ROLE authority', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + slug: 'dummy-role', workspaceId: workspaceAlice.id, - name: 'Member' + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] } - }, - data: { - authorities: { - set: [Authority.UPDATE_WORKSPACE_ROLE, Authority.READ_WORKSPACE_ROLE] + }) + + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${dummyRole.slug}`, + payload: { + name: 'Updated Dummy Role', + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ] + }, + headers: { + 'x-e2e-user-email': charlie.email } - } - }) + }) - const dummyRole = await prisma.workspaceRole.create({ - data: { - name: 'Dummy Role', - workspaceId: workspaceAlice.id, - authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] - } - }) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual( + expect.objectContaining({ + id: dummyRole.id, + name: 'Updated Dummy Role', + slug: expect.any(String), + description: 'Updated Description', + colorCode: '#00FF00', + authorities: [ + Authority.CREATE_SECRET, + Authority.CREATE_WORKSPACE_ROLE + ], + workspaceId: workspaceAlice.id, + projects: [] + }) + ) - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${dummyRole.id}`, - payload: { - name: 'Updated Dummy Role', - description: 'Updated Description', - colorCode: '#00FF00', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE] - }, - headers: { - 'x-e2e-user-email': charlie.email - } + await prisma.workspaceRole.delete({ + where: { + id: dummyRole.id + } + }) }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual( - expect.objectContaining({ - id: dummyRole.id, - name: 'Updated Dummy Role', - description: 'Updated Description', - colorCode: '#00FF00', - authorities: [Authority.CREATE_SECRET, Authority.CREATE_WORKSPACE_ROLE], - workspaceId: workspaceAlice.id, - projects: [] + it('should be able to add projects to the role', async () => { + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + projectSlugs: projects.map((project) => project.slug) + }, + headers: { + 'x-e2e-user-email': alice.email + } }) - ) - await prisma.workspaceRole.delete({ - where: { - id: dummyRole.id - } - }) - }) - - it('should not be able to delete the workspace role with READ_WORKSPACE_ROLE authority', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/workspace-role/${adminRole1.id}`, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + ...adminRole1, + createdAt: expect.any(String), + updatedAt: expect.any(String), + projects: expect.arrayContaining([ + { + projectId: projects[0].id + }, + { + projectId: projects[1].id + } + ]) + }) }) - expect(response.statusCode).toBe(401) - }) - - it('should be able to delete the workspace role with DELETE_WORKSPACE_ROLE authority', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { - workspaceId: workspaceAlice.id, - name: 'Member' - } - }, - data: { - authorities: { - set: [Authority.DELETE_WORKSPACE_ROLE, Authority.READ_WORKSPACE_ROLE] + it('should be able to add projects to the role with UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } + }, + data: { + authorities: { + set: [ + Authority.UPDATE_WORKSPACE_ROLE, + Authority.READ_PROJECT, + Authority.READ_WORKSPACE_ROLE + ] + } } - } - }) + }) - const dummyRole = await prisma.workspaceRole.create({ - data: { - name: 'Dummy Role', - workspaceId: workspaceAlice.id, - authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] - } - }) + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + projectSlugs: projects.map((project) => project.slug) + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - const response = await app.inject({ - method: 'DELETE', - url: `/workspace-role/${dummyRole.id}`, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + ...adminRole1, + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasAdminAuthority: true, + projects: expect.arrayContaining([ + { + projectId: projects[0].id + }, + { + projectId: projects[1].id + } + ]) + }) }) - expect(response.statusCode).toBe(200) - }) + it('should not be able to add projects to the role without UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } + }, + data: { + authorities: { + set: [Authority.READ_WORKSPACE_ROLE] + } + } + }) - it('should be able to delete workspace role with WORKSPACE_ADMIN authority', async () => { - const dummyRole = await prisma.workspaceRole.create({ - data: { - name: 'Dummy Role', - workspaceId: workspaceAlice.id, - authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] - } - }) + const response = await app.inject({ + method: 'PUT', + url: `/workspace-role/${adminRole1.slug}`, + payload: { + projectIds: projects.map((project) => project.id) + }, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - const response = await app.inject({ - method: 'DELETE', - url: `/workspace-role/${dummyRole.id}`, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(200) }) - it('should have created a WORKSPACE_ROLE_DELETED event', async () => { - // Fetch the member role - const memberRole = await prisma.workspaceRole.findFirst({ - where: { - workspaceId: workspaceAlice.id, - name: 'Member' - } - }) + describe('Delete Workspace Role Tests', () => { + it('should not be able to delete the workspace role with READ_WORKSPACE_ROLE authority', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole1.slug}`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - // Delete the workspace role - await workspaceRoleService.deleteWorkspaceRole(alice, memberRole.id) + expect(response.statusCode).toBe(401) + }) - const response = await fetchEvents( - eventService, - alice, - workspaceAlice.id, - EventSource.WORKSPACE_ROLE - ) + it('should be able to delete the workspace role with DELETE_WORKSPACE_ROLE authority', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } + }, + data: { + authorities: { + set: [ + Authority.DELETE_WORKSPACE_ROLE, + Authority.READ_WORKSPACE_ROLE + ] + } + } + }) - const event = response.items[0] + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + slug: 'dummy-role', + workspaceId: workspaceAlice.id, + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] + } + }) - expect(event.source).toBe(EventSource.WORKSPACE_ROLE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_ROLE_DELETED) - expect(event.workspaceId).toBe(workspaceAlice.id) - expect(event.itemId).toBeDefined() - }) + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${dummyRole.slug}`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - it('should not be able to delete the auto generated admin role', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/workspace-role/${adminRole1.id}`, - headers: { - 'x-e2e-user-email': alice.email - } + expect(response.statusCode).toBe(200) }) - expect(response.statusCode).toBe(401) - }) - - it('should not be able to delete role of other workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - url: `/workspace-role/${adminRole2.id}`, - headers: { - 'x-e2e-user-email': alice.email - } - }) + it('should be able to delete workspace role with WORKSPACE_ADMIN authority', async () => { + const dummyRole = await prisma.workspaceRole.create({ + data: { + name: 'Dummy Role', + slug: 'dummy-role', + workspaceId: workspaceAlice.id, + authorities: [Authority.CREATE_API_KEY, Authority.CREATE_SECRET] + } + }) - expect(response.statusCode).toBe(401) - }) + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${dummyRole.slug}`, + headers: { + 'x-e2e-user-email': alice.email + } + }) - it('should be able to check if the workspace role exists', async () => { - const response = await app.inject({ - method: 'GET', - url: `/workspace-role/${workspaceAlice.id}/exists/Member`, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(200) }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - exists: true - }) - }) + it('should have created a WORKSPACE_ROLE_DELETED event', async () => { + // Fetch the member role + const memberRole = await prisma.workspaceRole.findFirst({ + where: { + workspaceId: workspaceAlice.id, + name: 'Member' + } + }) - it('should be able to check if the workspace role exists(2)', async () => { - const response = await app.inject({ - method: 'GET', - url: `/workspace-role/${workspaceAlice.id}/exists/new-stuff`, - headers: { - 'x-e2e-user-email': charlie.email - } - }) + // Delete the workspace role + await workspaceRoleService.deleteWorkspaceRole(alice, memberRole.slug) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - exists: false - }) - }) + const response = await fetchEvents( + eventService, + alice, + workspaceAlice.slug, + EventSource.WORKSPACE_ROLE + ) - it('should not be able to check if the workspace role exists for other workspace', async () => { - const response = await app.inject({ - method: 'GET', - url: `/workspace-role/${workspaceBob.id}/exists/Viewer`, - headers: { - 'x-e2e-user-email': charlie.email - } - }) + const event = response.items[0] - expect(response.statusCode).toBe(401) - }) + expect(event.source).toBe(EventSource.WORKSPACE_ROLE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_ROLE_DELETED) + expect(event.workspaceId).toBe(workspaceAlice.id) + expect(event.itemId).toBeDefined() + }) - it('should be able to fetch all the roles of a workspace with WORKSPACE_ADMIN role', async () => { - const roles = await prisma.workspaceRole - .findMany({ - where: { - workspaceId: workspaceAlice.id + it('should not be able to delete the auto generated admin role', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole1.slug}`, + headers: { + 'x-e2e-user-email': alice.email } }) - .then((roles) => - roles.map((role) => ({ - ...role, - createdAt: role.createdAt.toISOString(), - updatedAt: role.updatedAt.toISOString() - })) - ) - const response = await app.inject({ - method: 'GET', - url: `/workspace-role/${workspaceAlice.id}/all`, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(200) - expect(response.json().items).toEqual(expect.arrayContaining(roles)) + it('should not be able to delete role of other workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/workspace-role/${adminRole2.slug}`, + headers: { + 'x-e2e-user-email': alice.email + } + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toBe(roles.length) - expect(metadata.links.self).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) + expect(response.statusCode).toBe(401) + }) }) - it('should be able to fetch all the roles of a workspace with READ_WORKSPACE_ROLE role', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { - workspaceId: workspaceAlice.id, - name: 'Member' + describe('Check Workspace Role Exists Tests', () => { + it('should be able to check if the workspace role exists', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.slug}/exists/Member`, + headers: { + 'x-e2e-user-email': charlie.email } - }, - data: { - authorities: { - set: [Authority.READ_WORKSPACE_ROLE] - } - } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + exists: true + }) }) - const roles = await prisma.workspaceRole - .findMany({ - where: { - workspaceId: workspaceAlice.id + it('should be able to check if the workspace role exists(2)', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.slug}/exists/new-stuff`, + headers: { + 'x-e2e-user-email': charlie.email } }) - .then((roles) => - roles.map((role) => ({ - ...role, - createdAt: role.createdAt.toISOString(), - updatedAt: role.updatedAt.toISOString() - })) - ) - const response = await app.inject({ - method: 'GET', - url: `/workspace-role/${workspaceAlice.id}/all`, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + exists: false + }) }) - expect(response.statusCode).toBe(200) - expect(response.json().items).toEqual(expect.arrayContaining(roles)) + it('should not be able to check if the workspace role exists for other workspace', async () => { + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceBob.slug}/exists/Viewer`, + headers: { + 'x-e2e-user-email': charlie.email + } + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toBe(roles.length) - expect(metadata.links.self).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace-role/${workspaceAlice.id}/all?page=0&limit=10&sort=name&order=asc&search=` - ) + expect(response.statusCode).toBe(401) + }) }) - it('should not be able to fetch all the roles of a workspace without READ_WORKSPACE_ROLE role', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { - workspaceId: workspaceAlice.id, - name: 'Member' - } - }, - data: { - authorities: { - set: [Authority.CREATE_WORKSPACE_ROLE] + describe('Get All Workspace Role Tests', () => { + it('should be able to fetch all the roles of a workspace with WORKSPACE_ADMIN role', async () => { + const roles = await prisma.workspaceRole + .findMany({ + where: { + workspaceId: workspaceAlice.id + } + }) + .then((roles) => + roles.map((role) => ({ + ...role, + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString() + })) + ) + + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.slug}/all`, + headers: { + 'x-e2e-user-email': charlie.email } - } - }) - - const response = await app.inject({ - method: 'GET', - url: `/workspace/${workspaceAlice.id}`, - headers: { - 'x-e2e-user-email': bob.email - } - }) + }) - expect(response.statusCode).toBe(401) - }) + expect(response.statusCode).toBe(200) + expect(response.json().items).toEqual(expect.arrayContaining(roles)) - it('should be able to add projects to the role', async () => { - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - projectIds: projects.map((project) => project.id) - }, - headers: { - 'x-e2e-user-email': alice.email - } + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toBe(roles.length) + expect(metadata.links.self).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - ...adminRole1, - createdAt: expect.any(String), - updatedAt: expect.any(String), - projects: expect.arrayContaining([ - { - projectId: projects[0].id + it('should be able to fetch all the roles of a workspace with READ_WORKSPACE_ROLE role', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } }, - { - projectId: projects[1].id + data: { + authorities: { + set: [Authority.READ_WORKSPACE_ROLE] + } } - ]) - }) - }) + }) - it('should be able to add projects to the role with UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { - workspaceId: workspaceAlice.id, - name: 'Member' - } - }, - data: { - authorities: { - set: [ - Authority.UPDATE_WORKSPACE_ROLE, - Authority.READ_PROJECT, - Authority.READ_WORKSPACE_ROLE - ] + const roles = await prisma.workspaceRole + .findMany({ + where: { + workspaceId: workspaceAlice.id + } + }) + .then((roles) => + roles.map((role) => ({ + ...role, + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString() + })) + ) + + const response = await app.inject({ + method: 'GET', + url: `/workspace-role/${workspaceAlice.slug}/all`, + headers: { + 'x-e2e-user-email': charlie.email } - } - }) + }) - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - projectIds: projects.map((project) => project.id) - }, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(200) + expect(response.json().items).toEqual(expect.arrayContaining(roles)) + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toBe(roles.length) + expect(metadata.links.self).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace-role/${workspaceAlice.slug}/all?page=0&limit=10&sort=name&order=asc&search=` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual({ - ...adminRole1, - createdAt: expect.any(String), - updatedAt: expect.any(String), - hasAdminAuthority: true, - projects: expect.arrayContaining([ - { - projectId: projects[0].id + it('should not be able to fetch all the roles of a workspace without READ_WORKSPACE_ROLE role', async () => { + await prisma.workspaceRole.update({ + where: { + workspaceId_name: { + workspaceId: workspaceAlice.id, + name: 'Member' + } }, - { - projectId: projects[1].id + data: { + authorities: { + set: [Authority.CREATE_WORKSPACE_ROLE] + } } - ]) - }) - - adminRole1 = response.json() - }) + }) - it('should not be able to add projects to the role without UPDATE_WORKSPACE_ROLE and READ_PROJECT authorities', async () => { - await prisma.workspaceRole.update({ - where: { - workspaceId_name: { - workspaceId: workspaceAlice.id, - name: 'Member' + const response = await app.inject({ + method: 'GET', + url: `/workspace/${workspaceAlice.slug}`, + headers: { + 'x-e2e-user-email': bob.email } - }, - data: { - authorities: { - set: [Authority.READ_WORKSPACE_ROLE] - } - } - }) + }) - const response = await app.inject({ - method: 'PUT', - url: `/workspace-role/${adminRole1.id}`, - payload: { - projectIds: projects.map((project) => project.id) - }, - headers: { - 'x-e2e-user-email': charlie.email - } + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(401) }) }) diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index f948282c..25e12ca6 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -28,135 +28,143 @@ export class WorkspaceController { return this.workspaceService.createWorkspace(user, dto) } - @Put(':workspaceId') + @Put(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.UPDATE_WORKSPACE) async update( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() dto: UpdateWorkspace ) { - return this.workspaceService.updateWorkspace(user, workspaceId, dto) + return this.workspaceService.updateWorkspace(user, workspaceSlug, dto) } - @Put(':workspaceId/transfer-ownership/:userId') + @Put(':workspaceSlug/transfer-ownership/:userEmail') @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) async transferOwnership( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], - @Param('userId') userId: User['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] ) { - return this.workspaceService.transferOwnership(user, workspaceId, userId) + return this.workspaceService.transferOwnership( + user, + workspaceSlug, + userEmail + ) } - @Delete(':workspaceId') + @Delete(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.DELETE_WORKSPACE) async delete( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.deleteWorkspace(user, workspaceId) + return this.workspaceService.deleteWorkspace(user, workspaceSlug) } - @Post(':workspaceId/invite-users') + @Post(':workspaceSlug/invite-users') @RequiredApiKeyAuthorities(Authority.ADD_USER) async addUsers( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Body() members: WorkspaceMemberDTO[] ) { return this.workspaceService.inviteUsersToWorkspace( user, - workspaceId, + workspaceSlug, members ) } - @Delete(':workspaceId/remove-users') + @Delete(':workspaceSlug/remove-users') @RequiredApiKeyAuthorities(Authority.REMOVE_USER) async removeUsers( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], - @Body() userIds: User['id'][] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Body() userEmails: User['email'][] ) { return this.workspaceService.removeUsersFromWorkspace( user, - workspaceId, - userIds + workspaceSlug, + userEmails ) } - @Put(':workspaceId/update-member-role/:userId') + @Put(':workspaceSlug/update-member-role/:userEmail') @RequiredApiKeyAuthorities(Authority.UPDATE_USER_ROLE) async updateMemberRoles( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], - @Param('userId') userId: User['id'], - @Body() roleIds: WorkspaceRole['id'][] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'], + @Body() roleSlugs: WorkspaceRole['slug'][] ) { return this.workspaceService.updateMemberRoles( user, - workspaceId, - userId, - roleIds + workspaceSlug, + userEmail, + roleSlugs ) } - @Post(':workspaceId/accept-invitation') + @Post(':workspaceSlug/accept-invitation') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async acceptInvitation( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.acceptInvitation(user, workspaceId) + return this.workspaceService.acceptInvitation(user, workspaceSlug) } - @Delete(':workspaceId/decline-invitation') + @Delete(':workspaceSlug/decline-invitation') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async declineInvitation( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.declineInvitation(user, workspaceId) + return this.workspaceService.declineInvitation(user, workspaceSlug) } - @Delete(':workspaceId/cancel-invitation/:userId') + @Delete(':workspaceSlug/cancel-invitation/:userEmail') @RequiredApiKeyAuthorities(Authority.REMOVE_USER) async cancelInvitation( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], - @Param('userId') userId: User['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] ) { - return this.workspaceService.cancelInvitation(user, workspaceId, userId) + return this.workspaceService.cancelInvitation( + user, + workspaceSlug, + userEmail + ) } - @Delete(':workspaceId/leave') + @Delete(':workspaceSlug/leave') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async leave( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.leaveWorkspace(user, workspaceId) + return this.workspaceService.leaveWorkspace(user, workspaceSlug) } - @Get(':workspaceId/is-member/:userId') + @Get(':workspaceSlug/is-member/:userEmail') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async isMember( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], - @Param('userId') userId: User['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], + @Param('userEmail') userEmail: User['email'] ) { return this.workspaceService.isUserMemberOfWorkspace( user, - workspaceId, - userId + workspaceSlug, + userEmail ) } - @Get(':workspaceId/members') + @Get(':workspaceSlug/members') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getMembers( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Query('page') page: number = 0, @Query('limit') limit: number = 10, @Query('sort') sort: string = 'name', @@ -165,7 +173,7 @@ export class WorkspaceController { ) { return this.workspaceService.getAllMembersOfWorkspace( user, - workspaceId, + workspaceSlug, page, limit, sort, @@ -174,22 +182,22 @@ export class WorkspaceController { ) } - @Get(':workspaceId') + @Get(':workspaceSlug') @RequiredApiKeyAuthorities(Authority.READ_WORKSPACE) async getWorkspace( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.getWorkspaceById(user, workspaceId) + return this.workspaceService.getWorkspaceBySlug(user, workspaceSlug) } - @Get(':workspaceId/export-data') + @Get(':workspaceSlug/export-data') @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) async exportData( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'] + @Param('workspaceSlug') workspaceSlug: Workspace['slug'] ) { - return this.workspaceService.exportData(user, workspaceId) + return this.workspaceService.exportData(user, workspaceSlug) } @Get() @@ -212,7 +220,7 @@ export class WorkspaceController { ) } - @Get(':workspaceId/global-search/:searchTerm') + @Get(':workspaceSlug/global-search/:searchTerm') @RequiredApiKeyAuthorities( Authority.READ_WORKSPACE, Authority.READ_ENVIRONMENT, @@ -222,9 +230,9 @@ export class WorkspaceController { ) async globalSearch( @CurrentUser() user: User, - @Param('workspaceId') workspaceId: Workspace['id'], + @Param('workspaceSlug') workspaceSlug: Workspace['slug'], @Param('searchTerm') searchTerm: string ) { - return this.workspaceService.globalSearch(user, workspaceId, searchTerm) + return this.workspaceService.globalSearch(user, workspaceSlug, searchTerm) } } diff --git a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts index 01ad5bfc..83301ca3 100644 --- a/apps/api/src/workspace/dto/create.workspace/create.workspace.ts +++ b/apps/api/src/workspace/dto/create.workspace/create.workspace.ts @@ -13,5 +13,5 @@ export class CreateWorkspace { export interface WorkspaceMemberDTO { email: string - roleIds: WorkspaceRole['id'][] + roleSlugs: WorkspaceRole['slug'][] } diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 21a532bd..6821139b 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -30,12 +30,14 @@ import { IMailService, MAIL_SERVICE } from '@/mail/services/interface.service' import { JwtService } from '@nestjs/jwt' import { UpdateWorkspace } from '../dto/update.workspace/update.workspace' import { v4 } from 'uuid' -import createEvent from '@/common/create-event' -import createWorkspace from '@/common/create-workspace' import { AuthorityCheckerService } from '@/common/authority-checker.service' -import getCollectiveProjectAuthorities from '@/common/get-collective-project-authorities' import { paginate } from '@/common/paginate' -import { limitMaxItemsPerPage } from '@/common/limit-max-items-per-page' +import generateEntitySlug from '@/common/slug-generator' +import { getUserByEmail } from '@/common/user' +import { createWorkspace } from '@/common/workspace' +import { createEvent } from '@/common/event' +import { limitMaxItemsPerPage } from '@/common/util' +import { getCollectiveProjectAuthorities } from '@/common/collective-authorities' @Injectable() export class WorkspaceService { @@ -48,6 +50,13 @@ export class WorkspaceService { private readonly authorityCheckerService: AuthorityCheckerService ) {} + /** + * Creates a new workspace for the given user. + * @throws ConflictException if the workspace with the same name already exists + * @param user The user to create the workspace for + * @param dto The data to create the workspace with + * @returns The created workspace + */ async createWorkspace(user: User, dto: CreateWorkspace) { if (await this.existsByName(dto.name, user.id)) { throw new ConflictException('Workspace already exists') @@ -56,16 +65,24 @@ export class WorkspaceService { return await createWorkspace(user, dto, this.prisma) } + /** + * Updates a workspace + * @throws ConflictException if the workspace with the same name already exists + * @param user The user to update the workspace for + * @param workspaceSlug The slug of the workspace to update + * @param dto The data to update the workspace with + * @returns The updated workspace + */ async updateWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], dto: UpdateWorkspace ) { // Fetch the workspace const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.UPDATE_WORKSPACE], prisma: this.prisma @@ -81,10 +98,13 @@ export class WorkspaceService { const updatedWorkspace = await this.prisma.workspace.update({ where: { - id: workspaceId + id: workspace.id }, data: { name: dto.name, + slug: dto.name + ? await generateEntitySlug(dto.name, 'WORKSPACE', this.prisma) + : undefined, description: dto.description, lastUpdatedBy: { connect: { @@ -114,23 +134,35 @@ export class WorkspaceService { return updatedWorkspace } + /** + * Transfers ownership of a workspace to another user. + * @param user The user transferring the ownership + * @param workspaceSlug The slug of the workspace to transfer + * @param otherUserEmail The email of the user to transfer the ownership to + * @throws BadRequestException if the user is already the owner of the workspace, + * or if the workspace is the default workspace + * @throws NotFoundException if the other user is not a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + */ async transferOwnership( user: User, - workspaceId: Workspace['id'], - userId: User['id'] + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] ): Promise { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.WORKSPACE_ADMIN], prisma: this.prisma }) - if (userId === user.id) { + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + + if (otherUser.id === user.id) { throw new BadRequestException( - `You are already the owner of the workspace ${workspace.name} (${workspace.id})` + `You are already the owner of the workspace ${workspace.name} (${workspace.slug})` ) } @@ -138,31 +170,31 @@ export class WorkspaceService { // ownership if the workspace is the default workspace if (workspace.isDefault) { throw new BadRequestException( - `You cannot transfer ownership of default workspace ${workspace.name} (${workspace.id})` + `You cannot transfer ownership of default workspace ${workspace.name} (${workspace.slug})` ) } const workspaceMembership = await this.getWorkspaceMembership( - workspaceId, - userId + workspace.id, + otherUser.id ) // Check if the user is a member of the workspace if (!workspaceMembership) { throw new NotFoundException( - `User ${userId} is not a member of workspace ${workspace.name} (${workspace.id})` + `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` ) } const currentUserMembership = await this.getWorkspaceMembership( - workspaceId, + workspace.id, user.id ) // Get the admin ownership role const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ where: { - workspaceId, + workspaceId: workspace.id, hasAdminAuthority: true } }) @@ -196,10 +228,10 @@ export class WorkspaceService { // Update the owner of the workspace const updateWorkspace = this.prisma.workspace.update({ where: { - id: workspaceId + id: workspace.id }, data: { - ownerId: userId, + ownerId: otherUser.id, lastUpdatedBy: { connect: { id: user.id @@ -225,7 +257,7 @@ export class WorkspaceService { metadata: { workspaceId: workspace.id, name: workspace.name, - newOwnerId: userId + newOwnerId: otherUser.id }, workspaceId: workspace.id }, @@ -233,18 +265,24 @@ export class WorkspaceService { ) this.log.debug( - `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${userId}` + `Transferred ownership of workspace ${workspace.name} (${workspace.id}) to user ${otherUser.email} (${otherUser.id})` ) } + /** + * Deletes a workspace. + * @throws BadRequestException if the workspace is the default workspace + * @param user The user to delete the workspace for + * @param workspaceSlug The slug of the workspace to delete + */ async deleteWorkspace( user: User, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ): Promise { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.DELETE_WORKSPACE], prisma: this.prisma }) @@ -252,7 +290,7 @@ export class WorkspaceService { // We don't want the users to delete their default workspace if (workspace.isDefault) { throw new BadRequestException( - `You cannot delete the default workspace ${workspace.name} (${workspace.id})` + `You cannot delete the default workspace ${workspace.name} (${workspace.slug})` ) } @@ -263,18 +301,27 @@ export class WorkspaceService { } }) - this.log.debug(`Deleted workspace ${workspace.name} (${workspace.id})`) + this.log.debug(`Deleted workspace ${workspace.name} (${workspace.slug})`) } + /** + * Invites users to a workspace. + * @param user The user to invite the users for + * @param workspaceSlug The slug of the workspace to invite users to + * @param members The members to invite + * @throws BadRequestException if the user does not have the authority to add users to the workspace + * @throws NotFoundException if the workspace or any of the users to invite do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ async inviteUsersToWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], members: WorkspaceMemberDTO[] ): Promise { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.ADD_USER], prisma: this.prisma }) @@ -312,19 +359,42 @@ export class WorkspaceService { ) } + /** + * Removes users from a workspace. + * @param user The user to remove users from the workspace for + * @param workspaceSlug The slug of the workspace to remove users from + * @param userEmails The emails of the users to remove from the workspace + * @throws BadRequestException if the user is trying to remove themselves from the workspace, + * or if the user is not a member of the workspace + * @throws NotFoundException if the workspace or any of the users to remove do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ async removeUsersFromWorkspace( user: User, - workspaceId: Workspace['id'], - userIds: User['id'][] + workspaceSlug: Workspace['slug'], + userEmails: User['email'][] ): Promise { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.REMOVE_USER], prisma: this.prisma }) + const userIds = await this.prisma.user + .findMany({ + where: { + email: { + in: userEmails + } + }, + select: { + id: true + } + }) + .then((users) => users.map((u) => u.id)) + // Remove users from the workspace if any if (userIds && userIds.length > 0) { if (userIds.find((id) => id === user.id)) { @@ -336,7 +406,7 @@ export class WorkspaceService { // Delete the membership await this.prisma.workspaceMember.deleteMany({ where: { - workspaceId, + workspaceId: workspace.id, userId: { in: userIds } @@ -366,36 +436,48 @@ export class WorkspaceService { ) } + /** + * Updates the roles of a user in a workspace. + * + * @throws NotFoundException if the user is not a member of the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @param user The user to update the roles for + * @param workspaceSlug The slug of the workspace to update the roles in + * @param otherUserEmail The email of the user to update the roles for + * @param roleSlugs The slugs of the roles to assign to the user + */ async updateMemberRoles( user: User, - workspaceId: Workspace['id'], - userId: User['id'], - roleIds: WorkspaceRole['id'][] + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'], + roleSlugs: WorkspaceRole['slug'][] ): Promise { + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) + const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.UPDATE_USER_ROLE], prisma: this.prisma }) - if (!roleIds || roleIds.length === 0) { + if (!roleSlugs || roleSlugs.length === 0) { this.log.warn( - `No roles to update for user ${userId} in workspace ${workspace.name} (${workspace.id})` + `No roles to update for user ${otherUserEmail} in workspace ${workspace.name} (${workspace.id})` ) } // Check if the member in concern is a part of the workspace or not - if (!(await this.memberExistsInWorkspace(workspaceId, userId))) + if (!(await this.memberExistsInWorkspace(workspace.id, otherUser.id))) throw new NotFoundException( - `User ${userId} is not a member of workspace ${workspace.name} (${workspace.id})` + `${otherUser.email} is not a member of workspace ${workspace.name} (${workspace.slug})` ) - const workspaceAdminRole = await this.getWorkspaceAdminRole(workspaceId) + const workspaceAdminRole = await this.getWorkspaceAdminRole(workspace.id) // Check if the admin role is tried to be assigned to the user - if (roleIds.includes(workspaceAdminRole.id)) { + if (roleSlugs.includes(workspaceAdminRole.slug)) { throw new BadRequestException(`Admin role cannot be assigned to the user`) } @@ -403,8 +485,8 @@ export class WorkspaceService { const membership = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { - workspaceId, - userId + workspaceId: workspace.id, + userId: otherUser.id } } }) @@ -417,22 +499,27 @@ export class WorkspaceService { } }) - const invalidRoles = await this.findInvalidWorkspaceRoles( - workspace.id, - roleIds - ) + const roleSet = new Set() - if (invalidRoles.length > 0) { - throw new NotFoundException( - `Workspace ${workspace.name} (${workspace.id}) does not have roles ${invalidRoles.join(', ')}` - ) + for (const slug of roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException(`Role ${slug} not found`) + } + + roleSet.add(role) } // Create new associations const createNewAssociations = this.prisma.workspaceMemberRoleAssociation.createMany({ - data: roleIds.map((roleId) => ({ - roleId, + data: Array.from(roleSet).map((role) => ({ + roleId: role.id, workspaceMemberId: membership.id })) }) @@ -452,8 +539,8 @@ export class WorkspaceService { metadata: { workspaceId: workspace.id, name: workspace.name, - userId, - roleIds + userId: otherUser.id, + roleIds: roleSlugs }, workspaceId: workspace.id }, @@ -461,25 +548,37 @@ export class WorkspaceService { ) this.log.debug( - `Updated role of user ${userId} in workspace ${workspace.name} (${workspace.id})` + `Updated role of user ${otherUser.id} in workspace ${workspace.name} (${workspace.id})` ) } + /** + * Gets all members of a workspace, paginated. + * @param user The user to get the members for + * @param workspaceSlug The slug of the workspace to get the members from + * @param page The page number to get + * @param limit The number of items per page to get + * @param sort The field to sort by + * @param order The order to sort in + * @param search The search string to filter by + * @returns The members of the workspace, paginated, with metadata + */ async getAllMembersOfWorkspace( user: User, - workspaceId: Workspace['id'], + workspaceSlug: Workspace['slug'], page: number, limit: number, sort: string, order: string, search: string ) { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS], + prisma: this.prisma + }) //get all members of workspace for page with limit const items = await this.prisma.workspaceMember.findMany({ skip: page * limit, @@ -490,7 +589,7 @@ export class WorkspaceService { } }, where: { - workspaceId, + workspaceId: workspace.id, user: { OR: [ { @@ -534,7 +633,7 @@ export class WorkspaceService { //calculate metadata for pagination const totalCount = await this.prisma.workspaceMember.count({ where: { - workspaceId, + workspaceId: workspace.id, user: { OR: [ { @@ -552,29 +651,47 @@ export class WorkspaceService { } }) - const metadata = paginate(totalCount, `/workspace/${workspaceId}/members`, { - page, - limit: limitMaxItemsPerPage(limit), - sort, - order, - search - }) + const metadata = paginate( + totalCount, + `/workspace/${workspace.slug}/members`, + { + page, + limit: limitMaxItemsPerPage(limit), + sort, + order, + search + } + ) return { items, metadata } } + /** + * Accepts an invitation to a workspace. + * @param user The user to accept the invitation for + * @param workspaceSlug The slug of the workspace to accept the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ async acceptInvitation( user: User, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ): Promise { // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceId, user.id) + await this.checkInvitationPending(workspaceSlug, user) + + const workspace = await this.prisma.workspace.findUnique({ + where: { + slug: workspaceSlug + } + }) // Update the membership await this.prisma.workspaceMember.update({ where: { workspaceId_userId: { - workspaceId, + workspaceId: workspace.id, userId: user.id } }, @@ -583,12 +700,6 @@ export class WorkspaceService { } }) - const workspace = await this.prisma.workspace.findUnique({ - where: { - id: workspaceId - } - }) - await createEvent( { triggeredBy: user, @@ -597,7 +708,7 @@ export class WorkspaceService { source: EventSource.WORKSPACE, title: `${user.name} accepted invitation to workspace ${workspace.name}`, metadata: { - workspaceId: workspaceId + workspaceId: workspace.id }, workspaceId: workspace.id }, @@ -605,31 +716,39 @@ export class WorkspaceService { ) this.log.debug( - `User ${user.name} (${user.id}) accepted invitation to workspace ${workspaceId}` + `User ${user.name} (${user.id}) accepted invitation to workspace ${workspace.id}` ) } + /** + * Cancels an invitation to a workspace. + * @param user The user cancelling the invitation + * @param workspaceSlug The slug of the workspace to cancel the invitation for + * @param inviteeEmail The email of the user to cancel the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace or the user to cancel the invitation for do not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ async cancelInvitation( user: User, - workspaceId: Workspace['id'], - inviteeId: User['id'] + workspaceSlug: Workspace['slug'], + inviteeEmail: User['email'] ): Promise { + const inviteeUser = await getUserByEmail(inviteeEmail, this.prisma) + const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.REMOVE_USER], prisma: this.prisma }) // Check if the user has a pending invitation to the workspace - if (!(await this.invitationPending(workspaceId, inviteeId))) - throw new BadRequestException( - `User ${inviteeId} is not invited to workspace ${workspaceId}` - ) + await this.checkInvitationPending(workspaceSlug, inviteeUser) // Delete the membership - await this.deleteMembership(workspaceId, inviteeId) + await this.deleteMembership(workspace.id, inviteeUser.id) await createEvent( { @@ -639,8 +758,8 @@ export class WorkspaceService { source: EventSource.WORKSPACE, title: `Cancelled invitation to workspace`, metadata: { - workspaceId: workspaceId, - inviteeId + workspaceId: workspace.id, + inviteeId: inviteeUser.id }, workspaceId: workspace.id }, @@ -648,26 +767,34 @@ export class WorkspaceService { ) this.log.debug( - `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspaceId}` + `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspace.id}` ) } + /** + * Declines an invitation to a workspace. + * @param user The user declining the invitation + * @param workspaceSlug The slug of the workspace to decline the invitation for + * @throws BadRequestException if the user does not have a pending invitation to the workspace + * @throws NotFoundException if the workspace does not exist + * @throws InternalServerErrorException if there is an error in the transaction + */ async declineInvitation( user: User, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ): Promise { // Check if the user has a pending invitation to the workspace - await this.checkInvitationPending(workspaceId, user.id) - - // Delete the membership - await this.deleteMembership(workspaceId, user.id) + await this.checkInvitationPending(workspaceSlug, user) const workspace = await this.prisma.workspace.findUnique({ where: { - id: workspaceId + slug: workspaceSlug } }) + // Delete the membership + await this.deleteMembership(workspace.id, user.id) + await createEvent( { triggeredBy: user, @@ -676,7 +803,7 @@ export class WorkspaceService { source: EventSource.WORKSPACE, title: `${user.name} declined invitation to workspace ${workspace.name}`, metadata: { - workspaceId: workspaceId + workspaceId: workspace.id }, workspaceId: workspace.id }, @@ -684,18 +811,24 @@ export class WorkspaceService { ) this.log.debug( - `User ${user.name} (${user.id}) declined invitation to workspace ${workspaceId}` + `User ${user.name} (${user.id}) declined invitation to workspace ${workspace.id}` ) } + /** + * Leaves a workspace. + * @throws BadRequestException if the user is the owner of the workspace + * @param user The user to leave the workspace for + * @param workspaceSlug The slug of the workspace to leave + */ async leaveWorkspace( user: User, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ): Promise { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.READ_WORKSPACE], prisma: this.prisma }) @@ -703,7 +836,7 @@ export class WorkspaceService { const workspaceOwnerId = await this.prisma.workspace .findUnique({ where: { - id: workspaceId + id: workspace.id }, select: { ownerId: true @@ -718,7 +851,7 @@ export class WorkspaceService { ) // Delete the membership - await this.deleteMembership(workspaceId, user.id) + await this.deleteMembership(workspace.id, user.id) await createEvent( { @@ -728,7 +861,7 @@ export class WorkspaceService { source: EventSource.WORKSPACE, title: `User left workspace`, metadata: { - workspaceId: workspaceId + workspaceId: workspace.id }, workspaceId: workspace.id }, @@ -736,37 +869,64 @@ export class WorkspaceService { ) this.log.debug( - `User ${user.name} (${user.id}) left workspace ${workspaceId}` + `User ${user.name} (${user.id}) left workspace ${workspace.id}` ) } + /** + * Checks if a user is a member of a workspace. + * @param user The user to check if the other user is a member of the workspace for + * @param workspaceSlug The slug of the workspace to check if the user is a member of + * @param otherUserEmail The email of the user to check if is a member of the workspace + * @returns True if the user is a member of the workspace, false otherwise + */ async isUserMemberOfWorkspace( user: User, - workspaceId: Workspace['id'], - otherUserId: User['id'] + workspaceSlug: Workspace['slug'], + otherUserEmail: User['email'] ): Promise { - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [Authority.READ_USERS], - prisma: this.prisma - }) + const otherUser = await getUserByEmail(otherUserEmail, this.prisma) - return await this.memberExistsInWorkspace(workspaceId, otherUserId) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [Authority.READ_USERS], + prisma: this.prisma + }) + + return await this.memberExistsInWorkspace(workspace.id, otherUser.id) } - async getWorkspaceById( + /** + * Gets a workspace by its slug. + * @param user The user to get the workspace for + * @param workspaceSlug The slug of the workspace to get + * @returns The workspace + * @throws NotFoundException if the workspace does not exist or the user does not have the authority to read the workspace + */ + async getWorkspaceBySlug( user: User, - workspaceId: Workspace['id'] + workspaceSlug: Workspace['slug'] ): Promise { return await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.READ_USERS], prisma: this.prisma }) } + /** + * Gets all workspaces of a user, paginated. + * @param user The user to get the workspaces for + * @param page The page number to get + * @param limit The number of items per page to get + * @param sort The field to sort by + * @param order The order to sort in + * @param search The search string to filter by + * @returns The workspaces of the user, paginated, with metadata + */ async getWorkspacesOfUser( user: User, page: number, @@ -838,11 +998,19 @@ export class WorkspaceService { return { items, metadata } } - async exportData(user: User, workspaceId: Workspace['id']) { + /** + * Exports all data of a workspace, including its roles, projects, environments, variables and secrets. + * @param user The user to export the data for + * @param workspaceSlug The slug of the workspace to export + * @returns The exported data + * @throws NotFoundException if the workspace does not exist or the user does not have the authority to read the workspace + * @throws InternalServerErrorException if there is an error in the transaction + */ + async exportData(user: User, workspaceSlug: Workspace['slug']) { const workspace = await this.authorityCheckerService.checkAuthorityOverWorkspace({ userId: user.id, - entity: { id: workspaceId }, + entity: { slug: workspaceSlug }, authorities: [Authority.WORKSPACE_ADMIN], prisma: this.prisma }) @@ -856,7 +1024,7 @@ export class WorkspaceService { // Get all the roles of the workspace data.workspaceRoles = await this.prisma.workspaceRole.findMany({ where: { - workspaceId + workspaceId: workspace.id }, select: { name: true, @@ -875,7 +1043,7 @@ export class WorkspaceService { // Get all projects, environments, variables and secrets of the workspace data.projects = await this.prisma.project.findMany({ where: { - workspaceId + workspaceId: workspace.id }, select: { name: true, @@ -921,9 +1089,18 @@ export class WorkspaceService { return data } + /** + * Searches for projects, environments, secrets and variables + * based on a search term. The search is scoped to the workspace + * and the user's permissions. + * @param user The user to search for + * @param workspaceSlug The slug of the workspace to search in + * @param searchTerm The search term to search for + * @returns An object with the search results + */ async globalSearch( user: User, - workspaceId: string, + workspaceSlug: Workspace['slug'], searchTerm: string ): Promise<{ projects: Partial[] @@ -932,23 +1109,24 @@ export class WorkspaceService { variables: Partial[] }> { // Check authority over workspace - await this.authorityCheckerService.checkAuthorityOverWorkspace({ - userId: user.id, - entity: { id: workspaceId }, - authorities: [ - Authority.READ_WORKSPACE, - Authority.READ_PROJECT, - Authority.READ_ENVIRONMENT, - Authority.READ_SECRET, - Authority.READ_VARIABLE - ], - prisma: this.prisma - }) + const workspace = + await this.authorityCheckerService.checkAuthorityOverWorkspace({ + userId: user.id, + entity: { slug: workspaceSlug }, + authorities: [ + Authority.READ_WORKSPACE, + Authority.READ_PROJECT, + Authority.READ_ENVIRONMENT, + Authority.READ_SECRET, + Authority.READ_VARIABLE + ], + prisma: this.prisma + }) // Get a list of project IDs that the user has access to READ const accessibleProjectIds = await this.getAccessibleProjectIds( user.id, - workspaceId + workspace.id ) // Query all entities based on the search term and permissions @@ -965,6 +1143,15 @@ export class WorkspaceService { return { projects, environments, secrets, variables } } + + /** + * Gets a list of project IDs that the user has access to READ. + * The user has access to a project if the project is global or if the user has the READ_PROJECT authority. + * @param userId The ID of the user to get the accessible project IDs for + * @param workspaceId The ID of the workspace to get the accessible project IDs for + * @returns The list of project IDs that the user has access to READ + * @private + */ private async getAccessibleProjectIds( userId: string, workspaceId: string @@ -994,6 +1181,13 @@ export class WorkspaceService { return accessibleProjectIds } + /** + * Queries projects by IDs and search term. + * @param projectIds The IDs of projects to query + * @param searchTerm The search term to query by + * @returns The projects that match the search term + * @private + */ private async queryProjects( projectIds: string[], searchTerm: string @@ -1011,6 +1205,13 @@ export class WorkspaceService { }) } + /** + * Queries environments by IDs and search term. + * @param projectIds The IDs of projects to query + * @param searchTerm The search term to query by + * @returns The environments that match the search term + * @private + */ private async queryEnvironments( projectIds: string[], searchTerm: string @@ -1029,6 +1230,13 @@ export class WorkspaceService { }) } + /** + * Queries secrets by IDs and search term. + * @param projectIds The IDs of projects to query + * @param searchTerm The search term to query by + * @returns The secrets that match the search term + * @private + */ private async querySecrets( projectIds: string[], searchTerm: string @@ -1048,6 +1256,13 @@ export class WorkspaceService { }) } + /** + * Queries variables by IDs and search term. + * @param projectIds The IDs of projects to query + * @param searchTerm The search term to query by + * @returns The variables that match the search term + * @private + */ private async queryVariables( projectIds: string[], searchTerm: string @@ -1066,6 +1281,13 @@ export class WorkspaceService { }) } + /** + * Checks if a workspace with the given name exists for the given user. + * @param name The name of the workspace to check for + * @param userId The ID of the user to check for + * @returns True if the workspace exists, false otherwise + * @private + */ private async existsByName( name: string, userId: User['id'] @@ -1099,6 +1321,16 @@ export class WorkspaceService { return adminRole } + /** + * Adds members to a workspace. + * @param workspace The workspace to add members to + * @param currentUser The user performing the action + * @param members The members to add to the workspace + * @throws BadRequestException if the admin role is tried to be assigned to the user + * @throws ConflictException if the user is already a member of the workspace + * @throws InternalServerErrorException if there is an error in the transaction + * @private + */ private async addMembersToWorkspace( workspace: Workspace, currentUser: User, @@ -1108,7 +1340,7 @@ export class WorkspaceService { for (const member of members) { // Check if the admin role is tried to be assigned to the user - if (member.roleIds.includes(workspaceAdminRole.id)) { + if (member.roleSlugs.includes(workspaceAdminRole.slug)) { throw new BadRequestException( `Admin role cannot be assigned to the user` ) @@ -1131,23 +1363,28 @@ export class WorkspaceService { `User ${ memberUser.name ?? 'NO_NAME_YET' } (${userId}) is already a member of workspace ${workspace.name} (${ - workspace.id + workspace.slug }). Skipping.` ) throw new ConflictException( - `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.id})` + `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.slug})` ) } - const invalidRoles = await this.findInvalidWorkspaceRoles( - workspace.id, - member.roleIds - ) + const roleSet = new Set() - if (invalidRoles.length > 0) { - throw new NotFoundException( - `Workspace ${workspace.name} (${workspace.id}) does not have roles ${invalidRoles.join(', ')}` - ) + for (const slug of member.roleSlugs) { + const role = await this.prisma.workspaceRole.findUnique({ + where: { + slug + } + }) + + if (!role) { + throw new NotFoundException(`Workspace role ${slug} does not exist`) + } + + roleSet.add(role) } // Create the workspace membership @@ -1156,10 +1393,10 @@ export class WorkspaceService { workspaceId: workspace.id, userId, roles: { - create: member.roleIds.map((id) => ({ + create: Array.from(roleSet).map((role) => ({ role: { connect: { - id + id: role.id } } })) @@ -1215,26 +1452,13 @@ export class WorkspaceService { } } - private async findInvalidWorkspaceRoles( - workspaceId: string, - roleIds: string[] - ) { - const roles = await this.prisma.workspaceRole.findMany({ - where: { - id: { - in: roleIds - }, - workspaceId: workspaceId - } - }) - - const roleIdSet = new Set(roles.map((role) => role.id)) - - const invalidRoles = roleIds.filter((id) => !roleIdSet.has(id)) - - return invalidRoles - } - + /** + * Checks if a user is a member of a workspace. + * @param workspaceId The ID of the workspace to check + * @param userId The ID of the user to check + * @returns True if the user is a member of the workspace, false otherwise + * @private + */ private async memberExistsInWorkspace( workspaceId: string, userId: string @@ -1249,6 +1473,13 @@ export class WorkspaceService { ) } + /** + * Gets the workspace membership of a user in a workspace. + * @param workspaceId The ID of the workspace to get the membership for + * @param userId The ID of the user to get the membership for + * @returns The workspace membership of the user in the workspace + * @private + */ private async getWorkspaceMembership( workspaceId: Workspace['id'], userId: User['id'] @@ -1263,6 +1494,13 @@ export class WorkspaceService { }) } + /** + * Deletes the membership of a user in a workspace. + * @param workspaceId The ID of the workspace to delete the membership from + * @param userId The ID of the user to delete the membership for + * @returns A promise that resolves when the membership is deleted + * @private + */ private async deleteMembership( workspaceId: Workspace['id'], userId: User['id'] @@ -1277,28 +1515,31 @@ export class WorkspaceService { }) } - private async invitationPending( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - return await this.prisma.workspaceMember + /** + * Checks if a user has a pending invitation to a workspace. + * @throws BadRequestException if the user is not invited to the workspace + * @param workspaceSlug The slug of the workspace to check if the user is invited to + * @param user The user to check if the user is invited to the workspace + */ + private async checkInvitationPending( + workspaceSlug: Workspace['slug'], + user: User + ): Promise { + const membershipExists = await this.prisma.workspaceMember .count({ where: { - workspaceId, - userId, + workspace: { + slug: workspaceSlug + }, + userId: user.id, invitationAccepted: false } }) .then((count) => count > 0) - } - private async checkInvitationPending( - workspaceId: Workspace['id'], - userId: User['id'] - ): Promise { - if (!(await this.invitationPending(workspaceId, userId))) + if (!membershipExists) throw new BadRequestException( - `User ${userId} is not invited to workspace ${workspaceId}` + `${user.email} is not invited to workspace ${workspaceSlug}` ) } } diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts index 9df01949..70767ea6 100644 --- a/apps/api/src/workspace/workspace.e2e.spec.ts +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -19,13 +19,12 @@ import { Workspace, WorkspaceRole } from '@prisma/client' -import fetchEvents from '@/common/fetch-events' import { EventService } from '@/event/service/event.service' import { EventModule } from '@/event/event.module' import { UserModule } from '@/user/user.module' import { UserService } from '@/user/service/user.service' import { WorkspaceService } from './service/workspace.service' -import { QueryTransformPipe } from '@/common/query.transform.pipe' +import { QueryTransformPipe } from '@/common/pipes/query.transform.pipe' import { ProjectModule } from '@/project/project.module' import { EnvironmentModule } from '@/environment/environment.module' import { SecretModule } from '@/secret/secret.module' @@ -36,6 +35,7 @@ import { SecretService } from '@/secret/service/secret.service' import { VariableService } from '@/variable/service/variable.service' import { WorkspaceRoleService } from '@/workspace-role/service/workspace-role.service' import { WorkspaceRoleModule } from '@/workspace-role/workspace-role.module' +import { fetchEvents } from '@/common/event' const createMembership = async ( roleId: string, @@ -146,6 +146,7 @@ describe('Workspace Controller Tests', () => { memberRole = await prisma.workspaceRole.create({ data: { name: 'Member', + slug: 'member', workspaceId: workspace1.id, authorities: [Authority.READ_WORKSPACE] } @@ -181,1222 +182,1264 @@ describe('Workspace Controller Tests', () => { expect(workspaceRoleService).toBeDefined() }) - it('should be able to create a new workspace', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: '/workspace', - payload: { - name: 'Workspace 1', - description: 'Workspace 1 description' - } - }) - - expect(response.statusCode).toBe(201) - workspace1 = response.json() + describe('Create Workspace Tests', () => { + it('should be able to create a new workspace', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: '/workspace', + payload: { + name: 'Workspace 1', + description: 'Workspace 1 description' + } + }) - expect(workspace1.name).toBe('Workspace 1') - expect(workspace1.description).toBe('Workspace 1 description') - expect(workspace1.ownerId).toBe(user1.id) - expect(workspace1.isFreeTier).toBe(true) - expect(workspace1.isDefault).toBe(false) - }) + expect(response.statusCode).toBe(201) + const body = response.json() - it('should not be able to create a workspace with the same name', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: '/workspace', - payload: { - name: 'My Workspace', - description: 'My Workspace description' - } + expect(body.name).toBe('Workspace 1') + expect(body.slug).toBeDefined() + expect(body.description).toBe('Workspace 1 description') + expect(body.ownerId).toBe(user1.id) + expect(body.isFreeTier).toBe(true) + expect(body.isDefault).toBe(false) }) - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: 'Workspace already exists' - }) - }) + it('should not be able to create a workspace with the same name', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: '/workspace', + payload: { + name: 'My Workspace', + description: 'My Workspace description' + } + }) - it('should let other user to create workspace with same name', async () => { - await workspaceService.createWorkspace(user1, { - name: 'Workspace 1', - description: 'Workspace 1 description' + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: 'Workspace already exists' + }) }) - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user2.email - }, - url: '/workspace', - payload: { + it('should let other user to create workspace with same name', async () => { + await workspaceService.createWorkspace(user1, { name: 'Workspace 1', description: 'Workspace 1 description' - } - }) + }) - expect(response.statusCode).toBe(201) - workspace2 = response.json() + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: '/workspace', + payload: { + name: 'Workspace 1', + description: 'Workspace 1 description' + } + }) - expect(workspace2.name).toBe('Workspace 1') - expect(workspace2.description).toBe('Workspace 1 description') - expect(workspace2.ownerId).toBe(user2.id) - expect(workspace2.isFreeTier).toBe(true) - expect(workspace2.isDefault).toBe(false) - }) + expect(response.statusCode).toBe(201) + workspace2 = response.json() - it('should have created a WORKSPACE_CREATED event', async () => { - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + expect(workspace2.name).toBe('Workspace 1') + expect(workspace2.description).toBe('Workspace 1 description') + expect(workspace2.ownerId).toBe(user2.id) + expect(workspace2.isFreeTier).toBe(true) + expect(workspace2.isDefault).toBe(false) + }) - const event = response.items[0] + it('should have created a WORKSPACE_CREATED event', async () => { + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_CREATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + const event = response.items[0] + + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_CREATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) + + it('should have created a new role with name Admin', async () => { + expect(adminRole).toBeDefined() + expect(adminRole).toEqual({ + id: expect.any(String), + name: 'Admin', + slug: expect.any(String), + description: null, + colorCode: expect.any(String), + authorities: [Authority.WORKSPACE_ADMIN], + hasAdminAuthority: true, + workspaceId: workspace1.id, + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + }) - it('should have created a new role with name Admin', async () => { - expect(adminRole).toBeDefined() - expect(adminRole).toEqual({ - id: expect.any(String), - name: 'Admin', - description: null, - colorCode: expect.any(String), - authorities: [Authority.WORKSPACE_ADMIN], - hasAdminAuthority: true, - workspaceId: workspace1.id, - createdAt: expect.any(Date), - updatedAt: expect.any(Date) + it('should have associated the admin role with the user', async () => { + const userRole = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + userId: user1.id, + workspaceId: workspace1.id + } + } + }) + + expect(userRole).toBeDefined() + expect(userRole).toEqual({ + id: expect.any(String), + userId: user1.id, + workspaceId: workspace1.id, + invitationAccepted: true + }) }) }) - it('should have associated the admin role with the user', async () => { - const userRole = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - userId: user1.id, - workspaceId: workspace1.id + describe('Update Workspace Tests', () => { + it('should be able to update the workspace', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}`, + payload: { + name: 'Workspace 1 Updated', + description: 'Workspace 1 updated description' } - } + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + + expect(body.name).toBe('Workspace 1 Updated') + expect(body.slug).not.toBe(workspace1.slug) + expect(body.description).toBe('Workspace 1 updated description') }) - expect(userRole).toBeDefined() - expect(userRole).toEqual({ - id: expect.any(String), - userId: user1.id, - workspaceId: workspace1.id, - invitationAccepted: true + it('should not be able to change the name to an existing workspace or same name', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}`, + payload: { + name: 'My Workspace' + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: 'Workspace already exists' + }) }) - }) - it('should be able to update the workspace', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}`, - payload: { - name: 'Workspace 1 Updated', - description: 'Workspace 1 updated description' - } + it('should not allow external user to update a workspace', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}`, + payload: { + name: 'Workspace 1 Updated', + description: 'Workspace 1 updated description' + } + }) + + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(200) - workspace1 = response.json() + it('should have created a WORKSPACE_UPDATED event', async () => { + await workspaceService.updateWorkspace(user1, workspace1.slug, { + description: 'Workspace 1 Description' + }) - expect(workspace1.name).toBe('Workspace 1 Updated') - expect(workspace1.description).toBe('Workspace 1 updated description') - }) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - it('should not be able to change the name to an existing workspace or same name', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}`, - payload: { - name: 'My Workspace' - } - }) + const event = response.items[0] - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: 'Workspace already exists' + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) }) - it('should not allow external user to update a workspace', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}`, - payload: { - name: 'Workspace 1 Updated', - description: 'Workspace 1 updated description' - } + describe('Invite User Tests', () => { + it('should do nothing if null or empty array is sent for invitation of user', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/invite-users`, + payload: [] + }) + + expect(response.statusCode).toBe(201) }) - expect(response.statusCode).toBe(401) - }) + it('should not allow user to invite another user ', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [adminRole.slug] + } + ] + }) - it('should have created a WORKSPACE_UPDATED event', async () => { - await workspaceService.updateWorkspace(user1, workspace1.id, { - name: 'Workspace 1' + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `Admin role cannot be assigned to the user` + }) }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + it('should allow user to invite another user to the workspace', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ] + }) - const event = response.items[0] + expect(response.statusCode).toBe(201) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) - it('should do nothing if null or empty array is sent for invitation of user', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/invite-users`, - payload: [] + expect(membership).toBeDefined() + expect(membership).toEqual({ + id: expect.any(String), + userId: user2.id, + workspaceId: workspace1.id, + invitationAccepted: false + }) }) - expect(response.statusCode).toBe(201) - }) + it('should not be able to add an existing user to the workspace', async () => { + // Add user2 to workspace1 + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - it('should not allow user to invite another user ', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/invite-users`, - payload: [ - { - email: user2.email, - roleIds: [adminRole.id] - } - ] - }) + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/invite-users`, + payload: [ + { + email: user2.email, + roleSlugs: [] + } + ] + }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `Admin role cannot be assigned to the user` + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: `User ${user2.name} (${user2.id}) is already a member of workspace ${workspace1.name} (${workspace1.slug})` + }) }) - }) - it('should allow user to invite another user to the workspace', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/invite-users`, - payload: [ + it('should have created a INVITED_TO_WORKSPACE event', async () => { + // Invite user2 to workspace1 + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ { email: user2.email, - roleIds: [memberRole.id] + roleSlugs: [] } - ] - }) + ]) - expect(response.statusCode).toBe(201) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } - }) + const event = response.items[0] - expect(membership).toBeDefined() - expect(membership).toEqual({ - id: expect.any(String), - userId: user2.id, - workspaceId: workspace1.id, - invitationAccepted: false + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.INVITED_TO_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) - }) - - it('should not be able to update the membership to admin role', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/update-member-role/${user2.id}`, - payload: [adminRole.id] - }) + it('should have created a new user if they did not exist while inviting them to the workspace', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/invite-users`, + payload: [ + { + email: 'joy@keyshade.xyz', + roleSlugs: [memberRole.slug] + } + ] + }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `Admin role cannot be assigned to the user` - }) - }) + expect(response.statusCode).toBe(201) - it('should not be able to add an existing user to the workspace', async () => { - // Add user2 to workspace1 - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/invite-users`, - payload: [ - { - email: user2.email, - roleIds: [] + // Expect the user to have been created + const user = await prisma.user.findUnique({ + where: { + email: 'joy@keyshade.xyz' } - ] - }) + }) - expect(response.statusCode).toBe(409) - expect(response.json()).toEqual({ - statusCode: 409, - error: 'Conflict', - message: `User ${user2.name} (${user2.id}) is already a member of workspace ${workspace1.name} (${workspace1.id})` + expect(user).toBeDefined() }) }) - it('should have created a INVITED_TO_WORKSPACE event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [] - } - ]) + describe('Update Membership Tests', () => { + it('should not be able to update the membership to admin role', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [adminRole.slug] + }) - const event = response.items[0] + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `Admin role cannot be assigned to the user` + }) + }) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.INVITED_TO_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + it('should be able to update the role of a member', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - it('should be able to cancel the invitation', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [] - } - ]) + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [memberRole.slug] + }) - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/cancel-invitation/${user2.id}` - }) + expect(response.statusCode).toBe(200) - expect(response.statusCode).toBe(200) + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + }, + select: { + roles: { + select: { + roleId: true + } + } + } + }) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id + expect(membership.roles).toEqual([ + { + roleId: memberRole.id } - } + ]) }) - expect(membership).toBeNull() - }) - - it('should not be able to cancel the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/cancel-invitation/${user2.id}` - }) + it('should have created a WORKSPACE_MEMBERSHIP_UPDATED event', async () => { + // Create membership + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `User ${user2.id} is not invited to workspace ${workspace1.id}` - }) - }) + // Update the membership + await workspaceService.updateMemberRoles( + user1, + workspace1.slug, + user2.email, + [memberRole.slug] + ) - it('should have created a CANCELLED_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [] - } - ]) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - // Cancel the invitation - await workspaceService.cancelInvitation(user1, workspace1.id, user2.id) + const event = response.items[0] - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.WORKSPACE_MEMBERSHIP_UPDATED) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) - const event = response.items[0] + it('should not be able to update the role of a non existing member', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/update-member-role/${user2.email}`, + payload: [] + }) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.CANCELLED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `${user2.email} is not a member of workspace ${workspace1.name} (${workspace1.slug})` + }) + }) }) - it('should be able to decline invitation to the workspace', async () => { - // Send an invitation - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [memberRole.id] - } - ]) + describe('Cancel Invitation Tests', () => { + it('should be able to cancel the invitation', async () => { + // Invite user2 to workspace1 + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [] + } + ]) - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/decline-invitation` - }) + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/cancel-invitation/${user2.email}` + }) - expect(response.statusCode).toBe(200) + expect(response.statusCode).toBe(200) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } } - } - }) - - expect(membership).toBeNull() - }) + }) - it('should not be able to decline the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/decline-invitation` + expect(membership).toBeNull() }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `User ${user2.id} is not invited to workspace ${workspace1.id}` + it('should not be able to cancel the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/cancel-invitation/${user2.email}` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) }) - }) - it('should have created a DECLINED_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [memberRole.id] - } - ]) + it('should have created a CANCELLED_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [] + } + ]) - // Decline the invitation - await workspaceService.declineInvitation(user2, workspace1.id) + // Cancel the invitation + await workspaceService.cancelInvitation( + user1, + workspace1.slug, + user2.email + ) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - const event = response.items[0] + const event = response.items[0] - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.DECLINED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.CANCELLED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) }) - it('should be able to accept the invitation to the workspace', async () => { - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + describe('Decline Invitation Tests', () => { + it('should be able to decline invitation to the workspace', async () => { + // Send an invitation + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ]) - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/accept-invitation` - }) + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/decline-invitation` + }) - expect(response.statusCode).toBe(201) + expect(response.statusCode).toBe(200) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } } - } - }) + }) - expect(membership).toBeDefined() - expect(membership).toEqual({ - id: expect.any(String), - userId: user2.id, - workspaceId: workspace1.id, - invitationAccepted: true + expect(membership).toBeNull() }) - }) - it('should not be able to accept the invitation if the user is not invited', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/accept-invitation` - }) + it('should not be able to decline the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/decline-invitation` + }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `User ${user2.id} is not invited to workspace ${workspace1.id}` + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) }) - }) - - it('should have created a ACCEPT_INVITATION event', async () => { - // Invite user2 to workspace1 - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [memberRole.id] - } - ]) - // Accept the invitation - await workspaceService.acceptInvitation(user2, workspace1.id) + it('should have created a DECLINED_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ]) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + // Decline the invitation + await workspaceService.declineInvitation(user2, workspace1.slug) - const event = response.items[0] + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.ACCEPTED_INVITATION) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + const event = response.items[0] - it('should have created a new user if they did not exist while inviting them to the workspace', async () => { - const response = await app.inject({ - method: 'POST', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/invite-users`, - payload: [ - { - email: 'joy@keyshade.xyz', - roleIds: [memberRole.id] - } - ] + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.DECLINED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) + }) - expect(response.statusCode).toBe(201) + describe('Accept Invitation Tests', () => { + it('should be able to accept the invitation to the workspace', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - // Expect the user to have been created - const user = await prisma.user.findUnique({ - where: { - email: 'joy@keyshade.xyz' - } - }) + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/accept-invitation` + }) - expect(user).toBeDefined() - }) + expect(response.statusCode).toBe(201) - it('should be able to leave the workspace', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/leave` + expect(membership).toBeDefined() + expect(membership).toEqual({ + id: expect.any(String), + userId: user2.id, + workspaceId: workspace1.id, + invitationAccepted: true + }) }) - expect(response.statusCode).toBe(200) + it('should not be able to accept the invitation if the user is not invited', async () => { + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/accept-invitation` + }) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - } + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `${user2.email} is not invited to workspace ${workspace1.slug}` + }) }) - expect(membership).toBeNull() - }) + it('should have created a ACCEPT_INVITATION event', async () => { + // Invite user2 to workspace1 + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ]) - it('should not be able to leave the workspace if user is workspace owner', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/leave` - }) + // Accept the invitation + await workspaceService.acceptInvitation(user2, workspace1.slug) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` - }) - }) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - it('should not be able to leave the workspace if the user is not a member', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/leave` - }) + const event = response.items[0] - expect(response.statusCode).toBe(401) + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.ACCEPTED_INVITATION) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) }) - it('should have created a LEFT_WORKSPACE event', async () => { - // Create membership - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + describe('Leave Workspace Tests', () => { + it('should be able to leave the workspace', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - // Leave the workspace - await workspaceService.leaveWorkspace(user2, workspace1.id) + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/leave` + }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + expect(response.statusCode).toBe(200) - const event = response.items[0] + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.LEFT_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + expect(membership).toBeNull() + }) - it('should be able to update the role of a member', async () => { - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + it('should not be able to leave the workspace if user is workspace owner', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/leave` + }) - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/update-member-role/${user2.id}`, - payload: [memberRole.id] + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` + }) }) - expect(response.statusCode).toBe(200) + it('should not be able to leave the workspace if the user is not a member', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/leave` + }) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id - } - }, - select: { - roles: { - select: { - roleId: true - } - } - } + expect(response.statusCode).toBe(401) }) - expect(membership.roles).toEqual([ - { - roleId: memberRole.id - } - ]) - }) - - it('should have created a WORKSPACE_MEMBERSHIP_UPDATED event', async () => { - // Create membership - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + it('should have created a LEFT_WORKSPACE event', async () => { + // Create membership + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - // Update the membership - await workspaceService.updateMemberRoles(user1, workspace1.id, user2.id, [ - memberRole.id - ]) + // Leave the workspace + await workspaceService.leaveWorkspace(user2, workspace1.slug) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - const event = response.items[0] + const event = response.items[0] - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.WORKSPACE_MEMBERSHIP_UPDATED) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.LEFT_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() + }) }) - it('should be able to remove users from workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/remove-users`, - payload: [user2.id] - }) + describe('Remove Users Tests', () => { + it('should be able to remove users from workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/remove-users`, + payload: [user2.id] + }) - expect(response.statusCode).toBe(200) + expect(response.statusCode).toBe(200) - const membership = await prisma.workspaceMember.findUnique({ - where: { - workspaceId_userId: { - workspaceId: workspace1.id, - userId: user2.id + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } } - } - }) - - expect(membership).toBeNull() - }) - - it('should not be able to remove self from workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/remove-users`, - payload: [user1.id] - }) + }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` + expect(membership).toBeNull() }) - }) - it('should have created a REMOVED_FROM_WORKSPACE event', async () => { - // Create membership - await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + it('should not be able to remove self from workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/remove-users`, + payload: [user1.email] + }) - // Remove user2 from workspace1 - await workspaceService.removeUsersFromWorkspace(user1, workspace1.id, [ - user2.id - ]) + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` + }) + }) - const response = await fetchEvents( - eventService, - user1, - workspace1.id, - EventSource.WORKSPACE - ) + it('should have created a REMOVED_FROM_WORKSPACE event', async () => { + // Create membership + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) - const event = response.items[0] + // Remove user2 from workspace1 + await workspaceService.removeUsersFromWorkspace(user1, workspace1.slug, [ + user2.email + ]) - expect(event).toBeDefined() - expect(event.source).toBe(EventSource.WORKSPACE) - expect(event.triggerer).toBe(EventTriggerer.USER) - expect(event.severity).toBe(EventSeverity.INFO) - expect(event.type).toBe(EventType.REMOVED_FROM_WORKSPACE) - expect(event.workspaceId).toBe(workspace1.id) - expect(event.itemId).toBeDefined() - }) + const response = await fetchEvents( + eventService, + user1, + workspace1.slug, + EventSource.WORKSPACE + ) - it('should not be able to update the role of a non existing member', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/update-member-role/${user2.id}`, - payload: [] - }) + const event = response.items[0] - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `User ${user2.id} is not a member of workspace ${workspace1.name} (${workspace1.id})` + expect(event).toBeDefined() + expect(event.source).toBe(EventSource.WORKSPACE) + expect(event.triggerer).toBe(EventTriggerer.USER) + expect(event.severity).toBe(EventSeverity.INFO) + expect(event.type).toBe(EventType.REMOVED_FROM_WORKSPACE) + expect(event.workspaceId).toBe(workspace1.id) + expect(event.itemId).toBeDefined() }) }) - it('should be able to check if user is a member of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/is-member/${user2.id}` - }) - - expect(response.statusCode).toBe(200) - expect(response.json()).toEqual(false) - }) + describe('Check Membership Tests', () => { + it('should be able to check if user is a member of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/is-member/${user2.email}` + }) - it('should not be able to check if user is a member of the workspace if user is not a member', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/is-member/${user1.id}` + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(false) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to check if user is a member of the workspace if user is not a member', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/is-member/${user1.email}` + }) - it('should be able to get all the members of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/members` + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(200) - expect(response.json().items).toBeInstanceOf(Array) - expect(response.json().items).toHaveLength(1) - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(1) - expect(metadata.links.self).toEqual( - `/workspace/${workspace1.id}/members?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace/${workspace1.id}/members?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace/${workspace1.id}/members?page=0&limit=10&sort=name&order=asc&search=` - ) }) - it('should not be able to get all the members of the workspace if user is not a member', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/members` - }) - - expect(response.statusCode).toBe(401) - }) + describe('Get All Members Tests', () => { + it('should be able to get all the members of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/members` + }) - it('should be able to fetch the workspace by id', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}` + expect(response.statusCode).toBe(200) + expect(response.json().items).toBeInstanceOf(Array) + expect(response.json().items).toHaveLength(1) + + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(1) + expect(metadata.links.self).toEqual( + `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace/${workspace1.slug}/members?page=0&limit=10&sort=name&order=asc&search=` + ) }) - expect(response.statusCode).toBe(200) - expect(response.json().name).toEqual(workspace1.name) - }) + it('should not be able to get all the members of the workspace if user is not a member', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/members` + }) - it('should not be able to fetch the workspace by id if user is not a member', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}` + expect(response.statusCode).toBe(401) }) - - expect(response.statusCode).toBe(401) }) - it('should prevent external user from changing ownership of workspace', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/transfer-ownership/${user1.id}` - }) - - expect(response.statusCode).toBe(401) - }) + describe('Get Workspace Tests', () => { + it('should be able to fetch the workspace by slug', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}` + }) - it('should not be able to transfer the ownership to self', async () => { - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/transfer-ownership/${user1.id}` + expect(response.statusCode).toBe(200) + expect(response.json().name).toEqual(workspace1.name) }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You are already the owner of the workspace ${workspace1.name} (${workspace1.id})` + it('should not be able to fetch the workspace by slug if user is not a member', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}` + }) + + expect(response.statusCode).toBe(401) }) }) - it('should not be able to transfer ownership to a non member', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) + describe('Change Ownership Tests', () => { + it('should prevent external user from changing ownership of workspace', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/transfer-ownership/${user1.id}` + }) - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${newWorkspace.id}/transfer-ownership/${user3.id}` + expect(response.statusCode).toBe(401) }) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `User ${user3.id} is not a member of workspace ${newWorkspace.name} (${newWorkspace.id})` - }) - }) + it('should not be able to transfer the ownership to self', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/transfer-ownership/${user1.email}` + }) - it('should be able to fetch all the workspaces the user is a member of', async () => { - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: '/workspace' + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You are already the owner of the workspace ${workspace1.name} (${workspace1.slug})` + }) }) - expect(response.statusCode).toBe(200) - expect(response.json().items.length).toEqual(2) + it('should not be able to transfer ownership to a non member', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toBe(2) - expect(metadata.links.self).toEqual( - `/workspace?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace?page=0&limit=10&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toBeNull() - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace?page=0&limit=10&sort=name&order=asc&search=` - ) - }) + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user3.email}` + }) - it('should be able to fetch the 2nd page of the workspaces the user is a member of', async () => { - await createMembership(memberRole.id, user2.id, workspace1.id, prisma) - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: '/workspace?page=1&limit=1' + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `${user3.email} is not a member of workspace ${newWorkspace.name} (${newWorkspace.slug})` + }) }) - expect(response.statusCode).toBe(200) - expect(response.json().items).toHaveLength(1) - - //check metadata - const metadata = response.json().metadata - expect(metadata.totalCount).toEqual(2) - expect(metadata.links.self).toEqual( - `/workspace?page=1&limit=1&sort=name&order=asc&search=` - ) - expect(metadata.links.first).toEqual( - `/workspace?page=0&limit=1&sort=name&order=asc&search=` - ) - expect(metadata.links.previous).toEqual( - `/workspace?page=0&limit=1&sort=name&order=asc&search=` - ) - expect(metadata.links.next).toBeNull() - expect(metadata.links.last).toEqual( - `/workspace?page=1&limit=1&sort=name&order=asc&search=` - ) - }) + it('should be able to transfer the ownership of the workspace', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) - it('should be able to transfer the ownership of the workspace', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) + // Create membership + await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) - // Create membership - await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user2.email}` + }) - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${newWorkspace.id}/transfer-ownership/${user2.id}` - }) + expect(response.statusCode).toBe(200) - expect(response.statusCode).toBe(200) + const workspace = await prisma.workspace.findUnique({ + where: { + id: newWorkspace.id + } + }) - const workspace = await prisma.workspace.findUnique({ - where: { - id: newWorkspace.id - } + expect(workspace.ownerId).toEqual(user2.id) }) - expect(workspace.ownerId).toEqual(user2.id) - }) + it('should not be able to transfer ownership if is not admin', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) - it('should not be able to transfer ownership if is not admin', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) + // Create membership + await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) - // Create membership - await createMembership(memberRole.id, user2.id, newWorkspace.id, prisma) + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${newWorkspace.slug}/transfer-ownership/${user3.email}` + }) - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${newWorkspace.id}/transfer-ownership/${user3.id}` + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(401) - }) + it('should not be able to transfer ownership of default workspace', async () => { + // Invite another user to the workspace + await workspaceService.inviteUsersToWorkspace(user1, workspace1.slug, [ + { + email: user2.email, + roleSlugs: [memberRole.slug] + } + ]) - it('should not be able to export data of a non-existing workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/abc/export-data` - }) + // Accept the invitation + await workspaceService.acceptInvitation(user2, workspace1.slug) + + // Try transferring ownership + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/transfer-ownership/${user2.email}` + }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Workspace with id abc not found` + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot transfer ownership of default workspace ${workspace1.name} (${workspace1.slug})` + }) }) }) - it('should not be able to export data of a workspace it is not a member of', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/${workspace1.id}/export-data` - }) + describe('Get All Workspace Of User Tests', () => { + it('should be able to fetch all the workspaces the user is a member of', async () => { + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: '/workspace' + }) - expect(response.statusCode).toBe(401) - }) + expect(response.statusCode).toBe(200) + expect(response.json().items.length).toEqual(2) - it('should be able to export data of the workspace', async () => { - const response = await app.inject({ - method: 'GET', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/export-data` + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toBe(2) + expect(metadata.links.self).toEqual( + `/workspace?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace?page=0&limit=10&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toBeNull() + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace?page=0&limit=10&sort=name&order=asc&search=` + ) }) - expect(response.statusCode).toBe(200) + it('should be able to fetch the 2nd page of the workspaces the user is a member of', async () => { + await createMembership(memberRole.id, user2.id, workspace1.id, prisma) + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: '/workspace?page=1&limit=1' + }) - const body = response.json() + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(1) - expect(body.name).toEqual(workspace1.name) - expect(body.description).toEqual(workspace1.description) - expect(body.workspaceRoles).toBeInstanceOf(Array) - expect(body.projects).toBeInstanceOf(Array) + //check metadata + const metadata = response.json().metadata + expect(metadata.totalCount).toEqual(2) + expect(metadata.links.self).toEqual( + `/workspace?page=1&limit=1&sort=name&order=asc&search=` + ) + expect(metadata.links.first).toEqual( + `/workspace?page=0&limit=1&sort=name&order=asc&search=` + ) + expect(metadata.links.previous).toEqual( + `/workspace?page=0&limit=1&sort=name&order=asc&search=` + ) + expect(metadata.links.next).toBeNull() + expect(metadata.links.last).toEqual( + `/workspace?page=1&limit=1&sort=name&order=asc&search=` + ) + }) }) - it('should be able to delete the workspace', async () => { - const newWorkspace = await workspaceService.createWorkspace(user1, { - name: 'Workspace 2', - description: 'Workspace 2 description' - }) + describe('Export Data Tests', () => { + it('should not be able to export data of a non-existing workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/abc/export-data` + }) - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${newWorkspace.id}` + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Workspace abc not found` + }) }) - expect(response.statusCode).toBe(200) - }) + it('should not be able to export data of a workspace it is not a member of', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.slug}/export-data` + }) - it('should not be able to delete a non existing workspace', async () => { - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user2.email - }, - url: `/workspace/123` + expect(response.statusCode).toBe(401) }) - expect(response.statusCode).toBe(404) - expect(response.json()).toEqual({ - statusCode: 404, - error: 'Not Found', - message: `Workspace with id 123 not found` - }) - }) + it('should be able to export data of the workspace', async () => { + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}/export-data` + }) - it('should not be able to delete the default workspace', async () => { - // Try deleting the default workspace - const response = await app.inject({ - method: 'DELETE', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}` - }) + expect(response.statusCode).toBe(200) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot delete the default workspace ${workspace1.name} (${workspace1.id})` + const body = response.json() + + expect(body.name).toEqual(workspace1.name) + expect(body.description).toEqual(workspace1.description) + expect(body.workspaceRoles).toBeInstanceOf(Array) + expect(body.projects).toBeInstanceOf(Array) }) }) - it('should not be able to transfer ownership of default workspace', async () => { - // Invite another user to the workspace - await workspaceService.inviteUsersToWorkspace(user1, workspace1.id, [ - { - email: user2.email, - roleIds: [memberRole.id] - } - ]) + describe('Delete Workspace Tests', () => { + it('should be able to delete the workspace', async () => { + const newWorkspace = await workspaceService.createWorkspace(user1, { + name: 'Workspace 2', + description: 'Workspace 2 description' + }) + + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${newWorkspace.slug}` + }) - // Accept the invitation - await workspaceService.acceptInvitation(user2, workspace1.id) + expect(response.statusCode).toBe(200) + }) + + it('should not be able to delete a non existing workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/123` + }) - // Try transferring ownership - const response = await app.inject({ - method: 'PUT', - headers: { - 'x-e2e-user-email': user1.email - }, - url: `/workspace/${workspace1.id}/transfer-ownership/${user2.id}` + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `Workspace 123 not found` + }) }) - expect(response.statusCode).toBe(400) - expect(response.json()).toEqual({ - statusCode: 400, - error: 'Bad Request', - message: `You cannot transfer ownership of default workspace ${workspace1.name} (${workspace1.id})` + it('should not be able to delete the default workspace', async () => { + // Try deleting the default workspace + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.slug}` + }) + + expect(response.statusCode).toBe(400) + expect(response.json()).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: `You cannot delete the default workspace ${workspace1.name} (${workspace1.slug})` + }) }) }) @@ -1408,7 +1451,7 @@ describe('Workspace Controller Tests', () => { // Create projects const project1Response = await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Project 1', description: 'Project 1 description', @@ -1421,7 +1464,7 @@ describe('Workspace Controller Tests', () => { ) const project2Response = await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Project 2', description: 'Project 2 description', @@ -1434,7 +1477,7 @@ describe('Workspace Controller Tests', () => { ) const project3Response = await projectService.createProject( user1, - workspace1.id, + workspace1.slug, { name: 'Project 3', description: 'Project 3 description', @@ -1448,7 +1491,7 @@ describe('Workspace Controller Tests', () => { ) // Update member role to include project 2 - await workspaceRoleService.updateWorkspaceRole(user1, memberRole.id, { + await workspaceRoleService.updateWorkspaceRole(user1, memberRole.slug, { authorities: [ Authority.READ_ENVIRONMENT, Authority.READ_PROJECT, @@ -1456,7 +1499,7 @@ describe('Workspace Controller Tests', () => { Authority.READ_VARIABLE, Authority.READ_WORKSPACE ], - projectIds: [project2Response.id] + projectSlugs: [project2Response.slug] }) const project1DevEnv = await prisma.environment.findUnique({ @@ -1491,12 +1534,12 @@ describe('Workspace Controller Tests', () => { name: 'API_KEY', entries: [ { - environmentId: project1DevEnv.id, + environmentSlug: project1DevEnv.slug, value: 'test' } ] }, - project1Response.id + project1Response.slug ) await secretService.createSecret( @@ -1505,12 +1548,12 @@ describe('Workspace Controller Tests', () => { name: 'API_TOKEN', entries: [ { - environmentId: project3DevEnv.id, + environmentSlug: project3DevEnv.slug, value: 'test' } ] }, - project3Response.id + project3Response.slug ) // Create variables @@ -1520,12 +1563,12 @@ describe('Workspace Controller Tests', () => { name: 'PORT', entries: [ { - environmentId: project1DevEnv.id, + environmentSlug: project1DevEnv.slug, value: '3000' } ] }, - project1Response.id + project1Response.slug ) await variableService.createVariable( @@ -1534,12 +1577,12 @@ describe('Workspace Controller Tests', () => { name: 'PORT_NUMBER', entries: [ { - environmentId: project2DevEnv.id, + environmentSlug: project2DevEnv.slug, value: '4000' } ] }, - project2Response.id + project2Response.slug ) }) @@ -1549,7 +1592,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user1.email }, - url: `/workspace/${workspace1.id}/global-search/project` + url: `/workspace/${workspace1.slug}/global-search/project` }) expect(response.statusCode).toBe(200) @@ -1562,7 +1605,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user1.email }, - url: `/workspace/${workspace1.id}/global-search/api` + url: `/workspace/${workspace1.slug}/global-search/api` }) expect(response.statusCode).toBe(200) @@ -1575,7 +1618,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user1.email }, - url: `/workspace/${workspace1.id}/global-search/port` + url: `/workspace/${workspace1.slug}/global-search/port` }) expect(response.statusCode).toBe(200) @@ -1588,7 +1631,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user1.email }, - url: `/workspace/${workspace1.id}/global-search/dev` + url: `/workspace/${workspace1.slug}/global-search/dev` }) expect(response.statusCode).toBe(200) @@ -1601,7 +1644,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user2.email }, - url: `/workspace/${workspace1.id}/global-search/project` + url: `/workspace/${workspace1.slug}/global-search/project` }) expect(response.statusCode).toBe(200) @@ -1614,7 +1657,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user2.email }, - url: `/workspace/${workspace1.id}/global-search/api` + url: `/workspace/${workspace1.slug}/global-search/api` }) expect(response.statusCode).toBe(200) @@ -1627,7 +1670,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user2.email }, - url: `/workspace/${workspace1.id}/global-search/port` + url: `/workspace/${workspace1.slug}/global-search/port` }) expect(response.statusCode).toBe(200) @@ -1640,7 +1683,7 @@ describe('Workspace Controller Tests', () => { headers: { 'x-e2e-user-email': user2.email }, - url: `/workspace/${workspace1.id}/global-search/dev` + url: `/workspace/${workspace1.slug}/global-search/dev` }) expect(response.statusCode).toBe(200) diff --git a/apps/cli/src/commands/environment/create.environment.ts b/apps/cli/src/commands/environment/create.environment.ts index b5e8882f..f663f496 100644 --- a/apps/cli/src/commands/environment/create.environment.ts +++ b/apps/cli/src/commands/environment/create.environment.ts @@ -34,19 +34,19 @@ export class CreateEnvironment extends BaseCommand { getArguments(): CommandArgument[] { return [ { - name: '', + name: '', description: - 'ID of the project under which you want to add the environment' + 'Slug of the project under which you want to add the environment' } ] } async action({ options, args }: CommandActionData): Promise { - const [projectId] = args + const [projectSlug] = args const { name, description } = await this.parseInput(options) - if (!projectId) { - Logger.error('Project ID is required') + if (!projectSlug) { + Logger.error('Project slug is required') return } @@ -55,7 +55,7 @@ export class CreateEnvironment extends BaseCommand { const environmentData = { name, description, - projectId + projectSlug } const headers = { @@ -72,7 +72,9 @@ export class CreateEnvironment extends BaseCommand { } = await environmentController.createEnvironment(environmentData, headers) if (success) { - Logger.info(`Environment created:${environment.name} (${environment.id})`) + Logger.info( + `Environment created:${environment.name} (${environment.slug})` + ) } else { Logger.error(`Failed to create environment: ${error.message}`) } diff --git a/apps/cli/src/commands/environment/get.environment.ts b/apps/cli/src/commands/environment/get.environment.ts index 4ea604e8..3c6df22a 100644 --- a/apps/cli/src/commands/environment/get.environment.ts +++ b/apps/cli/src/commands/environment/get.environment.ts @@ -18,24 +18,22 @@ export class GetEnvironment extends BaseCommand { getArguments(): CommandArgument[] { return [ { - name: '', - description: 'ID of the environment which you want to fetch.' + name: '', + description: 'Slug of the environment which you want to fetch.' } ] } async action({ args }: CommandActionData): Promise { - const [environmentId] = args + const [environmentSlug] = args - if (!environmentId) { - Logger.error('Environment ID is required') + if (!environmentSlug) { + Logger.error('Environment slug is required') return } - const apiKey = this.apiKey - const headers = { - 'x-keyshade-token': apiKey + 'x-keyshade-token': this.apiKey } const environmentController = new EnvironmentController(this.baseUrl) @@ -45,15 +43,15 @@ export class GetEnvironment extends BaseCommand { success, error, data: environment - } = await environmentController.getEnvironmentById( - { id: environmentId }, + } = await environmentController.getEnvironment( + { slug: environmentSlug }, headers ) if (success) { Logger.info('Environment fetched successfully:') Logger.info( - `Environment ID: ${environment.id}, Name: ${environment.name}, Description: ${environment.description}` + `Environment Slug: ${environment.slug}, Name: ${environment.name}, Description: ${environment.description}` ) } else { Logger.error(`Error fetching environment: ${error.message}`) diff --git a/apps/cli/src/commands/environment/list.environment.ts b/apps/cli/src/commands/environment/list.environment.ts index 85adcec6..1ec3dcba 100644 --- a/apps/cli/src/commands/environment/list.environment.ts +++ b/apps/cli/src/commands/environment/list.environment.ts @@ -18,29 +18,22 @@ export class ListEnvironment extends BaseCommand { getArguments(): CommandArgument[] { return [ { - name: '', - description: 'ID of the project whose environments you want.' + name: '', + description: 'Slug of the project whose environments you want.' } ] } async action({ args }: CommandActionData): Promise { - const [projectId] = args + const [projectSlug] = args - if (!projectId) { - Logger.error('Project ID is required') + if (!projectSlug) { + Logger.error('Project slug is required') return } - const apiKey = this.apiKey - const headers = { - 'x-keyshade-token': apiKey - } - - if (!apiKey) { - Logger.error('Base URL and API Key must be set as environment variables') - return + 'x-keyshade-token': this.apiKey } const environmentController = new EnvironmentController(this.baseUrl) @@ -51,7 +44,7 @@ export class ListEnvironment extends BaseCommand { data: environments, error } = await environmentController.getAllEnvironmentsOfProject( - { projectId }, + { projectSlug }, headers ) diff --git a/apps/cli/src/commands/environment/update.environment.ts b/apps/cli/src/commands/environment/update.environment.ts index a54d786f..186dc5fb 100644 --- a/apps/cli/src/commands/environment/update.environment.ts +++ b/apps/cli/src/commands/environment/update.environment.ts @@ -34,31 +34,29 @@ export class UpdateEnvironment extends BaseCommand { getArguments(): CommandArgument[] { return [ { - name: '', - description: 'ID of the environment which you want to update.' + name: '', + description: 'Slug of the environment which you want to update.' } ] } async action({ options, args }: CommandActionData): Promise { - const [environmentId] = args + const [environmentSlug] = args const { name, description } = options - if (!environmentId) { - Logger.error('Environment ID is required') + if (!environmentSlug) { + Logger.error('Environment slug is required') return } - const apiKey = this.apiKey - const headers = { - 'x-keyshade-token': apiKey + 'x-keyshade-token': this.apiKey } const environmentData = { name, description, - id: environmentId + slug: environmentSlug } const environmentController = new EnvironmentController(this.baseUrl) @@ -73,7 +71,7 @@ export class UpdateEnvironment extends BaseCommand { if (success) { Logger.info('Environment updated successfully') Logger.info( - `Environment ID: ${environment.id}, Name: ${environment.name}, Description: ${environment.description}` + `Environment Slug: ${environment.slug}, Name: ${environment.name}, Description: ${environment.description}` ) } else { Logger.error(`Error updating Environment: ${error}`) diff --git a/apps/cli/src/commands/init.command.ts b/apps/cli/src/commands/init.command.ts index d422c0a4..0b8904bd 100644 --- a/apps/cli/src/commands/init.command.ts +++ b/apps/cli/src/commands/init.command.ts @@ -16,17 +16,17 @@ export default class InitCommand extends BaseCommand { { short: '-w', long: '--workspace ', - description: 'Workspace name to configure' + description: 'Workspace slug to configure' }, { short: '-p', long: '--project ', - description: 'Project name to configure' + description: 'Project slug to configure' }, { short: '-e', long: '--environment ', - description: 'Environment to configure' + description: 'Environment slug to configure' }, { short: '-k', @@ -64,19 +64,19 @@ export default class InitCommand extends BaseCommand { if (!workspace) { workspace = await text({ - message: 'Enter the workspace name' + message: 'Enter the workspace slug' }) } if (!project) { project = await text({ - message: 'Enter the project name' + message: 'Enter the project slug' }) } if (!environment) { environment = await text({ - message: 'Enter the environment name' + message: 'Enter the environment slug' }) } diff --git a/apps/cli/src/commands/run.command.ts b/apps/cli/src/commands/run.command.ts index 0bf43381..d8e8bd06 100644 --- a/apps/cli/src/commands/run.command.ts +++ b/apps/cli/src/commands/run.command.ts @@ -24,8 +24,8 @@ import { SecretController, VariableController } from '@keyshade/api-client' export default class RunCommand extends BaseCommand { private processEnvironmentalVariables = {} - private projectId: string - private environmentId: string + private projectSlug: string + private environmentSlug: string private shouldRestart = false @@ -100,9 +100,9 @@ export default class RunCommand extends BaseCommand { ioClient.on('connect', async () => { ioClient.emit('register-client-app', { - workspaceName: data.workspace, - projectName: data.project, - environmentName: data.environment + workspaceSlug: data.workspace, + projectSlug: data.project, + environmentSlug: data.environment }) ioClient.on('configuration-updated', async (data: Configuration) => { @@ -135,10 +135,16 @@ export default class RunCommand extends BaseCommand { ioClient.on( 'client-registered', (registrationResponse: ClientRegisteredResponse) => { - Logger.info('Successfully registered to API') - - this.projectId = registrationResponse.projectId - this.environmentId = registrationResponse.environmentId + if (registrationResponse.success) { + this.projectSlug = data.project + this.environmentSlug = data.environment + Logger.info('Successfully registered to API') + } else { + Logger.error( + 'Error registering to API: ' + registrationResponse.message + ) + throw new Error(registrationResponse.message) + } } ) }) @@ -184,8 +190,8 @@ export default class RunCommand extends BaseCommand { const secretsResponse = await secretController.getAllSecretsOfEnvironment( { - environmentId: this.environmentId, - projectId: this.projectId + environmentId: this.environmentSlug, + projectId: this.projectSlug }, { 'x-keyshade-token': this.apiKey @@ -199,8 +205,8 @@ export default class RunCommand extends BaseCommand { const variablesResponse = await variableController.getAllVariablesOfEnvironment( { - environmentId: this.environmentId, - projectId: this.projectId + environmentId: this.environmentSlug, + projectId: this.projectSlug }, { 'x-keyshade-token': this.apiKey diff --git a/apps/cli/src/types/command/profile.types.d.ts b/apps/cli/src/types/command/profile.types.d.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/cli/src/types/command/run.types.d.ts b/apps/cli/src/types/command/run.types.d.ts index 3bb79469..2e44b83a 100644 --- a/apps/cli/src/types/command/run.types.d.ts +++ b/apps/cli/src/types/command/run.types.d.ts @@ -1,4 +1,4 @@ -import { ProjectRootConfig } from '../index.types' +import { type ProjectRootConfig } from '../index.types' export interface Configuration { name: string @@ -7,9 +7,8 @@ export interface Configuration { } export interface ClientRegisteredResponse { - workspaceId: string - projectId: string - environmentId: string + success: boolean + message: string } export interface RunData extends ProjectRootConfig { diff --git a/packages/api-client/src/controllers/environment.ts b/packages/api-client/src/controllers/environment.ts index 709084ae..02eb9919 100644 --- a/packages/api-client/src/controllers/environment.ts +++ b/packages/api-client/src/controllers/environment.ts @@ -7,8 +7,8 @@ import { DeleteEnvironmentResponse, GetAllEnvironmentsOfProjectRequest, GetAllEnvironmentsOfProjectResponse, - GetEnvironmentByIdRequest, - GetEnvironmentByIdResponse, + GetEnvironmentRequest, + GetEnvironmentResponse, UpdateEnvironmentRequest, UpdateEnvironmentResponse } from '@api-client/types/environment.types' @@ -39,7 +39,7 @@ export default class EnvironmentController { headers?: Record ): Promise> { const response = await this.apiClient.put( - `/api/environment/${request.id}`, + `/api/environment/${request.slug}`, request, headers ) @@ -47,23 +47,23 @@ export default class EnvironmentController { return await parseResponse(response) } - async getEnvironmentById( - request: GetEnvironmentByIdRequest, + async getEnvironment( + request: GetEnvironmentRequest, headers?: Record - ): Promise> { + ): Promise> { const response = await this.apiClient.get( - `/api/environment/${request.id}`, + `/api/environment/${request.slug}`, headers ) - return await parseResponse(response) + return await parseResponse(response) } async getAllEnvironmentsOfProject( request: GetAllEnvironmentsOfProjectRequest, headers?: Record ): Promise> { - let url = `/api/environment/all/${request.projectId}?` + let url = `/api/environment/all/${request.projectSlug}?` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) @@ -80,7 +80,7 @@ export default class EnvironmentController { headers?: Record ): Promise> { const response = await this.apiClient.delete( - `/api/environment/${request.id}`, + `/api/environment/${request.slug}`, headers ) diff --git a/packages/api-client/src/controllers/event.ts b/packages/api-client/src/controllers/event.ts index f14fd5cd..f4b1c092 100644 --- a/packages/api-client/src/controllers/event.ts +++ b/packages/api-client/src/controllers/event.ts @@ -18,7 +18,7 @@ export default class EventController { headers?: Record ): Promise> { const response = await this.apiClient.get( - `/api/event/${request.workspaceId}?source=${request.source}`, + `/api/event/${request.workspaceSlug}?source=${request.source}`, headers ) diff --git a/packages/api-client/src/controllers/integration.ts b/packages/api-client/src/controllers/integration.ts index bd08279e..e4d3ffe4 100644 --- a/packages/api-client/src/controllers/integration.ts +++ b/packages/api-client/src/controllers/integration.ts @@ -26,7 +26,7 @@ export default class IntegrationController { headers?: Record ): Promise> { const response = await this.apiClient.post( - `/api/integration/${request.workspaceId}`, + `/api/integration/${request.workspaceSlug}`, request, headers ) @@ -38,7 +38,7 @@ export default class IntegrationController { headers?: Record ): Promise> { const response = await this.apiClient.put( - `/api/integration/${request.integrationId}`, + `/api/integration/${request.integrationSlug}`, request, headers ) @@ -50,7 +50,7 @@ export default class IntegrationController { headers?: Record ): Promise> { const response = await this.apiClient.get( - `/api/integration/${request.integrationId}`, + `/api/integration/${request.integrationSlug}`, headers ) return await parseResponse(response) @@ -60,7 +60,7 @@ export default class IntegrationController { request: GetAllIntegrationRequest, headers?: Record ): Promise> { - let url = `/api/integration/all/${request.workspaceId}` + let url = `/api/integration/all/${request.workspaceSlug}` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) @@ -76,7 +76,7 @@ export default class IntegrationController { headers?: Record ): Promise> { const response = await this.apiClient.delete( - `/api/integration/${request.integrationId}`, + `/api/integration/${request.integrationSlug}`, headers ) return await parseResponse(response) diff --git a/packages/api-client/src/controllers/project.ts b/packages/api-client/src/controllers/project.ts index 1d4ed26a..2e08be1c 100644 --- a/packages/api-client/src/controllers/project.ts +++ b/packages/api-client/src/controllers/project.ts @@ -34,7 +34,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.post( - `/api/project/${request.workspaceId}`, + `/api/project/${request.workspaceSlug}`, request, headers ) @@ -47,7 +47,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.put( - `/api/project/${request.projectId}`, + `/api/project/${request.projectSlug}`, request, headers ) @@ -60,7 +60,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.delete( - `/api/project/${request.projectId}`, + `/api/project/${request.projectSlug}`, headers ) @@ -72,7 +72,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.get( - `/api/project/${request.projectId}`, + `/api/project/${request.projectSlug}`, headers ) @@ -84,7 +84,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.post( - `/api/project/${request.projectId}/fork`, + `/api/project/${request.projectSlug}/fork`, request, headers ) @@ -97,7 +97,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.put( - `/project/${request.projectId}/fork`, + `/project/${request.projectSlug}/fork`, request, headers ) @@ -110,7 +110,7 @@ export default class ProjectController { headers: Record ): Promise> { const response = await this.apiClient.delete( - `/api/project/${request.projectId}/fork`, + `/api/project/${request.projectSlug}/fork`, headers ) @@ -121,7 +121,7 @@ export default class ProjectController { request: GetForkRequest, headers: Record ): Promise> { - let url = `/api/project/${request.projectId}/forks` + let url = `/api/project/${request.projectSlug}/forks` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) @@ -136,7 +136,7 @@ export default class ProjectController { request: GetAllProjectsRequest, headers: Record ): Promise> { - let url = `/api/project/all/${request.workspaceId}` + let url = `/api/project/all/${request.workspaceSlug}` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) diff --git a/packages/api-client/src/controllers/secret.ts b/packages/api-client/src/controllers/secret.ts index 0cd66b63..51f10e83 100644 --- a/packages/api-client/src/controllers/secret.ts +++ b/packages/api-client/src/controllers/secret.ts @@ -28,7 +28,7 @@ export default class SecretController { headers?: Record ): Promise> { const response = await this.apiClient.post( - `/api/secret/${request.projectId}`, + `/api/secret/${request.projectSlug}`, request, headers ) @@ -41,7 +41,7 @@ export default class SecretController { headers?: Record ): Promise> { const response = await this.apiClient.put( - `/api/secret/${request.secretId}`, + `/api/secret/${request.secretSlug}`, request, headers ) @@ -54,7 +54,7 @@ export default class SecretController { headers?: Record ): Promise> { const response = await this.apiClient.put( - `/api/secret/${request.secretId}/rollback/${request.version}?environmentId=${request.environmentId}`, + `/api/secret/${request.secretSlug}/rollback/${request.version}?environmentSlug=${request.environmentSlug}`, request, headers ) @@ -67,7 +67,7 @@ export default class SecretController { headers?: Record ): Promise> { const response = await this.apiClient.delete( - `/api/secret/${request.secretId}`, + `/api/secret/${request.secretSlug}`, headers ) @@ -78,7 +78,7 @@ export default class SecretController { request: GetAllSecretsOfProjectRequest, headers?: Record ): Promise> { - let url = `/api/secret/${request.projectId}?decryptValue=true` + let url = `/api/secret/${request.projectSlug}?decryptValue=true` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) @@ -93,7 +93,7 @@ export default class SecretController { request: GetAllSecretsOfEnvironmentRequest, headers?: Record ): Promise> { - const url = `/api/secret/${request.projectId}/${request.environmentId}` + const url = `/api/secret/${request.projectSlug}/${request.environmentSlug}` const response = await this.apiClient.get(url, headers) return await parseResponse(response) diff --git a/packages/api-client/src/controllers/variable.ts b/packages/api-client/src/controllers/variable.ts index 521da12d..7d990bfd 100644 --- a/packages/api-client/src/controllers/variable.ts +++ b/packages/api-client/src/controllers/variable.ts @@ -28,7 +28,7 @@ export default class VariableController { headers: Record ): Promise> { const response = await this.apiClient.post( - `/api/variable/${request.projectId}`, + `/api/variable/${request.projectSlug}`, request, headers ) @@ -40,7 +40,7 @@ export default class VariableController { headers: Record ): Promise> { const response = await this.apiClient.put( - `/api/variable/${request.variableId}`, + `/api/variable/${request.variableSlug}`, request, headers ) @@ -53,7 +53,7 @@ export default class VariableController { headers: Record ): Promise> { const response = await this.apiClient.put( - `/api/variable/${request.variableId}/rollback/${request.version}?environmentId=${request.environmentId}`, + `/api/variable/${request.variableSlug}/rollback/${request.version}?environmentSlug=${request.environmentSlug}`, request, headers ) @@ -66,7 +66,7 @@ export default class VariableController { headers: Record ): Promise> { const response = await this.apiClient.delete( - `/api/variable/${request.variableId}`, + `/api/variable/${request.variableSlug}`, headers ) @@ -77,7 +77,7 @@ export default class VariableController { request: GetAllVariablesOfProjectRequest, headers: Record ): Promise> { - let url = `/api/variable/${request.projectId}` + let url = `/api/variable/${request.projectSlug}` request.page && (url += `page=${request.page}&`) request.limit && (url += `limit=${request.limit}&`) request.sort && (url += `sort=${request.sort}&`) @@ -92,7 +92,7 @@ export default class VariableController { request: GetAllVariablesOfEnvironmentRequest, headers: Record ): Promise> { - const url = `/api/variable/${request.projectId}/${request.environmentId}` + const url = `/api/variable/${request.projectSlug}/${request.environmentSlug}` const response = await this.apiClient.get(url, headers) return await parseResponse(response) diff --git a/packages/api-client/src/core/response-parser.ts b/packages/api-client/src/core/response-parser.ts index e44f3d01..dcd65e63 100644 --- a/packages/api-client/src/core/response-parser.ts +++ b/packages/api-client/src/core/response-parser.ts @@ -1,5 +1,13 @@ import { ClientResponse, ResponseError } from '@api-client/types/index.types' +/** + * Takes a Response object and parses its contents into a ClientResponse object. + * This function assumes that the response is either a successful JSON response, + * or a failed JSON response with an error object in its body. + * + * @param {Response} response - The response object to parse. + * @returns {Promise>} A promise that resolves to a ClientResponse object. + */ export async function parseResponse( response: Response ): Promise> { diff --git a/packages/api-client/src/types/environment.types.d.ts b/packages/api-client/src/types/environment.types.d.ts index 05540791..ab864f84 100644 --- a/packages/api-client/src/types/environment.types.d.ts +++ b/packages/api-client/src/types/environment.types.d.ts @@ -9,6 +9,7 @@ export interface CreateEnvironmentRequest { export interface CreateEnvironmentResponse { id: string name: string + slug: string description: string | null createdAt: string updatedAt: string @@ -17,7 +18,7 @@ export interface CreateEnvironmentResponse { } export interface UpdateEnvironmentRequest { - id: string + slug: string name?: string description?: string } @@ -25,6 +26,7 @@ export interface UpdateEnvironmentRequest { export interface UpdateEnvironmentResponse { id: string name: string + slug: string description: string | null createdAt: string updatedAt: string @@ -32,13 +34,14 @@ export interface UpdateEnvironmentResponse { projectId: string } -export interface GetEnvironmentByIdRequest { - id: string +export interface GetEnvironmentRequest { + slug: string } -export interface GetEnvironmentByIdResponse { +export interface GetEnvironmentResponse { id: string name: string + slug: string description: string | null createdAt: string updatedAt: string @@ -47,7 +50,7 @@ export interface GetEnvironmentByIdResponse { } export interface GetAllEnvironmentsOfProjectRequest { - projectId: string + projectSlug: string page?: number limit?: number sort?: string @@ -58,6 +61,7 @@ export interface GetAllEnvironmentsOfProjectRequest { export interface GetAllEnvironmentsOfProjectResponse extends Page<{ id: string + slug: string name: string description: string | null createdAt: string @@ -71,7 +75,7 @@ export interface GetAllEnvironmentsOfProjectResponse }> {} export interface DeleteEnvironmentRequest { - id: string + slug: string } export interface DeleteEnvironmentResponse {} diff --git a/packages/api-client/src/types/event.types.d.ts b/packages/api-client/src/types/event.types.d.ts index d893dece..984ca32a 100644 --- a/packages/api-client/src/types/event.types.d.ts +++ b/packages/api-client/src/types/event.types.d.ts @@ -52,7 +52,7 @@ export enum EventType { } export interface GetEventsRequest { - workspaceId: string + workspaceSlug: string source: string } diff --git a/packages/api-client/src/types/integration.types.d.ts b/packages/api-client/src/types/integration.types.d.ts index e60d1619..7bdc40ff 100644 --- a/packages/api-client/src/types/integration.types.d.ts +++ b/packages/api-client/src/types/integration.types.d.ts @@ -37,18 +37,19 @@ export enum EventType { INTEGRATION_DELETED } export interface CreateIntegrationRequest { - workspaceId?: string - projectId?: string + workspaceSlug?: string + projectSlug?: string name: string type: string notifyOn: [string] metadata: Record - environmentId: string + environmentSlug: string } export interface CreateIntegrationResponse { id: string name: string + slug: string metadata: Record createdAt: string updatedAt: string @@ -60,9 +61,9 @@ export interface CreateIntegrationResponse { } export interface UpdateIntegrationRequest { - integrationId: string - workspaceId?: string - projectId?: string + integrationSlug: string + workspaceSlug?: string + projectSlug?: string name?: string type?: IntegrationType notifyOn?: EventType[] @@ -73,6 +74,7 @@ export interface UpdateIntegrationRequest { export interface UpdateIntegrationResponse { id: string name: string + slug: string metadata: Record createdAt: string updatedAt: string @@ -86,16 +88,17 @@ export interface UpdateIntegrationResponse { export interface DeleteIntegrationResponse {} export interface DeleteIntegrationRequest { - integrationId: string + integrationSlug: string } export interface GetIntegrationRequest { - integrationId: string + integrationSlug: string } export interface GetIntegrationResponse { id: string name: string + slug: string metadata: Record createdAt: string updatedAt: string @@ -112,13 +115,14 @@ export interface GetAllIntegrationRequest { sort?: string order?: string search?: string - workspaceId: string + workspaceSlug: string } export interface GetAllIntegrationResponse extends Page<{ id: string name: string + slug: string metadata: Record createdAt: string updatedAt: string diff --git a/packages/api-client/src/types/project.types.d.ts b/packages/api-client/src/types/project.types.d.ts index dd72be53..f8ac2542 100644 --- a/packages/api-client/src/types/project.types.d.ts +++ b/packages/api-client/src/types/project.types.d.ts @@ -2,7 +2,7 @@ import { Page } from './index.types' export interface CreateProjectRequest { name: string - workspaceId: string + workspaceSlug: string description?: string storePrivateKey?: boolean environments?: CreateEnvironment[] @@ -12,6 +12,7 @@ export interface CreateProjectRequest { export interface CreateProjectResponse { id: string name: string + slug: string description: string createdAt: string updatedAt: string @@ -28,13 +29,14 @@ export interface CreateProjectResponse { } export interface UpdateProjectRequest { - projectId: string + projectSlug: string name?: string } export interface UpdateProjectResponse { id: string name: string + slug: string description: string createdAt: string updatedAt: string @@ -51,18 +53,19 @@ export interface UpdateProjectResponse { } export interface DeleteProjectRequest { - projectId: string + projectSlug: string } export interface DeleteProjectResponse {} export interface GetProjectRequest { - projectId: string + projectSlug: string } export interface GetProjectResponse { id: string name: string + slug: string description: string createdAt: string updatedAt: string @@ -79,15 +82,16 @@ export interface GetProjectResponse { } export interface ForkProjectRequest { - projectId: string + projectSlug: string name?: string - workspaceId?: string + workspaceSlug?: string storePrivateKey?: boolean } export interface ForkProjectResponse { id: string name: string + slug: string description: string createdAt: string updatedAt: string @@ -104,21 +108,21 @@ export interface ForkProjectResponse { } export interface SyncProjectRequest { - projectId: string + projectSlug: string } export interface SyncProjectResponse {} export interface UnlinkProjectRequest { - projectId: string - workspaceId: string + projectSlug: string + workspaceSlug: string } export interface UnlinkProjectResponse {} export interface GetForkRequest { - projectId: string - workspaceId: string + projectSlug: string + workspaceSlug: string page?: number limit?: number sort?: string @@ -130,6 +134,7 @@ export interface GetForkResponse extends Page<{ id: string name: string + slug: string description: string createdAt: string updatedAt: string @@ -146,7 +151,7 @@ export interface GetForkResponse }> {} export interface GetAllProjectsRequest { - workspaceId: string + workspaceSlug: string page?: number limit?: number sort?: string @@ -158,4 +163,16 @@ export interface GetAllProjectsResponse extends Page<{ id: string name: string + slug: string + description: string + createdAt: string + updatedAt: string + storePrivateKey: boolean + isDisabled: boolean + accessLevel: string + pendingCreation: boolean + isForked: boolean + lastUpdatedById: string + workspaceId: string + forkedFromId: string }> {} diff --git a/packages/api-client/src/types/secret.types.d.ts b/packages/api-client/src/types/secret.types.d.ts index 4aeebc01..cc37b6fd 100644 --- a/packages/api-client/src/types/secret.types.d.ts +++ b/packages/api-client/src/types/secret.types.d.ts @@ -1,14 +1,14 @@ import { Page } from './index.types' export interface CreateSecretRequest { - projectId: string + projectSlug: string name: string note?: string rotateAfter?: '24' | '168' | '720' | '8760' | 'never' entries?: [ { value: string - environmentId: string + environmentSlug: string } ] } @@ -16,6 +16,7 @@ export interface CreateSecretRequest { export interface CreateSecretResponse { id: string name: string + slug: string createdAt: string updatedAt: string rotateAt: string | null @@ -34,14 +35,14 @@ export interface CreateSecretResponse { } export interface UpdateSecretRequest { - secretId: string + secretSlug: string name?: string note?: string rotateAfter?: '24' | '168' | '720' | '8760' | 'never' entries?: [ { value: string - environmentId: string + environmentSlug: string } ] } @@ -51,6 +52,7 @@ export interface UpdateSecretResponse { id: string name: string note: string + slug: string } updatedVersions: [ { @@ -62,22 +64,22 @@ export interface UpdateSecretResponse { } export interface DeleteSecretRequest { - secretId: string + secretSlug: string } export interface DeleteSecretResponse {} export interface RollBackSecretRequest { - environmentId: string + environmentSlug: string version: number - secretId: string + secretSlug: string } export interface RollBackSecretResponse { count: string } export interface GetAllSecretsOfProjectRequest { - projectId: string + projectSlug: string page?: number limit?: number sort?: string @@ -88,6 +90,7 @@ export interface GetAllSecretsOfProjectResponse extends Page<{ secret: { id: string + slug: string name: string createdAt: string updatedAt: string @@ -111,8 +114,8 @@ export interface GetAllSecretsOfProjectResponse }> {} export interface GetAllSecretsOfEnvironmentRequest { - projectId: string - environmentId: string + projectSlug: string + environmentSlug: string } export type GetAllSecretsOfEnvironmentResponse = { name: string diff --git a/packages/api-client/src/types/variable.types.d.ts b/packages/api-client/src/types/variable.types.d.ts index a7b245e8..cb2eced6 100644 --- a/packages/api-client/src/types/variable.types.d.ts +++ b/packages/api-client/src/types/variable.types.d.ts @@ -1,13 +1,13 @@ import { Page } from './index.types' export interface CreateVariableRequest { - projectId: string + projectSlug: string name: string note?: string entries?: [ { value: string - environmentId: string + environmentSlug: string } ] } @@ -15,6 +15,7 @@ export interface CreateVariableRequest { export interface CreateVariableResponse { id: string name: string + slug: string createdAt: string updatedAt: string note: string | null @@ -31,12 +32,12 @@ export interface CreateVariableResponse { ] } export interface UpdateVariableRequest { - variableId: string + variableSlug: string name?: string entries?: [ { value: string - environmentId: string + environmentSlug: string } ] } @@ -45,6 +46,7 @@ export interface UpdateVariableResponse { id: string name: string note: string + slug: string } updatedVersions: [ { @@ -55,9 +57,9 @@ export interface UpdateVariableResponse { } export interface RollBackVariableRequest { - variableId: string + variableSlug: string version: number - environmentId: string + environmentSlug: string } export interface RollBackVariableResponse { @@ -65,13 +67,13 @@ export interface RollBackVariableResponse { } export interface DeleteVariableRequest { - variableId: string + variableSlug: string } export interface DeleteVariableResponse {} export interface GetAllVariablesOfProjectRequest { - projectId: string + projectSlug: string page?: number limit?: number sort?: string @@ -84,6 +86,7 @@ export interface GetAllVariablesOfProjectResponse variable: { id: string name: string + slug: string createdAt: string updatedAt: string note: string | null @@ -105,8 +108,8 @@ export interface GetAllVariablesOfProjectResponse }> {} export interface GetAllVariablesOfEnvironmentRequest { - projectId: string - environmentId: string + projectSlug: string + environmentSlug: string page?: number limit?: number sort?: string diff --git a/packages/api-client/tests/environment.spec.ts b/packages/api-client/tests/environment.spec.ts index 073aad92..4bdfca9c 100644 --- a/packages/api-client/tests/environment.spec.ts +++ b/packages/api-client/tests/environment.spec.ts @@ -8,9 +8,9 @@ describe('Environments Controller Tests', () => { const environmentController = new EnvironmentController(backendUrl) const email = 'johndoe@example.com' - let projectId: string | null - let workspaceId: string | null - let environmentId: string | null + let projectSlug: string | null + let workspaceSlug: string | null + let environmentSlug: string | null beforeAll(async () => { //Create the user's workspace @@ -26,12 +26,12 @@ describe('Environments Controller Tests', () => { ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug // Create a project const projectResponse = (await ( await client.post( - `/api/project/${workspaceId}`, + `/api/project/${workspaceSlug}`, { name: 'Project', storePrivateKey: true @@ -42,12 +42,12 @@ describe('Environments Controller Tests', () => { ) ).json()) as any - projectId = projectResponse.id + projectSlug = projectResponse.slug }) afterAll(async () => { // Delete the workspace - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -56,7 +56,7 @@ describe('Environments Controller Tests', () => { // Create an environment const createEnvironmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Dev' }, @@ -66,12 +66,12 @@ describe('Environments Controller Tests', () => { ) ).json()) as any - environmentId = createEnvironmentResponse.id + environmentSlug = createEnvironmentResponse.slug }) afterEach(async () => { // Delete the environment - await client.delete(`/api/environment/${environmentId}`, { + await client.delete(`/api/environment/${environmentSlug}`, { 'x-e2e-user-email': email }) }) @@ -80,7 +80,7 @@ describe('Environments Controller Tests', () => { const environments = ( await environmentController.getAllEnvironmentsOfProject( { - projectId, + projectSlug, page: 0, limit: 10 }, @@ -96,23 +96,23 @@ describe('Environments Controller Tests', () => { //check metadata expect(environments.metadata.totalCount).toEqual(2) expect(environments.metadata.links.self).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(environments.metadata.links.first).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(environments.metadata.links.previous).toBeNull() expect(environments.metadata.links.next).toBeNull() expect(environments.metadata.links.last).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) }) - it('should be able to fetch environment by ID', async () => { + it('should be able to fetch environment by slug', async () => { const environmentResponse = ( - await environmentController.getEnvironmentById( + await environmentController.getEnvironment( { - id: environmentId + slug: environmentSlug }, { 'x-e2e-user-email': email @@ -120,7 +120,7 @@ describe('Environments Controller Tests', () => { ) ).data - expect(environmentResponse.id).toBe(environmentId) + expect(environmentResponse.slug).toBe(environmentSlug) expect(environmentResponse.name).toBe('Dev') }) @@ -128,7 +128,7 @@ describe('Environments Controller Tests', () => { const createEnvironmentResponse = ( await environmentController.createEnvironment( { - projectId, + projectId: projectSlug, name: 'Prod' }, { @@ -140,7 +140,7 @@ describe('Environments Controller Tests', () => { expect(createEnvironmentResponse.name).toBe('Prod') const fetchEnvironmentResponse = (await ( - await client.get(`/api/environment/${createEnvironmentResponse.id}`, { + await client.get(`/api/environment/${createEnvironmentResponse.slug}`, { 'x-e2e-user-email': email }) ).json()) as any @@ -148,7 +148,7 @@ describe('Environments Controller Tests', () => { expect(fetchEnvironmentResponse.name).toBe('Prod') // Delete the environment - await client.delete(`/api/environment/${createEnvironmentResponse.id}`, { + await client.delete(`/api/environment/${createEnvironmentResponse.slug}`, { 'x-e2e-user-email': email }) }) @@ -157,7 +157,7 @@ describe('Environments Controller Tests', () => { const updateEnvironmentResponse = ( await environmentController.updateEnvironment( { - id: environmentId, + slug: environmentSlug, name: 'Prod' }, { @@ -169,19 +169,24 @@ describe('Environments Controller Tests', () => { expect(updateEnvironmentResponse.name).toBe('Prod') const fetchEnvironmentResponse = (await ( - await client.get(`/api/environment/${environmentId}`, { + await client.get(`/api/environment/${updateEnvironmentResponse.slug}`, { 'x-e2e-user-email': email }) ).json()) as any expect(fetchEnvironmentResponse.name).toBe('Prod') + + // Delete this environment + await client.delete(`/api/environment/${updateEnvironmentResponse.slug}`, { + 'x-e2e-user-email': email + }) }) it('should be able to delete an environment', async () => { // Create an environment const createEnvironmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Prod' }, @@ -193,7 +198,7 @@ describe('Environments Controller Tests', () => { await environmentController.deleteEnvironment( { - id: createEnvironmentResponse.id + slug: createEnvironmentResponse.slug }, { 'x-e2e-user-email': email @@ -204,7 +209,7 @@ describe('Environments Controller Tests', () => { const environments = ( await environmentController.getAllEnvironmentsOfProject( { - projectId + projectSlug }, { 'x-e2e-user-email': email @@ -215,15 +220,15 @@ describe('Environments Controller Tests', () => { expect(environments.items).toHaveLength(2) expect(environments.metadata.totalCount).toEqual(2) expect(environments.metadata.links.self).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(environments.metadata.links.first).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) expect(environments.metadata.links.previous).toBeNull() expect(environments.metadata.links.next).toBeNull() expect(environments.metadata.links.last).toBe( - `/environment/all/${projectId}?page=0&limit=10&sort=name&order=asc&search=` + `/environment/all/${projectSlug}?page=0&limit=10&sort=name&order=asc&search=` ) }) }) diff --git a/packages/api-client/tests/event.spec.ts b/packages/api-client/tests/event.spec.ts index 2635cea7..9166670b 100644 --- a/packages/api-client/tests/event.spec.ts +++ b/packages/api-client/tests/event.spec.ts @@ -16,8 +16,8 @@ describe('Event Controller Tests', () => { const client = new APIClient(backendUrl) const eventController = new EventController(backendUrl) const email = 'johndoe@example.com' - let projectId: string | null - let workspaceId: string | null + let projectSlug: string | null + let workspaceSlug: string | null let environment: any beforeAll(async () => { @@ -33,12 +33,12 @@ describe('Event Controller Tests', () => { } ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug }) afterAll(async () => { // Delete the workspace - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -46,7 +46,7 @@ describe('Event Controller Tests', () => { it('should fetch a Project Event', async () => { const projectResponse = (await ( await client.post( - `/api/project/${workspaceId}`, + `/api/project/${workspaceSlug}`, { name: 'Project', storePrivateKey: true @@ -57,20 +57,19 @@ describe('Event Controller Tests', () => { ) ).json()) as any - projectId = projectResponse.id + projectSlug = projectResponse.slug const events = await eventController.getEvents( - { workspaceId, source: 'PROJECT' }, + { workspaceSlug, source: 'PROJECT' }, { 'x-e2e-user-email': email } ) expect(events.data.items[0].source).toBe(EventSource.PROJECT) - expect(events.data.items[0].metadata.projectId).toBe(projectId) expect(events.data.items[0].metadata.name).toBe('Project') }) it('should fetch a Environment Event', async () => { const environmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Dev' }, @@ -80,7 +79,7 @@ describe('Event Controller Tests', () => { ) ).json()) as any const events = await eventController.getEvents( - { workspaceId, source: EventSource.ENVIRONMENT }, + { workspaceSlug, source: EventSource.ENVIRONMENT }, { 'x-e2e-user-email': email } ) expect(events.data.items[0].source).toBe('ENVIRONMENT') @@ -92,15 +91,15 @@ describe('Event Controller Tests', () => { }) it('should fetch a Secret Event', async () => { - const secretRepsonse = (await ( + const secretResponse = (await ( await client.post( - `/api/secret/${projectId}`, + `/api/secret/${projectSlug}`, { name: 'My secret', entries: [ { value: 'My value', - environmentId: environment.id + environmentSlug: environment.slug } ], note: 'Some note', @@ -112,24 +111,24 @@ describe('Event Controller Tests', () => { ) ).json()) as any const events = await eventController.getEvents( - { workspaceId, source: EventSource.SECRET }, + { workspaceSlug, source: EventSource.SECRET }, { 'x-e2e-user-email': email } ) expect(events.data.items[0].source).toBe('SECRET') - expect(events.data.items[0].metadata.secretId).toBe(secretRepsonse.id) + expect(events.data.items[0].metadata.secretId).toBe(secretResponse.id) expect(events.data.items[0].metadata.name).toBe('My secret') }) it('should fetch a Variable Event', async () => { const variableResponse = (await ( await client.post( - `/api/variable/${projectId}`, + `/api/variable/${projectSlug}`, { name: 'My variable', entries: [ { value: 'My value', - environmentId: environment.id + environmentSlug: environment.slug } ], note: 'Some note' @@ -140,7 +139,7 @@ describe('Event Controller Tests', () => { ) ).json()) as any const events = await eventController.getEvents( - { workspaceId, source: EventSource.VARIABLE }, + { workspaceSlug, source: EventSource.VARIABLE }, { 'x-e2e-user-email': email } ) expect(events.data.items[0].source).toBe('VARIABLE') diff --git a/packages/api-client/tests/integration.spec.ts b/packages/api-client/tests/integration.spec.ts index 9ab16b36..3fd0aa19 100644 --- a/packages/api-client/tests/integration.spec.ts +++ b/packages/api-client/tests/integration.spec.ts @@ -7,10 +7,10 @@ describe('Get Environments Tests', () => { const client = new APIClient(backendUrl) const integrationController = new IntegrationController(backendUrl) const email = 'johndoe@example.com' - let projectId: string | undefined - let workspaceId: string + let projectSlug: string | undefined + let workspaceSlug: string let environment: any - let integrationId: string + let integrationSlug: string beforeAll(async () => { // Create the user's workspace @@ -26,12 +26,12 @@ describe('Get Environments Tests', () => { ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug // Create a project const projectResponse = (await ( await client.post( - `/api/project/${workspaceId}`, + `/api/project/${workspaceSlug}`, { name: 'Project', storePrivateKey: true @@ -42,11 +42,11 @@ describe('Get Environments Tests', () => { ) ).json()) as any - projectId = projectResponse.id + projectSlug = projectResponse.slug const createEnvironmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Dev' }, @@ -61,7 +61,7 @@ describe('Get Environments Tests', () => { afterAll(async () => { // Delete the workspace - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -70,27 +70,27 @@ describe('Get Environments Tests', () => { // Create a dummy integration before each test const integration = await integrationController.createIntegration( { - workspaceId, - projectId, + workspaceSlug, + projectSlug, name: 'Dummy Integration', type: 'DISCORD', notifyOn: ['PROJECT_CREATED'], metadata: { webhookUrl: '{{vault:WEBHOOK_URL}}' }, - environmentId: environment.id + environmentSlug: environment.slug }, { 'x-e2e-user-email': email } ) - integrationId = integration.data?.id as string + integrationSlug = integration.data?.slug as string }) afterEach(async () => { // Delete the dummy integration after each test await integrationController.deleteIntegration( - { integrationId }, + { integrationSlug }, { 'x-e2e-user-email': email } ) }) @@ -98,39 +98,42 @@ describe('Get Environments Tests', () => { it('should create an integration', async () => { const integration = await integrationController.createIntegration( { - workspaceId, - projectId, + workspaceSlug, + projectSlug, name: 'Discord second', type: 'DISCORD', notifyOn: ['PROJECT_CREATED'], metadata: { webhookUrl: '{{vault:WEBHOOK_URL}}' }, - environmentId: environment.id + environmentSlug: environment.slug }, { 'x-e2e-user-email': email } ) expect(integration.data.name).toBe('Discord second') - expect(integration.data.projectId).toBe(projectId) - expect(integration.data.environmentId).toBe(environment.id) - expect(integration.data.workspaceId).toBe(workspaceId) expect(integration.data.type).toBe('DISCORD') }) it('should update the integration', async () => { const updatedIntegration: any = await integrationController.updateIntegration( - { integrationId, name: 'Github second' }, + { integrationSlug, name: 'Github second' }, { 'x-e2e-user-email': email } ) expect(updatedIntegration.data.name).toBe('Github second') + + // Delete the integration + await integrationController.deleteIntegration( + { integrationSlug: updatedIntegration.data.slug }, + { 'x-e2e-user-email': email } + ) }) it('should get an integration', async () => { const integration: any = await integrationController.getIntegration( - { integrationId }, + { integrationSlug }, { 'x-e2e-user-email': email } ) expect(integration).toBeDefined() @@ -140,22 +143,22 @@ describe('Get Environments Tests', () => { // Adding another integration await integrationController.createIntegration( { - workspaceId, - projectId, + workspaceSlug, + projectSlug, name: 'Discord third', type: 'DISCORD', notifyOn: ['PROJECT_CREATED'], metadata: { webhookUrl: '{{vault:WEBHOOK_URL}}' }, - environmentId: environment.id + environmentSlug: environment.slug }, { 'x-e2e-user-email': email } ) const integrations: any = await integrationController.getAllIntegrations( - { workspaceId }, + { workspaceSlug }, { 'x-e2e-user-email': email } ) expect(integrations.data?.items.length).toBe(3) @@ -163,11 +166,11 @@ describe('Get Environments Tests', () => { it('should delete an integration', async () => { await integrationController.deleteIntegration( - { integrationId }, + { integrationSlug }, { 'x-e2e-user-email': email } ) const integrations: any = await integrationController.getAllIntegrations( - { workspaceId }, + { workspaceSlug }, { 'x-e2e-user-email': email } ) expect(integrations.data.items.length).toBe(2) diff --git a/packages/api-client/tests/project.spec.ts b/packages/api-client/tests/project.spec.ts index e09e873f..c0e8d5c3 100644 --- a/packages/api-client/tests/project.spec.ts +++ b/packages/api-client/tests/project.spec.ts @@ -8,8 +8,8 @@ describe('Get Project Tests', () => { const projectController = new ProjectController(backendUrl) const email = 'johndoe@example.com' - let projectId: string | null - let workspaceId: string | null + let projectSlug: string | null + let workspaceSlug: string | null beforeAll(async () => { //Create the user's workspace @@ -25,12 +25,12 @@ describe('Get Project Tests', () => { ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug }) afterAll(async () => { // Delete the workspace - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -43,7 +43,7 @@ describe('Get Project Tests', () => { name: 'Project', description: 'Project Description', storePrivateKey: true, - workspaceId, + workspaceSlug, accessLevel: 'GLOBAL' }, { @@ -52,12 +52,12 @@ describe('Get Project Tests', () => { ) ).data - projectId = project.id + projectSlug = project.slug }) afterEach(async () => { // Delete all projects - await client.delete(`/api/project/${projectId}`, { + await client.delete(`/api/project/${projectSlug}`, { 'x-e2e-user-email': email }) }) @@ -69,7 +69,7 @@ describe('Get Project Tests', () => { name: 'Project 2', description: 'Project Description', storePrivateKey: true, - workspaceId, + workspaceSlug, accessLevel: 'GLOBAL' }, { @@ -78,11 +78,10 @@ describe('Get Project Tests', () => { ) ).data - expect(project.id).toBeDefined() + expect(project.slug).toBeDefined() expect(project.name).toBe('Project 2') expect(project.description).toBe('Project Description') expect(project.storePrivateKey).toBe(true) - expect(project.workspaceId).toBe(workspaceId) expect(project.publicKey).toBeDefined() expect(project.privateKey).toBeDefined() expect(project.createdAt).toBeDefined() @@ -91,7 +90,7 @@ describe('Get Project Tests', () => { // Delete the project await projectController.deleteProject( { - projectId: project.id + projectSlug: project.slug }, { 'x-e2e-user-email': email @@ -99,11 +98,37 @@ describe('Get Project Tests', () => { ) }) - it('should be able to get a project by ID', async () => { + it('should be able to update a project', async () => { + const project = ( + await projectController.updateProject( + { + projectSlug, + name: 'Updated Project' + }, + { + 'x-e2e-user-email': email + } + ) + ).data + + expect(project.name).toBe('Updated Project') + + // Delete the project + await projectController.deleteProject( + { + projectSlug: project.slug + }, + { + 'x-e2e-user-email': email + } + ) + }) + + it('should be able to get a project by slug', async () => { const project = ( await projectController.getProject( { - projectId + projectSlug }, { 'x-e2e-user-email': email @@ -111,7 +136,7 @@ describe('Get Project Tests', () => { ) ).data - expect(project.id).toBe(projectId) + expect(project.slug).toBe(projectSlug) expect(project.name).toBe('Project') }) @@ -119,8 +144,8 @@ describe('Get Project Tests', () => { const fork = ( await projectController.forkProject( { - projectId, - workspaceId, + projectSlug, + workspaceSlug, name: 'Forked Stuff' }, { @@ -130,12 +155,11 @@ describe('Get Project Tests', () => { ).data expect(fork.isForked).toBe(true) - expect(fork.forkedFromId).toBe(projectId) // Delete the fork await projectController.deleteProject( { - projectId: fork.id + projectSlug: fork.slug }, { 'x-e2e-user-email': email @@ -148,8 +172,8 @@ describe('Get Project Tests', () => { const fork = ( await projectController.forkProject( { - projectId, - workspaceId, + projectSlug, + workspaceSlug, name: 'Forked Stuff' }, { @@ -160,8 +184,8 @@ describe('Get Project Tests', () => { const forks = await projectController.getForks( { - projectId, - workspaceId + projectSlug, + workspaceSlug }, { 'x-e2e-user-email': email @@ -172,7 +196,7 @@ describe('Get Project Tests', () => { // Delete the fork await projectController.deleteProject( { - projectId: fork.id + projectSlug: fork.slug }, { 'x-e2e-user-email': email @@ -185,8 +209,8 @@ describe('Get Project Tests', () => { const fork = ( await projectController.forkProject( { - projectId, - workspaceId, + projectSlug, + workspaceSlug, name: 'Forked Stuff' }, { @@ -198,8 +222,8 @@ describe('Get Project Tests', () => { // Unlink the fork await projectController.unlinkFork( { - projectId: fork.id, - workspaceId + projectSlug: fork.slug, + workspaceSlug }, { 'x-e2e-user-email': email @@ -208,8 +232,8 @@ describe('Get Project Tests', () => { const forks = await projectController.getForks( { - projectId, - workspaceId + projectSlug, + workspaceSlug }, { 'x-e2e-user-email': email @@ -220,7 +244,7 @@ describe('Get Project Tests', () => { // Delete the fork await projectController.deleteProject( { - projectId: fork.id + projectSlug: fork.slug }, { 'x-e2e-user-email': email @@ -231,7 +255,7 @@ describe('Get Project Tests', () => { it('should get all projects in the workspace', async () => { const projects = await projectController.getAllProjects( { - workspaceId + workspaceSlug }, { 'x-e2e-user-email': email @@ -243,7 +267,7 @@ describe('Get Project Tests', () => { it('should get delete a the project', async () => { await projectController.deleteProject( { - projectId + projectSlug }, { 'x-e2e-user-email': email @@ -252,7 +276,7 @@ describe('Get Project Tests', () => { const projects = await projectController.getAllProjects( { - workspaceId + workspaceSlug }, { 'x-e2e-user-email': email diff --git a/packages/api-client/tests/secret.spec.ts b/packages/api-client/tests/secret.spec.ts index a52cbf09..a9b9b8ed 100644 --- a/packages/api-client/tests/secret.spec.ts +++ b/packages/api-client/tests/secret.spec.ts @@ -8,10 +8,10 @@ describe('Secret Controller Tests', () => { const secretController = new SecretController(backendUrl) const email = 'johndoe@example.com' - let projectId: string | null - let workspaceId: string | null - let environmentId: string | null - let secretId: string | null + let projectSlug: string | null + let workspaceSlug: string | null + let environmentSlug: string | null + let secretSlug: string | null beforeAll(async () => { //Create the user's workspace @@ -28,12 +28,12 @@ describe('Secret Controller Tests', () => { ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug // Create a project const projectResponse = (await ( await client.post( - `/api/project/${workspaceId}`, + `/api/project/${workspaceSlug}`, { name: 'Project', storePrivateKey: true @@ -44,11 +44,11 @@ describe('Secret Controller Tests', () => { ) ).json()) as any - projectId = projectResponse.id + projectSlug = projectResponse.slug const createEnvironmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Dev' }, @@ -58,12 +58,12 @@ describe('Secret Controller Tests', () => { ) ).json()) as any - environmentId = createEnvironmentResponse.id + environmentSlug = createEnvironmentResponse.slug }) afterAll(async () => { // Delete the workspace - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -76,22 +76,32 @@ describe('Secret Controller Tests', () => { note: 'Secret 1 note', entries: [ { - environmentId, + environmentSlug, value: 'Secret 1 value' } ], - projectId + projectSlug }, { 'x-e2e-user-email': email } ) - secretId = createSecretResponse.data.id + expect(createSecretResponse.data.slug).toBeDefined() + + secretSlug = createSecretResponse.data.slug + + // Fetch all secrets + const secrets = await secretController.getAllSecretsOfProject( + { projectSlug }, + { 'x-e2e-user-email': email } + ) + + expect(secrets.data.items.length).toBe(1) }) afterEach(async () => { // Delete the secret await secretController.deleteSecret( - { secretId }, + { secretSlug }, { 'x-e2e-user-email': email } ) }) @@ -104,24 +114,23 @@ describe('Secret Controller Tests', () => { note: 'Secret 2 note', entries: [ { - environmentId, + environmentSlug, value: 'Secret 1 value' } ], - projectId + projectSlug }, { 'x-e2e-user-email': email } ) - expect(secret.data.projectId).toBe(projectId) - expect(secret.data.project.workspaceId).toBe(workspaceId) expect(secret.data.name).toBe('Secret 2') + expect(secret.data.slug).toBeDefined() expect(secret.data.versions.length).toBe(1) expect(secret.error).toBe(null) // Delete the secret await secretController.deleteSecret( - { secretId: secret.data.id }, + { secretSlug: secret.data.slug }, { 'x-e2e-user-email': email } ) }) @@ -131,11 +140,19 @@ describe('Secret Controller Tests', () => { const updatedSecret = await secretController.updateSecret( { name: 'Updated Secret 1', - secretId + secretSlug }, { 'x-e2e-user-email': email } ) expect(updatedSecret.data.secret.name).toBe('Updated Secret 1') + + // Delete the secret since the slug will be updated + const deleteSecretResponse = await secretController.deleteSecret( + { secretSlug: updatedSecret.data.secret.slug }, + { 'x-e2e-user-email': email } + ) + + expect(deleteSecretResponse.error).toBe(null) }) // // Add Version to a Secret @@ -145,10 +162,10 @@ describe('Secret Controller Tests', () => { entries: [ { value: 'Updated Secret 1 value', - environmentId + environmentSlug } ], - secretId + secretSlug }, { 'x-e2e-user-email': email } ) @@ -163,10 +180,10 @@ describe('Secret Controller Tests', () => { entries: [ { value: 'Secret 1 value', - environmentId + environmentSlug } ], - secretId + secretSlug }, { 'x-e2e-user-email': email } ) @@ -176,16 +193,16 @@ describe('Secret Controller Tests', () => { entries: [ { value: 'Updated Secret 1 value', - environmentId + environmentSlug } ], - secretId + secretSlug }, { 'x-e2e-user-email': email } ) const rollbackSecret = await secretController.rollbackSecret( - { secretId, environmentId, version: 1 }, + { secretSlug, environmentSlug, version: 1 }, { 'x-e2e-user-email': email } ) @@ -195,7 +212,7 @@ describe('Secret Controller Tests', () => { // // Get all secrets of a Project it('should get all secrets of a project', async () => { const secrets: any = await secretController.getAllSecretsOfProject( - { projectId }, + { projectSlug }, { 'x-e2e-user-email': email } ) expect(secrets.data.items.length).toBe(1) @@ -205,8 +222,8 @@ describe('Secret Controller Tests', () => { it('should get all secrets of an environment', async () => { const secrets: any = await secretController.getAllSecretsOfEnvironment( { - environmentId, - projectId: projectId + environmentSlug, + projectSlug }, { 'x-e2e-user-email': email } ) @@ -223,14 +240,14 @@ describe('Secret Controller Tests', () => { }) }) - // // Delete a Secert from a Project + // Delete a Secret from a Project it('should delete a secret', async () => { await secretController.deleteSecret( - { secretId }, + { secretSlug }, { 'x-e2e-user-email': email } ) const secrets: any = await secretController.getAllSecretsOfProject( - { projectId }, + { projectSlug }, { 'x-e2e-user-email': email } ) expect(secrets.data.items.length).toBe(0) diff --git a/packages/api-client/tests/variable.spec.ts b/packages/api-client/tests/variable.spec.ts index 653c817d..b7295a4e 100644 --- a/packages/api-client/tests/variable.spec.ts +++ b/packages/api-client/tests/variable.spec.ts @@ -7,10 +7,10 @@ describe('Get Variable Tests', () => { const client = new APIClient(backendUrl) const variableController = new VariableController(backendUrl) const email = 'johndoe@example.com' - let workspaceId: string | null - let projectId: string | null + let workspaceSlug: string | null + let projectSlug: string | null let environment: any - let variableId: string | null + let variableSlug: string | null beforeAll(async () => { const workspaceResponse = (await ( @@ -25,12 +25,12 @@ describe('Get Variable Tests', () => { ) ).json()) as any - workspaceId = workspaceResponse.id + workspaceSlug = workspaceResponse.slug // Create a project const projectResponse = (await ( await client.post( - `/api/project/${workspaceId}`, + `/api/project/${workspaceSlug}`, { name: 'Project', storePrivateKey: true @@ -41,11 +41,11 @@ describe('Get Variable Tests', () => { ) ).json()) as any - projectId = projectResponse.id + projectSlug = projectResponse.slug const createEnvironmentResponse = (await ( await client.post( - `/api/environment/${projectId}`, + `/api/environment/${projectSlug}`, { name: 'Dev' }, @@ -57,8 +57,9 @@ describe('Get Variable Tests', () => { environment = createEnvironmentResponse }) + afterAll(async () => { - await client.delete(`/api/workspace/${workspaceId}`, { + await client.delete(`/api/workspace/${workspaceSlug}`, { 'x-e2e-user-email': email }) }) @@ -67,21 +68,21 @@ describe('Get Variable Tests', () => { it('should create a variable', async () => { const variable = await variableController.createVariable( { - projectId, + projectSlug, name: 'Variable 1', - entries: [{ value: 'Variable 1 value', environmentId: environment.id }] + entries: [ + { value: 'Variable 1 value', environmentSlug: environment.slug } + ] }, { 'x-e2e-user-email': email } ) expect(variable.data.name).toBe('Variable 1') - expect(variable.data.projectId).toBe(projectId) - expect(variable.data.project.workspaceId).toBe(workspaceId) expect(variable.data.versions.length).toBe(1) expect(variable.data.versions[0].value).toBe('Variable 1 value') expect(variable.data.versions[0].environmentId).toBe(environment.id) - variableId = variable.data.id + variableSlug = variable.data.slug }) // Update Name of the Variable @@ -89,14 +90,15 @@ describe('Get Variable Tests', () => { const updatedVariable = await variableController.updateVariable( { name: 'UpdatedVariable 1', - variableId + variableSlug }, { 'x-e2e-user-email': email } ) expect(updatedVariable.data.variable.name).toBe('UpdatedVariable 1') - expect(updatedVariable.data.variable.id).toBe(variableId) + + variableSlug = updatedVariable.data.variable.slug }) // Create a new version of Variable @@ -106,10 +108,10 @@ describe('Get Variable Tests', () => { entries: [ { value: '1234', - environmentId: environment.id + environmentSlug: environment.slug } ], - variableId + variableSlug }, { 'x-e2e-user-email': email } ) @@ -124,9 +126,9 @@ describe('Get Variable Tests', () => { it('should rollback a variable', async () => { const rolledBackVariable: any = await variableController.rollbackVariable( { - variableId, + variableSlug, version: 1, - environmentId: environment.id + environmentSlug: environment.slug }, { 'x-e2e-user-email': email } ) @@ -136,15 +138,15 @@ describe('Get Variable Tests', () => { // Get all the variables of project it('should get all variables of project', async () => { const response: any = await variableController.getAllVariablesOfProject( - { projectId }, + { projectSlug }, { 'x-e2e-user-email': email } ) expect(response.data.items.length).toBe(1) const variable1 = response.data.items[0] const variable = variable1.variable const values = variable1.values - expect(variable).toHaveProperty('id') - expect(typeof variable.id).toBe('string') + expect(variable).toHaveProperty('slug') + expect(typeof variable.slug).toBe('string') expect(variable).toHaveProperty('name') expect(typeof variable.name).toBe('string') @@ -163,12 +165,9 @@ describe('Get Variable Tests', () => { expect(variable).toHaveProperty('lastUpdatedById') expect(typeof variable.lastUpdatedById).toBe('string') - expect(variable).toHaveProperty('projectId') - expect(typeof variable.projectId).toBe('string') - - expect(variable).toHaveProperty('lastUpdatedBy') expect(variable.lastUpdatedBy).toHaveProperty('id') expect(typeof variable.lastUpdatedBy.id).toBe('string') + expect(variable.lastUpdatedBy).toHaveProperty('name') expect(typeof variable.lastUpdatedBy.name).toBe('string') @@ -192,8 +191,8 @@ describe('Get Variable Tests', () => { const variables: any = await variableController.getAllVariablesOfEnvironment( { - environmentId: environment.id, - projectId + environmentSlug: environment.slug, + projectSlug }, { 'x-e2e-user-email': email } ) @@ -218,11 +217,11 @@ describe('Get Variable Tests', () => { // Delete a variable it('should delete variable', async () => { await variableController.deleteVariable( - { variableId }, + { variableSlug }, { 'x-e2e-user-email': email } ) const variables: any = await variableController.getAllVariablesOfProject( - { projectId }, + { projectSlug }, { 'x-e2e-user-email': email } ) expect(variables.data.items.length).toBe(0)