From c5d8a446ae9f77d9d4594fc32a3d607865d1b33e Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Wed, 8 Feb 2023 15:18:46 +0600 Subject: [PATCH] feat: teamEnvironment module added --- packages/hoppscotch-backend/src/app.module.ts | 2 + packages/hoppscotch-backend/src/errors.ts | 2 +- .../src/pubsub/topicsDefs.ts | 4 + .../gql-team-env-team.guard.ts | 83 ++++ .../team-environments.model.ts | 24 ++ .../team-environments.module.ts | 21 + .../team-environments.resolver.ts | 205 +++++++++ .../team-environments.service.spec.ts | 403 ++++++++++++++++++ .../team-environments.service.ts | 234 ++++++++++ .../src/team-environments/team.resolver.ts | 16 + .../src/team/guards/gql-team-member.guard.ts | 11 +- .../hoppscotch-backend/src/team/team.model.ts | 2 +- .../src/team/team.module.ts | 2 +- .../src/team/team.service.ts | 2 +- packages/hoppscotch-backend/src/utils.ts | 22 +- 15 files changed, 1012 insertions(+), 21 deletions(-) create mode 100644 packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team-environments.model.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team-environments.module.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team-environments.service.ts create mode 100644 packages/hoppscotch-backend/src/team-environments/team.resolver.ts diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 6c75e78ae..32bfed4ac 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -9,6 +9,7 @@ import { UserEnvironmentsModule } from './user-environment/user-environments.mod import { UserHistoryModule } from './user-history/user-history.module'; import { subscriptionContextCookieParser } from './auth/helper'; import { TeamModule } from './team/team.module'; +import { TeamEnvironmentsModule } from './team-environments/team-environments.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { TeamModule } from './team/team.module'; UserEnvironmentsModule, UserHistoryModule, TeamModule, + TeamEnvironmentsModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index f53711e84..82a70233a 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -163,7 +163,7 @@ export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const; * Invalid or non-existent TEAM ENVIRONMMENT ID * (TeamEnvironmentsService) */ -export const TEAM_ENVIRONMMENT_NOT_FOUND = +export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; /** diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 18f41bda1..199772ec3 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -3,6 +3,7 @@ import { UserSettings } from 'src/user-settings/user-settings.model'; import { UserEnvironment } from '../user-environment/user-environments.model'; import { UserHistory } from '../user-history/user-history.model'; import { TeamMember } from 'src/team/team.model'; +import { TeamEnvironment } from 'src/team-environments/team-environments.model'; // A custom message type that defines the topic and the corresponding payload. // For every module that publishes a subscription add its type def and the possible subscription type. @@ -19,5 +20,8 @@ export type TopicDef = { [topic: `team/${string}/member_removed`]: string; [topic: `team/${string}/member_added`]: TeamMember; [topic: `team/${string}/member_updated`]: TeamMember; + [topic: `team_environment/${string}/created`]: TeamEnvironment; + [topic: `team_environment/${string}/updated`]: TeamEnvironment; + [topic: `team_environment/${string}/deleted`]: TeamEnvironment; [topic: `user_history/${string}/deleted_many`]: number; }; diff --git a/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts new file mode 100644 index 000000000..af6c503e9 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts @@ -0,0 +1,83 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import * as TE from 'fp-ts/TaskEither'; +import * as O from 'fp-ts/Option'; +import * as S from 'fp-ts/string'; +import { pipe } from 'fp-ts/function'; +import { + getAnnotatedRequiredRoles, + getGqlArg, + getUserFromGQLContext, + namedTrace, + throwErr, +} from 'src/utils'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_ENV_GUARD_NO_ENV_ID, + BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES, + TEAM_ENVIRONMENT_NOT_TEAM_MEMBER, + TEAM_ENVIRONMENT_NOT_FOUND, +} from 'src/errors'; +import { TeamService } from 'src/team/team.service'; + +/** + * A guard which checks whether the caller of a GQL Operation + * is in the team which owns the environment. + * This guard also requires the RequireRole decorator for access control + */ +@Injectable() +export class GqlTeamEnvTeamGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamEnvironmentService: TeamEnvironmentsService, + private readonly teamService: TeamService, + ) {} + + canActivate(context: ExecutionContext): Promise { + return pipe( + TE.Do, + + TE.bindW('requiredRoles', () => + pipe( + getAnnotatedRequiredRoles(this.reflector, context), + TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES), + ), + ), + + TE.bindW('user', () => + pipe( + getUserFromGQLContext(context), + TE.fromOption(() => BUG_AUTH_NO_USER_CTX), + ), + ), + + TE.bindW('envID', () => + pipe( + getGqlArg('id', context), + O.fromPredicate(S.isString), + TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID), + ), + ), + + TE.bindW('membership', ({ envID, user }) => + pipe( + this.teamEnvironmentService.getTeamEnvironment(envID), + TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND), + TE.chainW((env) => + pipe( + this.teamService.getTeamMemberTE(env.teamID, user.uid), + TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER), + ), + ), + ), + ), + + TE.map(({ membership, requiredRoles }) => + requiredRoles.includes(membership.role), + ), + + TE.getOrElse(throwErr), + )(); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts new file mode 100644 index 000000000..9fa9820fa --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.model.ts @@ -0,0 +1,24 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class TeamEnvironment { + @Field(() => ID, { + description: 'ID of the Team Environment', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the team this environment belongs to', + }) + teamID: string; + + @Field({ + description: 'Name of the environment', + }) + name: string; + + @Field({ + description: 'All variables present in the environment', + }) + variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts new file mode 100644 index 000000000..69e2f5c16 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { TeamEnvironmentsResolver } from './team-environments.resolver'; +import { UserModule } from 'src/user/user.module'; +import { PubSubModule } from 'src/pubsub/pubsub.module'; +import { TeamModule } from 'src/team/team.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; +import { TeamEnvsTeamResolver } from './team.resolver'; + +@Module({ + imports: [PrismaModule, PubSubModule, UserModule, TeamModule], + providers: [ + TeamEnvironmentsResolver, + TeamEnvironmentsService, + GqlTeamEnvTeamGuard, + TeamEnvsTeamResolver, + ], + exports: [TeamEnvironmentsService, GqlTeamEnvTeamGuard], +}) +export class TeamEnvironmentsModule {} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts new file mode 100644 index 000000000..303783e7e --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts @@ -0,0 +1,205 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver, Mutation, Args, Subscription, ID } from '@nestjs/graphql'; +import { pipe } from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { TeamMemberRole } from 'src/team/team.model'; +import { throwErr } from 'src/utils'; +import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; + +@Resolver(() => 'TeamEnvironment') +export class TeamEnvironmentsResolver { + constructor( + private readonly teamEnvironmentsService: TeamEnvironmentsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => TeamEnvironment, { + description: 'Create a new Team Environment for given Team ID', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) + createTeamEnvironment( + @Args({ + name: 'name', + description: 'Name of the Team Environment', + }) + name: string, + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + return this.teamEnvironmentsService.createTeamEnvironment( + name, + teamID, + variables, + )(); + } + + @Mutation(() => Boolean, { + description: 'Delete a Team Environment for given Team ID', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) + deleteTeamEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + return pipe( + this.teamEnvironmentsService.deleteTeamEnvironment(id), + TE.getOrElse(throwErr), + )(); + } + + @Mutation(() => TeamEnvironment, { + description: + 'Add/Edit a single environment variable or variables to a Team Environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) + updateTeamEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + @Args({ + name: 'name', + description: 'Name of the Team Environment', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + return pipe( + this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables), + TE.getOrElse(throwErr), + )(); + } + + @Mutation(() => TeamEnvironment, { + description: 'Delete all variables from a Team Environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) + deleteAllVariablesFromTeamEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + return pipe( + this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id), + TE.getOrElse(throwErr), + )(); + } + + @Mutation(() => TeamEnvironment, { + description: 'Create a duplicate of an existing environment', + }) + @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) + @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) + createDuplicateEnvironment( + @Args({ + name: 'id', + description: 'ID of the Team Environment', + type: () => ID, + }) + id: string, + ): Promise { + return pipe( + this.teamEnvironmentsService.createDuplicateEnvironment(id), + TE.getOrElse(throwErr), + )(); + } + + /* Subscriptions */ + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Updates', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + teamEnvironmentUpdated( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/updated`); + } + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Creation Messages', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + teamEnvironmentCreated( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/created`); + } + + @Subscription(() => TeamEnvironment, { + description: 'Listen for Team Environment Deletion Messages', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + teamEnvironmentDeleted( + @Args({ + name: 'teamID', + description: 'ID of the Team', + type: () => ID, + }) + teamID: string, + ) { + return this.pubsub.asyncIterator(`team_environment/${teamID}/deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts new file mode 100644 index 000000000..8837094f8 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts @@ -0,0 +1,403 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; +import { TEAM_ENVIRONMENT_NOT_FOUND, TEAM_MEMBER_NOT_FOUND } from 'src/errors'; + +const mockPrisma = mockDeep(); + +const mockPubSub = { + publish: jest.fn().mockResolvedValue(null), +}; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const teamEnvironmentsService = new TeamEnvironmentsService( + mockPrisma, + mockPubSub as any, +); + +const teamEnvironment = { + id: '123', + name: 'test', + teamID: 'abc123', + variables: [{}], +}; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('TeamEnvironmentsService', () => { + describe('getTeamEnvironment', () => { + test('queries the db with the id', async () => { + mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); + + await teamEnvironmentsService.getTeamEnvironment('123')(); + + expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: '123', + }, + }), + ); + }); + + test('requests prisma to reject the query promise if not found', async () => { + mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); + + await teamEnvironmentsService.getTeamEnvironment('123')(); + + expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + rejectOnNotFound: true, + }), + ); + }); + + test('should return a Some of the correct environment if exists', async () => { + mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); + + const result = await teamEnvironmentsService.getTeamEnvironment('123')(); + + expect(result).toEqualSome(teamEnvironment); + }); + + test('should return a None if the environment does not exist', async () => { + mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); + + const result = await teamEnvironmentsService.getTeamEnvironment('123')(); + + expect(result).toBeNone(); + }); + }); + describe('createTeamEnvironment', () => { + test('should create and return a new team environment given a valid name,variable and team ID', async () => { + mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); + + const result = await teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + )(); + + expect(result).toEqual({ + id: teamEnvironment.id, + name: teamEnvironment.name, + teamID: teamEnvironment.teamID, + variables: JSON.stringify(teamEnvironment.variables), + }); + }); + + test('should reject if given team ID is invalid', async () => { + mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); + + await expect( + teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + 'invalidteamid', + JSON.stringify(teamEnvironment.variables), + ), + ).rejects.toBeDefined(); + }); + + test('should reject if provided team environment name is not a string', async () => { + mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); + + await expect( + teamEnvironmentsService.createTeamEnvironment( + null as any, + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + ), + ).rejects.toBeDefined(); + }); + + test('should reject if provided variable is not a string', async () => { + mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); + + await expect( + teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + teamEnvironment.teamID, + null as any, + ), + ).rejects.toBeDefined(); + }); + + test('should send pubsub message to "team_environment//created" if team environment is created successfully', async () => { + mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.createTeamEnvironment( + teamEnvironment.name, + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + )(); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/created`, + result, + ); + }); + }); + + describe('deleteTeamEnvironment', () => { + test('should resolve to true given a valid team environment ID', async () => { + mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.deleteTeamEnvironment( + teamEnvironment.id, + )(); + + expect(result).toEqualRight(true); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if given id is invalid', async () => { + mockPrisma.teamEnvironment.delete.mockRejectedValue('RecordNotFound'); + + const result = await teamEnvironmentsService.deleteTeamEnvironment( + 'invalidid', + )(); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//deleted" if team environment is deleted successfully', async () => { + mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.deleteTeamEnvironment( + teamEnvironment.id, + )(); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/deleted`, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, + ); + }); + }); + + describe('updateVariablesInTeamEnvironment', () => { + test('should add new variable to a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: 'value' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }]), + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: 'value' }]), + }); + }); + + test('should add new variable to already existing list of variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: 'value' }, { key_2: 'value_2' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), + }); + }); + + test('should edit existing variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: '1234' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: '1234' }]), + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: '1234' }]), + }); + }); + + test('should delete existing variable in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{}]), + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{}]), + }); + }); + + test('should edit name of an existing team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ + ...teamEnvironment, + variables: [{ key: '123' }], + }); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: '123' }]), + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{ key: '123' }]), + }); + }); + + test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + 'invalidid', + teamEnvironment.name, + JSON.stringify(teamEnvironment.variables), + )(); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//updated" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + teamEnvironment.name, + JSON.stringify([{ key: 'value' }]), + )(); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/updated`, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, + ); + }); + }); + + describe('deleteAllVariablesFromTeamEnvironment', () => { + test('should delete all variables in a team environment', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + const result = + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + teamEnvironment.id, + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + variables: JSON.stringify([{}]), + }); + }); + + test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); + + const result = + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + 'invalidid', + )(); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//updated" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); + + const result = + await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + teamEnvironment.id, + )(); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/updated`, + { + ...teamEnvironment, + variables: JSON.stringify([{}]), + }, + ); + }); + }); + + describe('createDuplicateEnvironment', () => { + test('should duplicate an existing team environment', async () => { + mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( + teamEnvironment, + ); + + mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ + ...teamEnvironment, + id: 'newid', + }); + + const result = await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + )(); + + expect(result).toEqualRight({ + ...teamEnvironment, + id: 'newid', + variables: JSON.stringify(teamEnvironment.variables), + }); + }); + + test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); + + const result = await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + )(); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); + }); + + test('should send pubsub message to "team_environment//created" if team environment is updated successfully', async () => { + mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( + teamEnvironment, + ); + + mockPrisma.teamEnvironment.create.mockResolvedValueOnce({ + ...teamEnvironment, + id: 'newid', + }); + + const result = await teamEnvironmentsService.createDuplicateEnvironment( + teamEnvironment.id, + )(); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team_environment/${teamEnvironment.teamID}/created`, + { + ...teamEnvironment, + id: 'newid', + variables: JSON.stringify([{}]), + }, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts new file mode 100644 index 000000000..582757d6c --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts @@ -0,0 +1,234 @@ +import { Injectable } from '@nestjs/common'; +import { pipe } from 'fp-ts/function'; +import * as T from 'fp-ts/Task'; +import * as TO from 'fp-ts/TaskOption'; +import * as TE from 'fp-ts/TaskEither'; +import * as A from 'fp-ts/Array'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { TeamEnvironment } from './team-environments.model'; +import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; + +@Injectable() +export class TeamEnvironmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + getTeamEnvironment(id: string) { + return TO.tryCatch(() => + this.prisma.teamEnvironment.findFirst({ + where: { id }, + rejectOnNotFound: true, + }), + ); + } + + createTeamEnvironment(name: string, teamID: string, variables: string) { + return pipe( + () => + this.prisma.teamEnvironment.create({ + data: { + name: name, + teamID: teamID, + variables: JSON.parse(variables), + }, + }), + T.chainFirst( + (environment) => () => + this.pubsub.publish( + `team_environment/${environment.teamID}/created`, + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + T.map((data) => { + return { + id: data.id, + name: data.name, + teamID: data.teamID, + variables: JSON.stringify(data.variables), + }; + }), + ); + } + + deleteTeamEnvironment(id: string) { + return pipe( + TE.tryCatch( + () => + this.prisma.teamEnvironment.delete({ + where: { + id: id, + }, + }), + () => TEAM_ENVIRONMENT_NOT_FOUND, + ), + TE.chainFirst((environment) => + TE.fromTask(() => + this.pubsub.publish( + `team_environment/${environment.teamID}/deleted`, + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + ), + TE.map((data) => true), + ); + } + + updateTeamEnvironment(id: string, name: string, variables: string) { + return pipe( + TE.tryCatch( + () => + this.prisma.teamEnvironment.update({ + where: { id: id }, + data: { + name, + variables: JSON.parse(variables), + }, + }), + () => TEAM_ENVIRONMENT_NOT_FOUND, + ), + TE.chainFirst((environment) => + TE.fromTask(() => + this.pubsub.publish( + `team_environment/${environment.teamID}/updated`, + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + ), + TE.map( + (environment) => + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ); + } + + deleteAllVariablesFromTeamEnvironment(id: string) { + return pipe( + TE.tryCatch( + () => + this.prisma.teamEnvironment.update({ + where: { id: id }, + data: { + variables: [], + }, + }), + () => TEAM_ENVIRONMENT_NOT_FOUND, + ), + TE.chainFirst((environment) => + TE.fromTask(() => + this.pubsub.publish( + `team_environment/${environment.teamID}/updated`, + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + ), + TE.map( + (environment) => + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ); + } + + createDuplicateEnvironment(id: string) { + return pipe( + TE.tryCatch( + () => + this.prisma.teamEnvironment.findFirst({ + where: { + id: id, + }, + rejectOnNotFound: true, + }), + () => TEAM_ENVIRONMENT_NOT_FOUND, + ), + TE.chain((environment) => + TE.fromTask(() => + this.prisma.teamEnvironment.create({ + data: { + name: environment.name, + teamID: environment.teamID, + variables: environment.variables as Prisma.JsonArray, + }, + }), + ), + ), + TE.chainFirst((environment) => + TE.fromTask(() => + this.pubsub.publish( + `team_environment/${environment.teamID}/created`, + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + ), + TE.map( + (environment) => + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ); + } + + fetchAllTeamEnvironments(teamID: string) { + return pipe( + () => + this.prisma.teamEnvironment.findMany({ + where: { + teamID: teamID, + }, + }), + T.map( + A.map( + (environment) => + { + id: environment.id, + name: environment.name, + teamID: environment.teamID, + variables: JSON.stringify(environment.variables), + }, + ), + ), + ); + } +} diff --git a/packages/hoppscotch-backend/src/team-environments/team.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts new file mode 100644 index 000000000..fb83db9d7 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts @@ -0,0 +1,16 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Team } from 'src/team/team.model'; +import { TeamEnvironment } from './team-environments.model'; +import { TeamEnvironmentsService } from './team-environments.service'; + +@Resolver(() => Team) +export class TeamEnvsTeamResolver { + constructor(private teamEnvironmentService: TeamEnvironmentsService) {} + + @ResolveField(() => [TeamEnvironment], { + description: 'Returns all Team Environments for the given Team', + }) + teamEnvironments(@Parent() team: Team): Promise { + return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)(); + } +} diff --git a/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts index b3036e02b..afbff4b6c 100644 --- a/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts +++ b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts @@ -9,6 +9,7 @@ import { BUG_AUTH_NO_USER_CTX, BUG_TEAM_NO_REQUIRE_TEAM_ROLE, BUG_TEAM_NO_TEAM_ID, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; @Injectable() @@ -23,24 +24,20 @@ export class GqlTeamMemberGuard implements CanActivate { 'requiresTeamRole', context.getHandler(), ); - if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE); const gqlExecCtx = GqlExecutionContext.create(context); const { user } = gqlExecCtx.getContext().req; - if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); const { teamID } = gqlExecCtx.getArgs<{ teamID: string }>(); - if (!teamID) throw new Error(BUG_TEAM_NO_TEAM_ID); - const status = await this.teamService.getTeamMember(teamID, user.uid); + const teamMember = await this.teamService.getTeamMember(teamID, user.uid); + if (!teamMember) throw new Error(TEAM_MEMBER_NOT_FOUND); - if (!status) throw new Error('team/member_not_found'); - - if (requireRoles.includes(status.role)) return true; + if (requireRoles.includes(teamMember.role)) return true; throw new Error(TEAM_NOT_REQUIRED_ROLE); } diff --git a/packages/hoppscotch-backend/src/team/team.model.ts b/packages/hoppscotch-backend/src/team/team.model.ts index f047da40e..c370b0ee7 100644 --- a/packages/hoppscotch-backend/src/team/team.model.ts +++ b/packages/hoppscotch-backend/src/team/team.model.ts @@ -16,7 +16,7 @@ export class Team { @ObjectType() export class TeamMember { @Field(() => ID, { - description: 'Membership ID of the Team Member' + description: 'Membership ID of the Team Member', }) membershipID: string; diff --git a/packages/hoppscotch-backend/src/team/team.module.ts b/packages/hoppscotch-backend/src/team/team.module.ts index 01df035f9..50bfa6190 100644 --- a/packages/hoppscotch-backend/src/team/team.module.ts +++ b/packages/hoppscotch-backend/src/team/team.module.ts @@ -8,7 +8,7 @@ import { PrismaModule } from '../prisma/prisma.module'; import { PubSubModule } from '../pubsub/pubsub.module'; @Module({ - imports: [UserModule ,PubSubModule , PrismaModule ], + imports: [UserModule, PubSubModule, PrismaModule], providers: [ TeamService, TeamResolver, diff --git a/packages/hoppscotch-backend/src/team/team.service.ts b/packages/hoppscotch-backend/src/team/team.service.ts index b16524d30..80d239661 100644 --- a/packages/hoppscotch-backend/src/team/team.service.ts +++ b/packages/hoppscotch-backend/src/team/team.service.ts @@ -66,7 +66,7 @@ export class TeamService implements UserDataHandler, OnModuleInit { role: TeamMemberRole, ): Promise | E.Right> { const user = await this.userService.findUserByEmail(email); - if(O.isNone(user)) return E.left(USER_NOT_FOUND); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); const teamMember = await this.addMemberToTeam(teamID, user.value.uid, role); return E.right(teamMember); diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 7deafd76b..8ef4df427 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -1,12 +1,14 @@ import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/Option'; import * as TE from 'fp-ts/TaskEither'; import * as T from 'fp-ts/Task'; import * as E from 'fp-ts/Either'; -import { User } from './user/user.model'; import * as A from 'fp-ts/Array'; +import { TeamMemberRole } from './team/team.model'; +import { User } from './user/user.model'; import { JSON_INVALID } from './errors'; /** @@ -51,14 +53,14 @@ export const namedTrace = * @param context NestJS Execution Context * @returns An Option which contains the defined roles */ -// export const getAnnotatedRequiredRoles = ( -// reflector: Reflector, -// context: ExecutionContext, -// ) => -// pipe( -// reflector.get('requiresTeamRole', context.getHandler()), -// O.fromNullable, -// ); +export const getAnnotatedRequiredRoles = ( + reflector: Reflector, + context: ExecutionContext, +) => + pipe( + reflector.get('requiresTeamRole', context.getHandler()), + O.fromNullable, + ); /** * Gets the user from the NestJS GQL Execution Context. @@ -70,7 +72,7 @@ export const getUserFromGQLContext = (ctx: ExecutionContext) => pipe( ctx, GqlExecutionContext.create, - (ctx) => ctx.getContext<{ user?: User }>(), + (ctx) => ctx.getContext().req, ({ user }) => user, O.fromNullable, );