Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/v2/src/ee/platform-endpoints-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsInviteModule } from "@/modules/teams/invite/teams-invite.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
Expand All @@ -32,6 +33,7 @@ import { Module } from "@nestjs/common";
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
TeamsInviteModule,
SlotsModule_2024_04_15,
SlotsModule_2024_09_04,
TeamsModule,
Expand Down
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")

Copy link
Copy Markdown

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]). The hasMinimumRole function in roles.guard.ts checks checkedRoleIndex <= requiredRoleIndex, meaning TEAM_MEMBER at 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_MEMBER role 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 👍 / 👎

Suggested change
@Roles("TEAM_MEMBER")
@Roles("TEAM_ADMIN")
  • Apply suggested fix

@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 };
}
}
38 changes: 38 additions & 0 deletions apps/api/v2/src/modules/teams/invite/outputs/invite.output.ts
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;
}
11 changes: 11 additions & 0 deletions apps/api/v2/src/modules/teams/invite/teams-invite.module.ts
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 {}
8 changes: 4 additions & 4 deletions packages/features/ee/teams/services/teamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -96,14 +96,14 @@ export class TeamService {
});

return {
token,
token: newToken.identifier,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 Bug: Token field returns identifier string instead of actual token

At line 99, the returned token field is set to newToken.identifier, which is the descriptive string "invite-link-for-teamId-{teamId}" — not the actual cryptographic token. Meanwhile, buildInviteLink on line 100 correctly uses the token variable (the random hex string) to construct the URL.

This causes two problems:

  1. API response contains wrong value: Consumers receive "invite-link-for-teamId-123" as the token instead of the actual crypto hex token.
  2. E2E test will fail: The test asserts expect(response.body.data.inviteLink).toContain(response.body.data.token) — the identifier string won't appear in the invite URL, so this assertion fails.

The fix should return the actual token (the random hex), or equivalently newToken.token, since that's what is embedded in the invite link URL.

Was this helpful? React with 👍 / 👎

Suggested change
token: newToken.identifier,
token: newToken.token,
  • Apply suggested fix

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);
Expand Down Expand Up @@ -564,4 +564,4 @@ export class TeamService {
}),
]);
}
}
}