-
Notifications
You must be signed in to change notification settings - Fork 0
Code Review Bench PR #26644 - feat: api v2 team invite link endpoint #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| import { bootstrap } from "@/bootstrap"; | ||
| import { AppModule } from "@/app.module"; | ||
| import { PrismaModule } from "@/modules/prisma/prisma.module"; | ||
| import { TokensModule } from "@/modules/tokens/tokens.module"; | ||
| import { UsersModule } from "@/modules/users/users.module"; | ||
| import { INestApplication } from "@nestjs/common"; | ||
| import { NestExpressApplication } from "@nestjs/platform-express"; | ||
| import { Test } from "@nestjs/testing"; | ||
| import request from "supertest"; | ||
| import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; | ||
| 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 { withApiAuth } from "test/utils/withApiAuth"; | ||
|
|
||
| import { SUCCESS_STATUS } from "@calcom/platform-constants"; | ||
| import type { Team, User } from "@calcom/prisma/client"; | ||
|
|
||
| describe("Teams Invite Endpoints", () => { | ||
| describe("User Authentication - User is Team Admin", () => { | ||
| let app: INestApplication; | ||
|
|
||
| let userRepositoryFixture: UserRepositoryFixture; | ||
| let teamsRepositoryFixture: TeamRepositoryFixture; | ||
| let membershipsRepositoryFixture: MembershipRepositoryFixture; | ||
|
|
||
| let team: Team; | ||
|
|
||
| const userEmail = `teams-invite-admin-${randomString()}@api.com`; | ||
|
|
||
| let user: User; | ||
|
|
||
| beforeAll(async () => { | ||
| const moduleRef = await withApiAuth( | ||
| userEmail, | ||
| Test.createTestingModule({ | ||
| imports: [AppModule, PrismaModule, UsersModule, TokensModule], | ||
| }) | ||
| ).compile(); | ||
|
|
||
| userRepositoryFixture = new UserRepositoryFixture(moduleRef); | ||
| teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); | ||
| membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); | ||
|
|
||
| user = await userRepositoryFixture.create({ | ||
| email: userEmail, | ||
| username: userEmail, | ||
| }); | ||
|
|
||
| team = await teamsRepositoryFixture.create({ | ||
| name: `teams-invite-team-${randomString()}`, | ||
| isOrganization: false, | ||
| }); | ||
|
|
||
| // Admin of the team | ||
| await membershipsRepositoryFixture.create({ | ||
| role: "ADMIN", | ||
| user: { connect: { id: user.id } }, | ||
| team: { connect: { id: team.id } }, | ||
| }); | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| bootstrap(app as NestExpressApplication); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| it("should create a team invite", async () => { | ||
| return request(app.getHttpServer()) | ||
| .post(`/v2/teams/${team.id}/invite`) | ||
| .expect(200) | ||
| .then((response) => { | ||
| expect(response.body.status).toEqual(SUCCESS_STATUS); | ||
| expect(response.body.data.token.length).toBeGreaterThan(0); | ||
| expect(response.body.data.inviteLink).toEqual(expect.any(String)); | ||
| expect(response.body.data.inviteLink).toContain(response.body.data.token); | ||
| }); | ||
| }); | ||
|
|
||
| it("should create a new invite on each request", async () => { | ||
| const first = await request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(200); | ||
| const firstToken = first.body.data.token as string; | ||
|
|
||
| return request(app.getHttpServer()) | ||
| .post(`/v2/teams/${team.id}/invite`) | ||
| .expect(200) | ||
| .then((response) => { | ||
| expect(response.body.status).toEqual(SUCCESS_STATUS); | ||
| expect(response.body.data.token).not.toEqual(firstToken); | ||
| expect(response.body.data.inviteLink).toEqual(expect.any(String)); | ||
| expect(response.body.data.inviteLink).toContain(response.body.data.token); | ||
| }); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await userRepositoryFixture.deleteByEmail(user.email); | ||
| await teamsRepositoryFixture.delete(team.id); | ||
| await app.close(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("User Authentication - User is Team Member (not Admin)", () => { | ||
| let app: INestApplication; | ||
|
|
||
| let userRepositoryFixture: UserRepositoryFixture; | ||
| let teamsRepositoryFixture: TeamRepositoryFixture; | ||
| let membershipsRepositoryFixture: MembershipRepositoryFixture; | ||
|
|
||
| let team: Team; | ||
|
|
||
| const userEmail = `teams-invite-member-${randomString()}@api.com`; | ||
|
|
||
| let user: User; | ||
|
|
||
| beforeAll(async () => { | ||
| const moduleRef = await withApiAuth( | ||
| userEmail, | ||
| Test.createTestingModule({ | ||
| imports: [AppModule, PrismaModule, UsersModule, TokensModule], | ||
| }) | ||
| ).compile(); | ||
|
|
||
| userRepositoryFixture = new UserRepositoryFixture(moduleRef); | ||
| teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); | ||
| membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); | ||
|
|
||
| user = await userRepositoryFixture.create({ | ||
| email: userEmail, | ||
| username: userEmail, | ||
| }); | ||
|
|
||
| team = await teamsRepositoryFixture.create({ | ||
| name: `teams-invite-member-team-${randomString()}`, | ||
| isOrganization: false, | ||
| }); | ||
|
|
||
| // Regular member of the team (not admin) | ||
| await membershipsRepositoryFixture.create({ | ||
| role: "MEMBER", | ||
| user: { connect: { id: user.id } }, | ||
| team: { connect: { id: team.id } }, | ||
| }); | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| bootstrap(app as NestExpressApplication); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| it("should fail to create invite as non-admin member", async () => { | ||
| return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await userRepositoryFixture.deleteByEmail(user.email); | ||
| await teamsRepositoryFixture.delete(team.id); | ||
| await app.close(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("User Authentication - User is not a Team Member", () => { | ||
| let app: INestApplication; | ||
|
|
||
| let userRepositoryFixture: UserRepositoryFixture; | ||
| let teamsRepositoryFixture: TeamRepositoryFixture; | ||
|
|
||
| let team: Team; | ||
|
|
||
| const userEmail = `teams-invite-non-member-${randomString()}@api.com`; | ||
|
|
||
| let user: User; | ||
|
|
||
| beforeAll(async () => { | ||
| const moduleRef = await withApiAuth( | ||
| userEmail, | ||
| Test.createTestingModule({ | ||
| imports: [AppModule, PrismaModule, UsersModule, TokensModule], | ||
| }) | ||
| ).compile(); | ||
|
|
||
| userRepositoryFixture = new UserRepositoryFixture(moduleRef); | ||
| teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); | ||
|
|
||
| user = await userRepositoryFixture.create({ | ||
| email: userEmail, | ||
| username: userEmail, | ||
| }); | ||
|
|
||
| team = await teamsRepositoryFixture.create({ | ||
| name: `teams-invite-non-member-team-${randomString()}`, | ||
| isOrganization: false, | ||
| }); | ||
|
|
||
| // User is NOT a member of this team | ||
|
|
||
| app = moduleRef.createNestApplication(); | ||
| bootstrap(app as NestExpressApplication); | ||
| await app.init(); | ||
| }); | ||
|
|
||
| it("should fail to create invite as non-member", async () => { | ||
| return request(app.getHttpServer()).post(`/v2/teams/${team.id}/invite`).expect(403); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await userRepositoryFixture.deleteByEmail(user.email); | ||
| await teamsRepositoryFixture.delete(team.id); | ||
| await app.close(); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { API_VERSIONS_VALUES } from "@/lib/api-versions"; | ||
| import { API_KEY_HEADER } from "@/lib/docs/headers"; | ||
| 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"; | ||
| import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; | ||
| import { CreateInviteOutputDto } from "@/modules/teams/invite/outputs/invite.output"; | ||
|
|
||
| import { | ||
| Controller, | ||
| UseGuards, | ||
| Post, | ||
| Param, | ||
| ParseIntPipe, | ||
| HttpCode, | ||
| HttpStatus, | ||
| } from "@nestjs/common"; | ||
| import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; | ||
|
|
||
| import { SUCCESS_STATUS } from "@calcom/platform-constants"; | ||
| import { TeamService } from "@calcom/features/ee/teams/services/teamService"; | ||
|
|
||
| @Controller({ | ||
| path: "/v2/teams/:teamId", | ||
| version: API_VERSIONS_VALUES, | ||
| }) | ||
| @UseGuards(ApiAuthGuard, RolesGuard) | ||
| @DocsTags("Teams / Invite") | ||
| @ApiHeader(API_KEY_HEADER) | ||
| export class TeamsInviteController { | ||
| @Post("/invite") | ||
| @Roles("TEAM_MEMBER") | ||
| @ApiOperation({ summary: "Create team invite link" }) | ||
| @HttpCode(HttpStatus.OK) | ||
| async createInvite( | ||
| @Param("teamId", ParseIntPipe) teamId: number | ||
| ): Promise<CreateInviteOutputDto> { | ||
| const result = await TeamService.createInvite(teamId); | ||
| return { status: SUCCESS_STATUS, data: result }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; | ||
| import { ApiProperty } from "@nestjs/swagger"; | ||
| import { Expose, Type } from "class-transformer"; | ||
| import { IsEnum, IsString, ValidateNested } from "class-validator"; | ||
|
|
||
| export class InviteDataDto { | ||
| @IsString() | ||
| @Expose() | ||
| @ApiProperty({ | ||
| description: | ||
| "Unique invitation token for this team. Share this token with prospective members to allow them to join the team.", | ||
| example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2", | ||
| }) | ||
| token!: string; | ||
|
|
||
| @IsString() | ||
| @Expose() | ||
| @ApiProperty({ | ||
| description: | ||
| "Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.", | ||
| example: | ||
| "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started", | ||
| }) | ||
| inviteLink!: string; | ||
| } | ||
|
|
||
| export class CreateInviteOutputDto { | ||
| @Expose() | ||
| @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) | ||
| @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) | ||
| status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; | ||
|
|
||
| @Expose() | ||
| @ValidateNested() | ||
| @Type(() => InviteDataDto) | ||
| @ApiProperty({ type: InviteDataDto }) | ||
| data!: InviteDataDto; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { MembershipsModule } from "@/modules/memberships/memberships.module"; | ||
| import { PrismaModule } from "@/modules/prisma/prisma.module"; | ||
| import { RedisModule } from "@/modules/redis/redis.module"; | ||
| import { TeamsInviteController } from "@/modules/teams/invite/controllers/teams-invite.controller"; | ||
| import { Module } from "@nestjs/common"; | ||
|
|
||
| @Module({ | ||
| imports: [PrismaModule, RedisModule, MembershipsModule], | ||
| controllers: [TeamsInviteController], | ||
| }) | ||
| export class TeamsInviteModule {} |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -85,7 +85,7 @@ export class TeamService { | |||||
| } | ||||||
|
|
||||||
| const token = randomBytes(32).toString("hex"); | ||||||
| await prisma.verificationToken.create({ | ||||||
| const newToken = await prisma.verificationToken.create({ | ||||||
| data: { | ||||||
| identifier: `invite-link-for-teamId-${teamId}`, | ||||||
| token, | ||||||
|
|
@@ -96,14 +96,14 @@ export class TeamService { | |||||
| }); | ||||||
|
|
||||||
| return { | ||||||
| token, | ||||||
| token: newToken.identifier, | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 Bug: Token field returns identifier string instead of actual tokenAt line 99, the returned This causes two problems:
The fix should return the actual Was this helpful? React with 👍 / 👎
Suggested change
|
||||||
| inviteLink: await TeamService.buildInviteLink(token, isOrganizationOrATeamInOrganization), | ||||||
| }; | ||||||
| } | ||||||
|
|
||||||
| private static async buildInviteLink(token: string, isOrgContext: boolean): Promise<string> { | ||||||
| const teamInviteLink = `${WEBAPP_URL}/teams?token=${token}`; | ||||||
| if (!isOrgContext) { | ||||||
| if (isOrgContext) { | ||||||
| return teamInviteLink; | ||||||
| } | ||||||
| const gettingStartedPath = await OnboardingPathService.getGettingStartedPathWhenInvited(prisma); | ||||||
|
|
@@ -564,4 +564,4 @@ export class TeamService { | |||||
| }), | ||||||
| ]); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚨 Bug: Role guard uses TEAM_MEMBER instead of TEAM_ADMIN
The controller uses
@Roles("TEAM_MEMBER")which is the lowest role level in the hierarchy (TEAM_ROLES = [TEAM_OWNER, TEAM_ADMIN, TEAM_MEMBER]). ThehasMinimumRolefunction inroles.guard.tscheckscheckedRoleIndex <= requiredRoleIndex, meaningTEAM_MEMBERat index 2 allows any team member (OWNER, ADMIN, or MEMBER) to access this endpoint.However, the intent (per PR description) is to restrict invite link creation to team administrators only. The E2E test also explicitly verifies that a non-admin MEMBER receives a 403 Forbidden response, which will fail with the current
TEAM_MEMBERrole since it permits all members.This should be
@Roles("TEAM_ADMIN")to match the intended authorization requirement and pass the E2E tests.Was this helpful? React with 👍 / 👎