* fix: align checkBookingAccessWithPBAC with listing logic for personal event types Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: align permission strings with doesUserIdHaveAccessToBooking granular permissions Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * change implementation * remove unused code * test: add getBookingDetails tests for personal event type booking access Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: remove unused MembershipRole import Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: convert BookingDetailsService tests to integration tests Replace unit tests with mocks by integration tests using real database. Tests verify org/team admin access to personal event type bookings. - Org admin viewing team member's personal event booking (fails on main, passes on PR) - Team admin viewing team member's personal event booking (fails on main, passes on PR) - Non-admin denied access (passes on both) - Owner viewing own booking (passes on both) - Non-existent booking returns Forbidden (passes on both) Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: refactor integration tests to use repositories instead of direct prisma calls Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: replace direct prisma usage with TestXXXRepository pattern in integration tests Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: use production repositories (OrganizationRepository, BookingRepository, UserRepository) instead of test repos for setup Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * test: inline test repository logic into integration test file The TestXXXRepository classes were thin wrappers around single prisma calls with barely any logic, so the indirection wasn't justified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import type { PrismaClient } from "@calcom/prisma";
|
|
import { MembershipRole } from "@calcom/prisma/enums";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { BookingRepository } from "../repositories/BookingRepository";
|
|
import { BookingAccessService } from "./BookingAccessService";
|
|
|
|
vi.mock("../repositories/BookingRepository");
|
|
vi.mock("@calcom/features/users/repositories/UserRepository");
|
|
|
|
describe("BookingAccessService", () => {
|
|
let service: BookingAccessService;
|
|
let mockPrismaClient: PrismaClient;
|
|
let mockBookingRepo: {
|
|
findByUidIncludeEventType: ReturnType<typeof vi.fn>;
|
|
};
|
|
let mockUserRepo: {
|
|
getUserOrganizationAndTeams: ReturnType<typeof vi.fn>;
|
|
};
|
|
let mockPermissionCheckService: {
|
|
checkPermission: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockPrismaClient = {} as PrismaClient;
|
|
|
|
mockBookingRepo = {
|
|
findByUidIncludeEventType: vi.fn(),
|
|
};
|
|
|
|
mockUserRepo = {
|
|
getUserOrganizationAndTeams: vi.fn(),
|
|
};
|
|
|
|
mockPermissionCheckService = {
|
|
checkPermission: vi.fn(),
|
|
};
|
|
|
|
vi.mocked(BookingRepository).mockImplementation(function () {
|
|
return mockBookingRepo as any;
|
|
});
|
|
vi.mocked(UserRepository).mockImplementation(function () {
|
|
return mockUserRepo as any;
|
|
});
|
|
|
|
service = new BookingAccessService(mockPrismaClient);
|
|
|
|
(service as any).permissionCheckService = mockPermissionCheckService;
|
|
});
|
|
|
|
describe("doesUserIdHaveAccessToBooking", () => {
|
|
describe("Case 1: Booking Organizer", () => {
|
|
it("should return true when user is the booking organizer", async () => {
|
|
const mockBooking = {
|
|
userId: 123,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockBookingRepo.findByUidIncludeEventType).toHaveBeenCalledWith({
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
});
|
|
|
|
it("should return false when user is not the organizer and booking has no team", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(null);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Case 2: Booking Host", () => {
|
|
it("should return true when user is a host in eventType.hosts", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
user: { id: 456, email: "organizer@example.com" },
|
|
eventType: {
|
|
hosts: [
|
|
{ userId: 123, user: { email: "host@example.com" } },
|
|
{ userId: 789, user: { email: "other-host@example.com" } },
|
|
],
|
|
users: [],
|
|
},
|
|
attendees: [{ email: "host@example.com" }],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return true when user is in eventType.users", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
user: { id: 456, email: "organizer@example.com" },
|
|
eventType: {
|
|
hosts: [],
|
|
users: [
|
|
{ id: 123, email: "user@example.com" },
|
|
{ id: 789, email: "other-user@example.com" },
|
|
],
|
|
},
|
|
attendees: [{ email: "user@example.com" }],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false when user is not a host", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
user: { id: 456, email: "organizer@example.com" },
|
|
eventType: {
|
|
hosts: [{ userId: 789, user: { email: "host@example.com" } }],
|
|
users: [],
|
|
},
|
|
attendees: [{ email: "host@example.com" }],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(null);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Case 3: Team Event Access", () => {
|
|
it("should return true when user has booking.readTeamBookings permission", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: {
|
|
teamId: 100,
|
|
},
|
|
attendees: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
|
userId: 123,
|
|
teamId: 100,
|
|
permission: "booking.readTeamBookings",
|
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
|
});
|
|
});
|
|
|
|
it("should return false when user lacks booking.readTeamBookings permission", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: {
|
|
teamId: 100,
|
|
},
|
|
attendees: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
|
userId: 123,
|
|
teamId: 100,
|
|
permission: "booking.readTeamBookings",
|
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Case 4: Org Admin Access (Personal Bookings)", () => {
|
|
it("should return true when user has booking.readOrgBookings permission", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
const mockBookingOwner = {
|
|
organizationId: 200,
|
|
teams: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
|
|
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
|
userId: 123,
|
|
teamId: 200,
|
|
permission: "booking.readOrgBookings",
|
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
|
});
|
|
});
|
|
|
|
it("should return false when user lacks booking.readOrgBookings permission", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
const mockBookingOwner = {
|
|
organizationId: 200,
|
|
teams: [],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
|
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Case 5: Team Admin Access (Personal Bookings)", () => {
|
|
it("should return true when user has booking.readTeamBookings on ANY team", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
const mockBookingOwner = {
|
|
organizationId: null,
|
|
teams: [{ teamId: 300 }, { teamId: 400 }],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
|
|
mockPermissionCheckService.checkPermission
|
|
.mockResolvedValueOnce(false) // Team 300 - no permission
|
|
.mockResolvedValueOnce(true); // Team 400 - has permission
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledTimes(2);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenNthCalledWith(1, {
|
|
userId: 123,
|
|
teamId: 300,
|
|
permission: "booking.readTeamBookings",
|
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
|
});
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenNthCalledWith(2, {
|
|
userId: 123,
|
|
teamId: 400,
|
|
permission: "booking.readTeamBookings",
|
|
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
|
});
|
|
});
|
|
|
|
it("should return false when user lacks permission on all teams", async () => {
|
|
const mockBooking = {
|
|
userId: 456,
|
|
eventType: null,
|
|
attendees: [],
|
|
};
|
|
|
|
const mockBookingOwner = {
|
|
organizationId: null,
|
|
teams: [{ teamId: 300 }, { teamId: 400 }],
|
|
};
|
|
|
|
mockBookingRepo.findByUidIncludeEventType.mockResolvedValue(mockBooking);
|
|
mockUserRepo.getUserOrganizationAndTeams.mockResolvedValue(mockBookingOwner);
|
|
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
|
|
|
const result = await service.doesUserIdHaveAccessToBooking({
|
|
userId: 123,
|
|
bookingUid: "test-booking-uid",
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
});
|