From 7ce1ee5cea77707cbb3c00e020bbef7ff2467e0b Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Tue, 27 Jan 2026 12:24:30 -0300 Subject: [PATCH] feat: add forcedSlowMode rate limit to Team and Organization DELETE endpoints (#27258) * feat: add forcedSlowMode rate limit to Team and Organization DELETE endpoints Co-Authored-By: alex@cal.com * test: add NoOpThrottlerGuard to disable rate limiting in tests - Create NoOpThrottlerGuard class that always allows requests through - Create withNoThrottler helper function to override CustomThrottlerGuard - Update teams.controller.e2e-spec.ts to use withNoThrottler - Update organizations-teams.controller.e2e-spec.ts to use withNoThrottler - Update organizations-organizations.controller.e2e-spec.ts to use withNoThrottler This prevents race conditions when tests fire rapidly against rate-limited endpoints. Co-Authored-By: alex@cal.com * fix: use jest.spyOn to mock throttler guard in tests Changed approach from overrideGuard to jest.spyOn for mocking CustomThrottlerGuard.handleRequest to always return true. This properly disables rate limiting in tests for the DELETE endpoints. Co-Authored-By: alex@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> --- ...tions-organizations.controller.e2e-spec.ts | 13 +- .../organizations-organizations.controller.ts | 42 +- ...organizations-teams.controller.e2e-spec.ts | 439 +++++++++--------- .../index/organizations-teams.controller.ts | 41 +- .../controllers/teams.controller.e2e-spec.ts | 13 +- .../teams/controllers/teams.controller.ts | 18 +- apps/api/v2/test/utils/withNoThrottler.ts | 5 + 7 files changed, 298 insertions(+), 273 deletions(-) create mode 100644 apps/api/v2/test/utils/withNoThrottler.ts diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts index 8e96a7efc4..1b7f4e3282 100644 --- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts @@ -29,6 +29,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; import { AppModule } from "@/app.module"; import { bootstrap } from "@/bootstrap"; import { getEnv } from "@/env"; @@ -91,12 +92,14 @@ describe("Organizations Organizations Endpoints", () => { const newDate = new Date(2035, 0, 9, 15, 0, 0); - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, TokensModule], - }).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - userRepositoryFixture = new UserRepositoryFixture(moduleRef); + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.ts index f2a957ad34..5fb2dc13fa 100644 --- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.ts +++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.ts @@ -1,5 +1,23 @@ +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { X_CAL_CLIENT_ID_HEADER, X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers"; +import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; @@ -17,24 +35,6 @@ import { CreateManagedOrganizationOutput } from "@/modules/organizations/organiz import { GetManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/get-managed-organization.output"; import { GetManagedOrganizationsOutput } from "@/modules/organizations/organizations/outputs/get-managed-organizations.output"; import { ManagedOrganizationsService } from "@/modules/organizations/organizations/services/managed-organizations.service"; -import { - Controller, - UseGuards, - Param, - ParseIntPipe, - Post, - Body, - Get, - Patch, - HttpCode, - HttpStatus, - Delete, - Query, -} from "@nestjs/common"; -import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; - -import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { SkipTakePagination } from "@calcom/platform-types"; const SCALE = "SCALE"; @@ -142,6 +142,7 @@ export class OrganizationsOrganizationsController { @Roles("ORG_ADMIN") @PlatformPlan(SCALE) @Delete("/:managedOrganizationId") + @Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "organizations_delete" }) @ApiOperation({ summary: "Delete an organization within an organization", description: @@ -151,9 +152,8 @@ export class OrganizationsOrganizationsController { async deleteOrganization( @Param("managedOrganizationId", ParseIntPipe) managedOrganizationId: number ): Promise { - const organization = await this.managedOrganizationsService.deleteManagedOrganization( - managedOrganizationId - ); + const organization = + await this.managedOrganizationsService.deleteManagedOrganization(managedOrganizationId); return { status: SUCCESS_STATUS, data: organization, diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts index 8b9d4e9959..7030658c35 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts @@ -12,6 +12,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository. import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; import { AppModule } from "@/app.module"; import { bootstrap } from "@/bootstrap"; import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; @@ -39,42 +40,44 @@ describe("Organizations Team Endpoints", () => { const userEmail = `organizations-teams-admin-${randomString()}@api.com`; let user: User; - beforeAll(async () => { - const moduleRef = await withApiAuth( - userEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, TokensModule], - }) - ).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); - teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); - membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); - user = await userRepositoryFixture.create({ - email: userEmail, - username: userEmail, - }); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); - org = await organizationsRepositoryFixture.create({ - name: `organizations-teams-organization-${randomString()}`, - isOrganization: true, - }); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); - await membershipsRepositoryFixture.create({ - role: "ADMIN", - user: { connect: { id: user.id } }, - team: { connect: { id: org.id } }, - }); + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); - team = await teamsRepositoryFixture.create({ - name: `organizations-teams-team1-${randomString()}`, - isOrganization: false, - parent: { connect: { id: org.id } }, - }); + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); - team2 = await teamsRepositoryFixture.create({ + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); + + team2 = await teamsRepositoryFixture.create({ name: `organizations-teams-team2-${randomString()}`, isOrganization: false, parent: { connect: { id: org.id } }, @@ -300,93 +303,95 @@ describe("Organizations Team Endpoints", () => { const userEmail = `organizations-teams-member-${randomString()}@api.com`; let user: User; - beforeAll(async () => { - const moduleRef = await withApiAuth( - userEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, TokensModule], - }) - ).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); - teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); - membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); - user = await userRepositoryFixture.create({ - email: userEmail, - username: userEmail, - }); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); - org = await organizationsRepositoryFixture.create({ - name: `organizations-teams-organization-${randomString()}`, - isOrganization: true, - }); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: user.id } }, - team: { connect: { id: org.id } }, - }); + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); - team = await teamsRepositoryFixture.create({ - name: `organizations-teams-team1-${randomString()}`, - isOrganization: false, - parent: { connect: { id: org.id } }, - }); + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); - team2 = await teamsRepositoryFixture.create({ - name: `organizations-teams-team2-${randomString()}`, - isOrganization: false, - parent: { connect: { id: org.id } }, - }); + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); - app = moduleRef.createNestApplication(); - bootstrap(app as NestExpressApplication); + team2 = await teamsRepositoryFixture.create({ + name: `organizations-teams-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); - await app.init(); - }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); - it("should be defined", () => { - expect(userRepositoryFixture).toBeDefined(); - expect(teamsRepositoryFixture).toBeDefined(); - expect(user).toBeDefined(); - expect(org).toBeDefined(); - }); + await app.init(); + }); - it("should deny get all the teams of the org", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); - }); + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(teamsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); - it("should deny get all the teams of the org paginated", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); - }); + it("should deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); - it("should deny get the team of the org", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403); - }); + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); - it("should deny create the team of the org", async () => { - return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/teams`) - .send({ - name: `organizations-teams-api-team1-${randomString()}`, - } satisfies CreateOrgTeamDto) - .expect(403); - }); + it("should deny get the team of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403); + }); - it("should deny update the team of the org", async () => { - return request(app.getHttpServer()) - .patch(`/v2/organizations/${org.id}/teams/${team.id}`) - .send({ - name: `organizations-teams-api-team1-${randomString()}-updated`, - } satisfies CreateOrgTeamDto) - .expect(403); - }); + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: `organizations-teams-api-team1-${randomString()}`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); - it("should deny delete the team of the org we created via api", async () => { - return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); - }); + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: `organizations-teams-api-team1-${randomString()}-updated`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); + }); afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); @@ -414,105 +419,107 @@ describe("Organizations Team Endpoints", () => { const userEmail = `organizations-teams-owner-${randomString()}@api.com`; let user: User; - beforeAll(async () => { - const moduleRef = await withApiAuth( - userEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, TokensModule], - }) - ).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); - teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); - membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); - user = await userRepositoryFixture.create({ - email: userEmail, - username: userEmail, - }); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); - org = await organizationsRepositoryFixture.create({ - name: `organizations-teams-organization-${randomString()}`, - isOrganization: true, - }); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); - await membershipsRepositoryFixture.create({ - role: "MEMBER", - user: { connect: { id: user.id } }, - team: { connect: { id: org.id } }, - }); + org = await organizationsRepositoryFixture.create({ + name: `organizations-teams-organization-${randomString()}`, + isOrganization: true, + }); - team = await teamsRepositoryFixture.create({ - name: `organizations-teams-team1-${randomString()}`, - isOrganization: false, - parent: { connect: { id: org.id } }, - }); + await membershipsRepositoryFixture.create({ + role: "MEMBER", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); - team2 = await teamsRepositoryFixture.create({ - name: `organizations-teams-team2-${randomString()}`, - isOrganization: false, - parent: { connect: { id: org.id } }, - }); + team = await teamsRepositoryFixture.create({ + name: `organizations-teams-team1-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); - await membershipsRepositoryFixture.create({ - role: "OWNER", - user: { connect: { id: user.id } }, - team: { connect: { id: team.id } }, - }); + team2 = await teamsRepositoryFixture.create({ + name: `organizations-teams-team2-${randomString()}`, + isOrganization: false, + parent: { connect: { id: org.id } }, + }); - await membershipsRepositoryFixture.create({ - role: "OWNER", - user: { connect: { id: user.id } }, - team: { connect: { id: team2.id } }, - }); + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team.id } }, + }); - app = moduleRef.createNestApplication(); - bootstrap(app as NestExpressApplication); + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: user.id } }, + team: { connect: { id: team2.id } }, + }); - await app.init(); - }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); - it("should be defined", () => { - expect(userRepositoryFixture).toBeDefined(); - expect(organizationsRepositoryFixture).toBeDefined(); - expect(user).toBeDefined(); - expect(org).toBeDefined(); - }); + await app.init(); + }); - it("should deny get all the teams of the org", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); - }); + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); - it("should deny get all the teams of the org paginated", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); - }); + it("should deny get all the teams of the org", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403); + }); - it("should get the team of the org for which the user is team owner", async () => { - return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200); - }); + it("should deny get all the teams of the org paginated", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403); + }); - it("should deny create the team of the org", async () => { - return request(app.getHttpServer()) - .post(`/v2/organizations/${org.id}/teams`) - .send({ - name: `organizations-teams-api-team1-${randomString()}`, - } satisfies CreateOrgTeamDto) - .expect(403); - }); + it("should get the team of the org for which the user is team owner", async () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200); + }); - it("should deny update the team of the org", async () => { - return request(app.getHttpServer()) - .patch(`/v2/organizations/${org.id}/teams/${team.id}`) - .send({ - name: `organizations-teams-api-team1-${randomString()}-updated`, - } satisfies CreateOrgTeamDto) - .expect(403); - }); + it("should deny create the team of the org", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/teams`) + .send({ + name: `organizations-teams-api-team1-${randomString()}`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); - it("should deny delete the team of the org we created via api", async () => { - return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); - }); + it("should deny update the team of the org", async () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/teams/${team.id}`) + .send({ + name: `organizations-teams-api-team1-${randomString()}-updated`, + } satisfies CreateOrgTeamDto) + .expect(403); + }); + + it("should deny delete the team of the org we created via api", async () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403); + }); afterAll(async () => { await userRepositoryFixture.deleteByEmail(user.email); @@ -544,43 +551,45 @@ describe("Organizations Team Endpoints", () => { const userEmail = `organizations-teams-platform-owner-${randomString()}@api.com`; let user: User; - beforeAll(async () => { - const moduleRef = await withApiAuth( - userEmail, - Test.createTestingModule({ - imports: [AppModule, PrismaModule, UsersModule, TokensModule], - }) - ).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - userRepositoryFixture = new UserRepositoryFixture(moduleRef); - teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); - membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); - oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); - orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); - user = await userRepositoryFixture.create({ - email: userEmail, - username: userEmail, - }); + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); - org = await orgRepositoryFixture.create({ - name: `organizations-teams-platform-organization-${randomString()}`, - isOrganization: true, - }); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); - await membershipsRepositoryFixture.create({ - role: "ADMIN", - user: { connect: { id: user.id } }, - team: { connect: { id: org.id } }, - }); + org = await orgRepositoryFixture.create({ + name: `organizations-teams-platform-organization-${randomString()}`, + isOrganization: true, + }); - oAuthClient1 = await createOAuthClient(org.id); - oAuthClient2 = await createOAuthClient(org.id); + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); - app = moduleRef.createNestApplication(); - bootstrap(app as NestExpressApplication); + oAuthClient1 = await createOAuthClient(org.id); + oAuthClient2 = await createOAuthClient(org.id); - await app.init(); - }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); async function createOAuthClient(organizationId: number) { const data = { diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts index 479a422d5a..8916dedd4d 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.ts @@ -1,9 +1,29 @@ +import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { OrgTeamOutputDto, SkipTakePagination } from "@calcom/platform-types"; +import type { Team } from "@calcom/prisma/client"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; +import { Request } from "express"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER, } from "@/lib/docs/headers"; +import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -25,26 +45,6 @@ import { } from "@/modules/organizations/teams/index/outputs/organization-team.output"; import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { - Controller, - UseGuards, - Get, - Param, - ParseIntPipe, - Query, - Delete, - Patch, - Post, - Body, - Req, -} from "@nestjs/common"; -import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; -import { plainToClass } from "class-transformer"; -import { Request } from "express"; - -import { SUCCESS_STATUS, X_CAL_CLIENT_ID } from "@calcom/platform-constants"; -import { OrgTeamOutputDto, SkipTakePagination } from "@calcom/platform-types"; -import type { Team } from "@calcom/prisma/client"; @Controller({ path: "/v2/organizations/:orgId/teams", @@ -121,6 +121,7 @@ export class OrganizationsTeamsController { @Roles("ORG_ADMIN") @PlatformPlan("ESSENTIALS") @Delete("/:teamId") + @Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "org_teams_delete" }) @ApiOperation({ summary: "Delete a team" }) async deleteTeam( @Param("orgId", ParseIntPipe) orgId: number, diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts index 210ceecf57..84a1c652ad 100644 --- a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts @@ -12,6 +12,7 @@ import { MembershipRepositoryFixture } from "test/fixtures/repository/membership import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; +import { mockThrottlerGuard } from "test/utils/withNoThrottler"; import { AppModule } from "@/app.module"; import { bootstrap } from "@/bootstrap"; import { StripeService } from "@/modules/stripe/stripe.service"; @@ -40,12 +41,14 @@ describe("Teams endpoint", () => { let team1: TeamOutputDto; let team2: TeamOutputDto; - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - imports: [AppModule, TeamsModule], - }).compile(); + beforeAll(async () => { + mockThrottlerGuard(); - jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(() => ({}) as unknown as Stripe); + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, TeamsModule], + }).compile(); + + jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(() => ({}) as unknown as Stripe); userRepositoryFixture = new UserRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts index 1f747b073c..3b16b790a3 100644 --- a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.ts @@ -1,5 +1,11 @@ +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { TeamOutputDto } from "@calcom/platform-types"; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { API_KEY_HEADER } from "@/lib/docs/headers"; +import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; @@ -14,12 +20,6 @@ import { UpdateTeamOutput } from "@/modules/teams/teams/outputs/teams/update-tea import { TeamsService } from "@/modules/teams/teams/services/teams.service"; import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { Controller, UseGuards, Get, Param, ParseIntPipe, Delete, Patch, Post, Body } from "@nestjs/common"; -import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; -import { plainToClass } from "class-transformer"; - -import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { TeamOutputDto } from "@calcom/platform-types"; @Controller({ path: "/v2/teams", @@ -29,7 +29,10 @@ import { TeamOutputDto } from "@calcom/platform-types"; @DocsTags("Teams") @ApiHeader(API_KEY_HEADER) export class TeamsController { - constructor(private teamsService: TeamsService, private teamsRepository: TeamsRepository) {} + constructor( + private teamsService: TeamsService, + private teamsRepository: TeamsRepository + ) {} @Post() @ApiOperation({ summary: "Create a team" }) @@ -95,6 +98,7 @@ export class TeamsController { @UseGuards(RolesGuard) @Delete("/:teamId") + @Throttle({ limit: 1, ttl: 30000, blockDuration: 30000, name: "teams_delete" }) @ApiOperation({ summary: "Delete a team" }) @Roles("TEAM_OWNER") async deleteTeam(@Param("teamId", ParseIntPipe) teamId: number): Promise { diff --git a/apps/api/v2/test/utils/withNoThrottler.ts b/apps/api/v2/test/utils/withNoThrottler.ts new file mode 100644 index 0000000000..ce4e3cdd5d --- /dev/null +++ b/apps/api/v2/test/utils/withNoThrottler.ts @@ -0,0 +1,5 @@ +import { CustomThrottlerGuard } from "@/lib/throttler-guard"; + +export const mockThrottlerGuard = (): void => { + jest.spyOn(CustomThrottlerGuard.prototype, "handleRequest").mockResolvedValue(true); +};