fix: add authorization checks to booking reassignment endpoints (#25054)

This commit is contained in:
Volnei Munhoz
2025-11-10 19:20:48 -03:00
committed by GitHub
parent eae779b81c
commit fd390f9d43
5 changed files with 139 additions and 40 deletions
@@ -405,9 +405,9 @@ export class BookingsController_2024_08_13 {
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: ApiAuthGuardUser
@GetUser() reassignedByUser: ApiAuthGuardUser
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
const booking = await this.bookingsService.reassignBooking(bookingUid, reassignedByUser);
return {
status: SUCCESS_STATUS,
@@ -430,13 +430,13 @@ export class BookingsController_2024_08_13 {
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@Param("userId") userId: number,
@GetUser("id") reassignedById: number,
@GetUser() reassignedByUser: ApiAuthGuardUser,
@Body() body: ReassignToUserBookingInput_2024_08_13
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBookingToUser(
bookingUid,
userId,
reassignedById,
reassignedByUser,
body
);
@@ -12,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
@@ -20,7 +21,6 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
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 { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
@@ -72,6 +72,7 @@ type EmailSetup = {
team: Team;
member1: User;
member2: User;
member1ApiKey: string;
collectiveEventType: { id: number };
roundRobinEventType: { id: number };
};
@@ -89,25 +90,21 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
// Setup data for tests
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = "team-emails-2024-08-13-user-admin@example.com";
// Utility function to check response data type
const responseDataIsBooking = (data: any): data is BookingOutput_2024_08_13 => {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
const responseDataIsBooking = (data: unknown): data is BookingOutput_2024_08_13 => {
return !Array.isArray(data) && data !== null && typeof data === "object" && data && "id" in data;
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -122,6 +119,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
// Create a base organization for all tests
@@ -131,11 +129,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
emailsEnabledSetup = await setupTestEnvironment(true);
emailsDisabledSetup = await setupTestEnvironment(false);
await userRepositoryFixture.create({
email: authEmail,
organization: { connect: { id: organization.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -173,10 +166,15 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
const collectiveEvent = await createEventType("COLLECTIVE", team.id, [member1.id, member2.id]);
const roundRobinEvent = await createEventType("ROUND_ROBIN", team.id, [member1.id, member2.id]);
// Create API key for member1 to use in authorized tests
const { keyString } = await apiKeysRepositoryFixture.createApiKey(member1.id, null);
const member1ApiKey = `cal_test_${keyString}`;
return {
team,
member1,
member2,
member1ApiKey,
collectiveEventType: { id: collectiveEvent.id },
roundRobinEventType: { id: roundRobinEvent.id },
};
@@ -351,6 +349,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
: emailsDisabledSetup.member1.id;
const manualReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(manualReassignResponse.status).toBe(200);
@@ -359,6 +358,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
// --- 4. Automatic Reassign ---
const autoReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
.set("Authorization", `Bearer ${emailsDisabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(autoReassignResponse.status).toBe(200);
@@ -484,6 +484,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
const manualReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign/${reassignToId}`)
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(manualReassignResponse.status).toBe(200);
@@ -499,6 +500,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
jest.clearAllMocks();
const autoReassignResponse = await request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBookingUid}/reassign`)
.set("Authorization", `Bearer ${emailsEnabledSetup.member1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
expect(autoReassignResponse.status).toBe(200);
@@ -523,7 +525,6 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
afterAll(async () => {
// Clean up database records
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(authEmail);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member2.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { BOOKING_REASSIGN_PERMISSION_ERROR } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
@@ -11,6 +12,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
@@ -21,15 +23,11 @@ import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repo
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 { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import type { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import type { Booking, User, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
@@ -47,6 +45,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
const teamUserEmail = `reassign-bookings-2024-08-13-user1-${randomString()}@api.com`;
const teamUserEmail2 = `reassign-bookings-2024-08-13-user2-${randomString()}@api.com`;
@@ -54,6 +53,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
let teamUser1: User;
let teamUser2: User;
let teamUser3: User;
let teamUser1ApiKey: string;
let teamUser2ApiKey: string;
let teamRoundRobinEventTypeId: number;
let teamRoundRobinFixedHostEventTypeId: number;
@@ -69,12 +70,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
let rescheduleReasonBookingInitialHostId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -90,6 +88,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({
@@ -126,6 +125,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: `reassign-bookings-2024-08-13-user3-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(teamUser1.id, null);
teamUser1ApiKey = `cal_test_${keyString}`;
const { keyString: keyString2 } = await apiKeysRepositoryFixture.createApiKey(teamUser2.id, null);
teamUser2ApiKey = `cal_test_${keyString2}`;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `reassign-bookings-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
@@ -473,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -512,6 +518,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
.send(body)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -562,6 +569,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set("Authorization", `Bearer ${teamUser2ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -611,6 +619,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${teamUser3.id}`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -669,6 +678,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign/${reassignToHostId}`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -704,6 +714,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${bookingUid}/reassign`)
.set("Authorization", `Bearer ${teamUser1ApiKey}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
@@ -724,6 +735,51 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should return 403 when unauthorized user tries to reassign booking", async () => {
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
const unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
locale: "en",
name: `fake-user-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const unauthorizedApiKeyString = `cal_test_${keyString}`;
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
});
it("should return 403 when unauthorized user tries to reassign booking to specific user", async () => {
const unauthorizedUserEmail = `fake-user-${randomString()}@api.com`;
const unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
locale: "en",
name: `fake-user-${randomString()}`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
const unauthorizedApiKeyString = `cal_test_${keyString}`;
const response = await request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser2.id}`)
.send({ reason: "Testing unauthorized access" })
.set("Authorization", `Bearer ${unauthorizedApiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
expect(response.body.error.message).toBe(BOOKING_REASSIGN_PERMISSION_ERROR);
await userRepositoryFixture.deleteByEmail(unauthorizedUserEmail);
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
@@ -749,4 +805,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.close();
});
});
});
});
@@ -72,6 +72,17 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithEventType(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
include: {
eventType: true,
},
});
}
async getByUidWithUser(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -23,7 +23,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
ConflictException,
ForbiddenException,
@@ -38,6 +38,8 @@ import { Request } from "express";
import { DateTime } from "luxon";
import { z } from "zod";
export const BOOKING_REASSIGN_PERMISSION_ERROR = "You do not have permission to reassign this booking";
import {
getTranslation,
getAllUserBookings,
@@ -992,19 +994,34 @@ export class BookingsService_2024_08_13 {
});
}
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
async reassignBooking(bookingUid: string, reassignedByUser: ApiAuthGuardUser) {
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
if (!booking.eventType) {
throw new BadRequestException(
`Event type with id=${booking.eventTypeId} was not found in the database`
);
}
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
reassignedByUser,
booking.eventType
);
if (!isAllowed) {
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
}
const platformClientParams = booking.eventTypeId
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
: undefined;
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
const profile = this.usersService.getUserMainProfile(requestUser);
const profile = this.usersService.getUserMainProfile(reassignedByUser);
try {
await roundRobinReassignment({
@@ -1012,7 +1029,7 @@ export class BookingsService_2024_08_13 {
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
reassignedById: requestUser.id,
reassignedById: reassignedByUser.id,
});
} catch (error) {
if (error instanceof Error) {
@@ -1036,13 +1053,27 @@ export class BookingsService_2024_08_13 {
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
reassignedById: number,
reassignedByUser: ApiAuthGuardUser,
body: ReassignToUserBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
if (!booking.eventType) {
throw new BadRequestException(
`Event type with id=${booking.eventTypeId} was not found in the database`
);
}
const isAllowed = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
reassignedByUser,
booking.eventType
);
if (!isAllowed) {
throw new ForbiddenException(BOOKING_REASSIGN_PERMISSION_ERROR);
}
const user = await this.usersRepository.findByIdWithProfile(newUserId);
if (!user) {
@@ -1063,7 +1094,7 @@ export class BookingsService_2024_08_13 {
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
reassignedById: reassignedByUser.id,
emailsEnabled,
platformClientParams,
});