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 fbffbbac..8fa2cefe 100644 --- a/apps/api/src/api-key/api-key.e2e.spec.ts +++ b/apps/api/src/api-key/api-key.e2e.spec.ts @@ -47,6 +47,11 @@ describe('Api Key Role Controller Tests', () => { }) }) + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + }) + it('should be able to create api key', async () => { const response = await app.inject({ method: 'POST', diff --git a/apps/api/src/event/event.e2e.spec.ts b/apps/api/src/event/event.e2e.spec.ts index f6d486d5..9c8eca71 100644 --- a/apps/api/src/event/event.e2e.spec.ts +++ b/apps/api/src/event/event.e2e.spec.ts @@ -111,6 +111,11 @@ describe('Event Controller Tests', () => { }) }) + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).toBeDefined() + }) + it('should be able to fetch a user event', async () => { const updatedUser = await userService.updateSelf(user, { isOnboardingFinished: true diff --git a/apps/api/src/prisma/migrations/20240215135023_update_authority/migration.sql b/apps/api/src/prisma/migrations/20240215135023_update_authority/migration.sql new file mode 100644 index 00000000..dfe4cce5 --- /dev/null +++ b/apps/api/src/prisma/migrations/20240215135023_update_authority/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - The values [TRANSFER_OWNERSHIP] on the enum `Authority` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Authority_new" AS ENUM ('CREATE_PROJECT', 'READ_USERS', 'ADD_USER', 'REMOVE_USER', 'UPDATE_USER_ROLE', 'READ_WORKSPACE', 'UPDATE_WORKSPACE', 'DELETE_WORKSPACE', 'CREATE_WORKSPACE_ROLE', 'READ_WORKSPACE_ROLE', 'UPDATE_WORKSPACE_ROLE', 'DELETE_WORKSPACE_ROLE', 'WORKSPACE_ADMIN', 'READ_PROJECT', 'UPDATE_PROJECT', 'DELETE_PROJECT', 'CREATE_SECRET', 'READ_SECRET', 'UPDATE_SECRET', 'DELETE_SECRET', 'CREATE_ENVIRONMENT', 'READ_ENVIRONMENT', 'UPDATE_ENVIRONMENT', 'DELETE_ENVIRONMENT', 'CREATE_WORKSPACE', 'CREATE_API_KEY', 'READ_API_KEY', 'UPDATE_API_KEY', 'DELETE_API_KEY', 'UPDATE_PROFILE', 'READ_SELF', 'UPDATE_SELF', 'READ_EVENT'); +ALTER TABLE "WorkspaceRole" ALTER COLUMN "authorities" TYPE "Authority_new"[] USING ("authorities"::text::"Authority_new"[]); +ALTER TABLE "ApiKey" ALTER COLUMN "authorities" TYPE "Authority_new"[] USING ("authorities"::text::"Authority_new"[]); +ALTER TYPE "Authority" RENAME TO "Authority_old"; +ALTER TYPE "Authority_new" RENAME TO "Authority"; +DROP TYPE "Authority_old"; +COMMIT; diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 1b01c797..33f22440 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -67,7 +67,6 @@ enum Authority { READ_WORKSPACE UPDATE_WORKSPACE DELETE_WORKSPACE - TRANSFER_OWNERSHIP CREATE_WORKSPACE_ROLE READ_WORKSPACE_ROLE UPDATE_WORKSPACE_ROLE 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 36b198bd..19a02a85 100644 --- a/apps/api/src/workspace-role/workspace-role.e2e.spec.ts +++ b/apps/api/src/workspace-role/workspace-role.e2e.spec.ts @@ -273,8 +273,9 @@ describe('Workspace Role Controller Tests', () => { ]) }) - it('should be defined', () => { + it('should be defined', async () => { expect(app).toBeDefined() + expect(prisma).toBeDefined() }) it('should be able to get the auto generated admin role', async () => { diff --git a/apps/api/src/workspace/controller/workspace.controller.ts b/apps/api/src/workspace/controller/workspace.controller.ts index 762ed1ac..2db1dcdb 100644 --- a/apps/api/src/workspace/controller/workspace.controller.ts +++ b/apps/api/src/workspace/controller/workspace.controller.ts @@ -41,7 +41,7 @@ export class WorkspaceController { } @Put(':workspaceId/transfer-ownership/:userId') - @RequiredApiKeyAuthorities(Authority.TRANSFER_OWNERSHIP) + @RequiredApiKeyAuthorities(Authority.WORKSPACE_ADMIN) async transferOwnership( @CurrentUser() user: User, @Param('workspaceId') workspaceId: Workspace['id'], @@ -93,7 +93,7 @@ export class WorkspaceController { @CurrentUser() user: User, @Param('workspaceId') workspaceId: Workspace['id'], @Param('userId') userId: User['id'], - @Query('roles') roleIds: WorkspaceRole['id'][] + @Body() roleIds: WorkspaceRole['id'][] ) { return this.workspaceService.updateMemberRoles( user, diff --git a/apps/api/src/workspace/service/workspace.service.ts b/apps/api/src/workspace/service/workspace.service.ts index 3968c4f1..1c421af8 100644 --- a/apps/api/src/workspace/service/workspace.service.ts +++ b/apps/api/src/workspace/service/workspace.service.ts @@ -3,9 +3,9 @@ import { ConflictException, Inject, Injectable, + InternalServerErrorException, Logger, - NotFoundException, - UnauthorizedException + NotFoundException } from '@nestjs/common' import { PrismaService } from '../../prisma/prisma.service' import { @@ -190,7 +190,7 @@ export class WorkspaceService { const workspace = await getWorkspaceWithAuthority( user.id, workspaceId, - Authority.TRANSFER_OWNERSHIP, + Authority.WORKSPACE_ADMIN, this.prisma ) @@ -212,6 +212,11 @@ export class WorkspaceService { ) } + const currentUserMembership = await this.getWorkspaceMembership( + workspaceId, + user.id + ) + // Get the admin ownership role const adminOwnershipRole = await this.prisma.workspaceRole.findFirst({ where: { @@ -220,21 +225,22 @@ export class WorkspaceService { } }) - // Assign this role to the new owner - const assignRole = this.prisma.workspaceMemberRoleAssociation.upsert({ + // Remove this role from the current owner + const removeRole = this.prisma.workspaceMemberRoleAssociation.delete({ where: { roleId_workspaceMemberId: { roleId: adminOwnershipRole.id, - workspaceMemberId: workspaceMembership.id + workspaceMemberId: currentUserMembership.id } - }, - create: { + } + }) + + // Assign this role to the new owner + const assignRole = this.prisma.workspaceMemberRoleAssociation.create({ + data: { role: { connect: { - workspaceId_name: { - name: adminOwnershipRole.name, - workspaceId - } + id: adminOwnershipRole.id } }, workspaceMember: { @@ -242,12 +248,11 @@ export class WorkspaceService { id: workspaceMembership.id } } - }, - update: {} + } }) // Update the owner of the workspace - const updateUser = this.prisma.workspace.update({ + const updateWorkspace = this.prisma.workspace.update({ where: { id: workspaceId }, @@ -261,7 +266,12 @@ export class WorkspaceService { } }) - await this.prisma.$transaction([assignRole, updateUser]) + try { + await this.prisma.$transaction([removeRole, assignRole, updateWorkspace]) + } catch (e) { + this.log.error('Error in transaction', e) + throw new InternalServerErrorException('Error in transaction') + } createEvent( { @@ -333,27 +343,33 @@ export class WorkspaceService { // Add users to the workspace if any if (members && members.length > 0) { - this.addMembersToWorkspace(workspace, user, members) - } + await this.addMembersToWorkspace(workspace, user, members) + + createEvent( + { + triggeredBy: user, + entity: workspace, + type: EventType.INVITED_TO_WORKSPACE, + source: EventSource.WORKSPACE, + title: `Invited users to workspace`, + metadata: { + workspaceId: workspace.id, + name: workspace.name, + members: members.map((m) => m.email) + } + }, + this.prisma + ) - createEvent( - { - triggeredBy: user, - entity: workspace, - type: EventType.INVITED_TO_WORKSPACE, - source: EventSource.WORKSPACE, - title: `Invited users to workspace`, - metadata: { - workspaceId: workspace.id, - name: workspace.name, - members: members.map((m) => m.email) - } - }, - this.prisma - ) + this.log.debug( + `Added users to workspace ${workspace.name} (${workspace.id})` + ) - this.log.debug( - `Added users to workspace ${workspace.name} (${workspace.id})` + return + } + + this.log.warn( + `No users to add to workspace ${workspace.name} (${workspace.id})` ) } @@ -369,36 +385,24 @@ export class WorkspaceService { this.prisma ) - // Check if the user is already a member of the workspace - if (!(await this.memberExistsInWorkspace(workspaceId, user.id))) - throw new ConflictException( - `User ${user.name} (${user.id}) is not a member of workspace ${workspace.name} (${workspace.id})` - ) - // Remove users from the workspace if any - const ops = [] if (userIds && userIds.length > 0) { - for (const userId of userIds) { - if (userId === user.id) - throw new ConflictException( - `You cannot remove yourself from the workspace. Please delete the workspace instead.` - ) - - // Delete the membership - ops.push( - this.prisma.workspaceMember.delete({ - where: { - workspaceId_userId: { - workspaceId, - userId - } - } - }) + if (userIds.find((id) => id === user.id)) { + throw new BadRequestException( + `You cannot remove yourself from the workspace. Please transfer the ownership to another member before leaving the workspace.` ) } - } - await this.prisma.$transaction(ops) + // Delete the membership + await this.prisma.workspaceMember.deleteMany({ + where: { + workspaceId, + userId: { + in: userIds + } + } + }) + } createEvent( { @@ -434,6 +438,12 @@ export class WorkspaceService { this.prisma ) + if (!roleIds || roleIds.length === 0) { + this.log.warn( + `No roles to update for user ${userId} 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))) throw new NotFoundException( @@ -441,20 +451,36 @@ export class WorkspaceService { ) // Update the role of the user - await this.prisma.workspaceMember.update({ + const membership = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } - }, - data: { - roles: { - set: roleIds.map((id) => ({ id })) - } } }) + // Clear out the existing roles + const deleteExistingAssociations = + this.prisma.workspaceMemberRoleAssociation.deleteMany({ + where: { + workspaceMemberId: membership.id + } + }) + + const createNewAssociations = + this.prisma.workspaceMemberRoleAssociation.createMany({ + data: roleIds.map((roleId) => ({ + roleId, + workspaceMemberId: membership.id + })) + }) + + await this.prisma.$transaction([ + deleteExistingAssociations, + createNewAssociations + ]) + createEvent( { triggeredBy: user, @@ -494,7 +520,7 @@ export class WorkspaceService { ) return await this.prisma.workspaceMember.findMany({ - skip: (page - 1) * limit, + skip: page * limit, take: limit, orderBy: { workspace: { @@ -549,10 +575,7 @@ export class WorkspaceService { workspaceId: Workspace['id'] ): Promise { // Check if the user has a pending invitation to the workspace - if (!(await this.invitationPending(workspaceId, user.id))) - throw new ConflictException( - `User ${user.name} (${user.id}) is not invited to workspace ${workspaceId}` - ) + await this.checkInvitationPending(workspaceId, user.id) // Update the membership await this.prisma.workspaceMember.update({ @@ -606,8 +629,8 @@ export class WorkspaceService { // Check if the user has a pending invitation to the workspace if (!(await this.invitationPending(workspaceId, inviteeId))) - throw new ConflictException( - `User ${user.id} is not invited to workspace ${workspaceId}` + throw new BadRequestException( + `User ${inviteeId} is not invited to workspace ${workspaceId}` ) // Delete the membership @@ -629,7 +652,7 @@ export class WorkspaceService { ) this.log.debug( - `User ${user.name} (${user.id}) declined invitation to workspace ${workspaceId}` + `User ${user.name} (${user.id}) cancelled invitation to workspace ${workspaceId}` ) } @@ -638,10 +661,7 @@ export class WorkspaceService { workspaceId: Workspace['id'] ): Promise { // Check if the user has a pending invitation to the workspace - if (!(await this.invitationPending(workspaceId, user.id))) - throw new ConflictException( - `User ${user.name} (${user.id}) is not invited to workspace ${workspaceId}` - ) + await this.checkInvitationPending(workspaceId, user.id) // Delete the membership await this.deleteMembership(workspaceId, user.id) @@ -675,6 +695,13 @@ export class WorkspaceService { user: User, workspaceId: Workspace['id'] ): Promise { + const workspace = await getWorkspaceWithAuthority( + user.id, + workspaceId, + Authority.READ_WORKSPACE, + this.prisma + ) + // Get all the memberships of this workspace const memberships = await this.prisma.workspaceMember.findMany({ where: { @@ -705,29 +732,9 @@ export class WorkspaceService { `You cannot leave the workspace as you are the owner of the workspace. Please transfer the ownership to another member before leaving the workspace.` ) - // Check if the user is a member of the workspace - if ( - memberships.find((membership) => membership.userId === user.id) === null - ) - throw new UnauthorizedException( - `User ${user.name} (${user.id}) is not a member of workspace ${workspaceId}` - ) - - if (memberships.length === 1) { - // If the user is the last member of the workspace, delete the workspace - await this.deleteWorkspace(user, workspaceId) - return - } - // Delete the membership await this.deleteMembership(workspaceId, user.id) - const workspace = await this.prisma.workspace.findUnique({ - where: { - id: workspaceId - } - }) - createEvent( { triggeredBy: user, @@ -764,7 +771,7 @@ export class WorkspaceService { ) return await this.prisma.workspaceMember.findMany({ - skip: (page - 1) * limit, + skip: page * limit, take: limit, orderBy: { workspace: { @@ -830,7 +837,7 @@ export class WorkspaceService { search: string ) { return await this.prisma.workspace.findMany({ - skip: (page - 1) * limit, + skip: page * limit, take: limit, orderBy: { [sort]: order @@ -865,7 +872,7 @@ export class WorkspaceService { search: string ) { return await this.prisma.workspace.findMany({ - skip: (page - 1) * limit, + skip: page * limit, take: limit, orderBy: { [sort]: order @@ -927,7 +934,9 @@ export class WorkspaceService { workspace.id }). Skipping.` ) - return + throw new ConflictException( + `User ${memberUser.name} (${userId}) is already a member of workspace ${workspace.name} (${workspace.id})` + ) } // Create the workspace membership @@ -1050,4 +1059,14 @@ export class WorkspaceService { }) .then((count) => count > 0) } + + private async checkInvitationPending( + workspaceId: Workspace['id'], + userId: User['id'] + ): Promise { + if (!(await this.invitationPending(workspaceId, userId))) + throw new BadRequestException( + `User ${userId} is not invited to workspace ${workspaceId}` + ) + } } diff --git a/apps/api/src/workspace/workspace.e2e.spec.ts b/apps/api/src/workspace/workspace.e2e.spec.ts new file mode 100644 index 00000000..bcc6d896 --- /dev/null +++ b/apps/api/src/workspace/workspace.e2e.spec.ts @@ -0,0 +1,1191 @@ +import { + FastifyAdapter, + NestFastifyApplication +} from '@nestjs/platform-fastify' +import { PrismaService } from '../prisma/prisma.service' +import { AppModule } from '../app/app.module' +import { WorkspaceModule } from './workspace.module' +import { Test } from '@nestjs/testing' +import { MAIL_SERVICE } from '../mail/services/interface.service' +import { MockMailService } from '../mail/services/mock.service' +import { + Authority, + EventSeverity, + EventSource, + EventTriggerer, + EventType, + User, + Workspace, + WorkspaceRole +} from '@prisma/client' +import cleanUp from '../common/cleanup' + +const fetchEvents = async ( + app: NestFastifyApplication, + user: User, + query?: string +) => + await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user.email + }, + url: `/event${query ? '?' + query : ''}` + }) + +const createMembership = async ( + adminRoleId: string, + userId: string, + workspaceId: string, + prisma: PrismaService +) => { + await prisma.workspaceMember.create({ + data: { + workspaceId: workspaceId, + userId: userId, + roles: { + create: { + role: { + connect: { + id: adminRoleId + } + } + } + } + } + }) +} + +describe('Workspace Controller Tests', () => { + let app: NestFastifyApplication + let prisma: PrismaService + + let user1: User, user2: User, user3: User + let workspace1: Workspace, workspace2: Workspace + let adminRole: WorkspaceRole, memberRole: WorkspaceRole + + const totalEvents = [] + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, WorkspaceModule] + }) + .overrideProvider(MAIL_SERVICE) + .useClass(MockMailService) + .compile() + + app = moduleRef.createNestApplication( + new FastifyAdapter() + ) + prisma = moduleRef.get(PrismaService) + + await app.init() + await app.getHttpAdapter().getInstance().ready() + + const createUser1 = prisma.user.create({ + data: { + email: 'johndoe@keyshade.xyz', + name: 'John Doe', + isOnboardingFinished: true + } + }) + + const createUser2 = prisma.user.create({ + data: { + email: 'janedoe@keyshade.xyz', + name: 'Jane Doe', + isOnboardingFinished: true + } + }) + + const createUser3 = prisma.user.create({ + data: { + email: 'sadie@keyshade.xyz', + name: 'Sadie', + isOnboardingFinished: true + } + }) + + const result = await prisma.$transaction([ + createUser1, + createUser2, + createUser3 + ]) + + user1 = result[0] + user2 = result[1] + user3 = result[2] + }) + + it('should be defined', async () => { + expect(app).toBeDefined() + expect(prisma).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) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Workspace 1', + description: 'Workspace 1 description', + ownerId: user1.id, + isFreeTier: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + lastUpdatedById: null + }) + + workspace1 = 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: 'Workspace 1', + description: 'Workspace 1 description' + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: 'Workspace already exists' + }) + }) + + it('should let other user to create workspace with same name', async () => { + 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(response.statusCode).toBe(201) + expect(response.json()).toEqual({ + id: expect.any(String), + name: 'Workspace 1', + description: 'Workspace 1 description', + ownerId: user2.id, + isFreeTier: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + lastUpdatedById: null + }) + + workspace2 = response.json() + }) + + it('should have created a WORKSPACE_CREATED event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.WORKSPACE_CREATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should have created a new role with name Admin', async () => { + adminRole = await prisma.workspaceRole.findUnique({ + where: { + workspaceId_name: { + workspaceId: workspace1.id, + name: 'Admin' + } + } + }) + + 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 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' + } + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ + id: workspace1.id, + name: 'Workspace 1 Updated', + description: 'Workspace 1 updated description', + ownerId: user1.id, + isFreeTier: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + lastUpdatedById: user1.id + }) + + workspace1 = response.json() + }) + + 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: 'Workspace 1 Updated', + description: 'Workspace 1 updated description' + } + }) + + expect(response.statusCode).toBe(409) + expect(response.json()).toEqual({ + statusCode: 409, + error: 'Conflict', + message: 'Workspace already exists' + }) + }) + + 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' + } + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + it('should have created a WORKSPACE_UPDATED event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.WORKSPACE_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + 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(response.statusCode).toBe(201) + }) + + it('should allow user to invite another user to the workspace', async () => { + memberRole = await prisma.workspaceRole.create({ + data: { + name: 'Member', + workspaceId: workspace1.id, + authorities: [Authority.READ_WORKSPACE] + } + }) + + 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: [memberRole.id] + } + ] + }) + + expect(response.statusCode).toBe(201) + + 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: false + }) + }) + + it('should not be able to add an existing 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: [ + { + email: user2.email, + roleIds: [] + } + ] + }) + + 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})` + }) + }) + + it('should have created a INVITED_TO_WORKSPACE event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.INVITED_TO_WORKSPACE, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to cancel the invitation', async () => { + 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) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.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}` + }) + + 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 have created a CANCELLED_INVITATION event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.CANCELLED_INVITATION, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to decline invitation to the workspace', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/decline-invitation` + }) + + expect(response.statusCode).toBe(200) + + 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(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 have created a DECLINED_INVITATION event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.DECLINED_INVITATION, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to accept the invitation to the workspace', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + + const response = await app.inject({ + method: 'POST', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/accept-invitation` + }) + + expect(response.statusCode).toBe(201) + + 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 + }) + }) + + 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` + }) + + 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 have created a ACCEPT_INVITATION event', async () => { + const response = await fetchEvents( + app, + user2, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.ACCEPTED_INVITATION, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to leave the workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}/leave` + }) + + expect(response.statusCode).toBe(200) + + const membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + expect(membership).toBeNull() + }) + + 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` + }) + + 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.` + }) + }) + + 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` + }) + + expect(response.statusCode).toBe(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + it('should have created a LEFT_WORKSPACE event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.LEFT_WORKSPACE, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to update the role of a member', async () => { + await createMembership(adminRole.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: [memberRole.id] + }) + + 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 + } + } + } + }) + + expect(membership.roles).toEqual([ + { + roleId: memberRole.id + } + ]) + }) + + it('should have created a WORKSPACE_MEMBERSHIP_UPDATED event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.WORKSPACE_MEMBERSHIP_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + 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] + }) + + expect(response.statusCode).toBe(200) + + 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.` + }) + }) + + it('should have created a REMOVED_FROM_WORKSPACE event', async () => { + const response = await fetchEvents( + app, + user1, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.REMOVED_FROM_WORKSPACE, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + 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: [] + }) + + 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})` + }) + }) + + 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) + }) + + 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(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + 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(200) + expect(response.json()).toBeInstanceOf(Array) + expect(response.json()).toHaveLength(1) + }) + + 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) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + 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()).toEqual(workspace1) + }) + + 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.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + 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) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user2.id} does not have the required authorities to perform the action` + }) + }) + + 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(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 transfer ownership to a non member', async () => { + const response = await app.inject({ + method: 'PUT', + headers: { + 'x-e2e-user-email': user1.email + }, + url: `/workspace/${workspace1.id}/transfer-ownership/${user3.id}` + }) + + expect(response.statusCode).toBe(404) + expect(response.json()).toEqual({ + statusCode: 404, + error: 'Not Found', + message: `User ${user3.id} is not a member of workspace ${workspace1.name} (${workspace1.id})` + }) + }) + + it('should be able to fetch all the workspaces the user is a member of', async () => { + await createMembership(adminRole.id, user2.id, workspace1.id, prisma) + const response = await app.inject({ + method: 'GET', + headers: { + 'x-e2e-user-email': user2.email + }, + url: '/workspace/all/as-user' + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual([workspace2, workspace1]) + }) + + it('should crash while transferring ownership if the assignee already has the Admin role(impossible case)', async () => { + 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(500) + expect(response.json()).toEqual({ + statusCode: 500, + error: 'Internal Server Error', + message: 'Error in transaction' + }) + }) + + it('should be able to transfer the ownership of the workspace', async () => { + const user2Membership = await prisma.workspaceMember.findUnique({ + where: { + workspaceId_userId: { + workspaceId: workspace1.id, + userId: user2.id + } + } + }) + + await prisma.workspaceMemberRoleAssociation.delete({ + where: { + roleId_workspaceMemberId: { + roleId: adminRole.id, + workspaceMemberId: user2Membership.id + } + } + }) + + await prisma.workspaceMemberRoleAssociation.create({ + data: { + roleId: memberRole.id, + workspaceMemberId: user2Membership.id + } + }) + + 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(200) + + const workspace = await prisma.workspace.findUnique({ + where: { + id: workspace1.id + } + }) + + expect(workspace).toEqual({ + ...workspace1, + ownerId: user2.id, + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + }) + + it('should not be able to transfer ownership if is not admin', async () => { + 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(401) + expect(response.json()).toEqual({ + statusCode: 401, + error: 'Unauthorized', + message: `User ${user1.id} does not have the required authorities to perform the action` + }) + }) + + it('should have created a WORKSPACE_UPDATED event', async () => { + const response = await fetchEvents( + app, + user2, + 'workspaceId=' + workspace1.id + ) + + const event = { + id: expect.any(String), + title: expect.any(String), + description: expect.any(String), + source: EventSource.WORKSPACE, + triggerer: EventTriggerer.USER, + severity: EventSeverity.INFO, + type: EventType.WORKSPACE_UPDATED, + timestamp: expect.any(String), + metadata: expect.any(Object) + } + + totalEvents.push(event) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual(totalEvents) + }) + + it('should be able to delete the workspace', async () => { + const response = await app.inject({ + method: 'DELETE', + headers: { + 'x-e2e-user-email': user2.email + }, + url: `/workspace/${workspace1.id}` + }) + + expect(response.statusCode).toBe(200) + + const workspace = await prisma.workspace.findUnique({ + where: { + id: workspace1.id + } + }) + + expect(workspace).toBeNull() + }) + + afterAll(async () => { + await cleanUp(prisma) + }) +})