fix: Remove hosts - verify event type belongs to event type (#25321)

* Add check that event type belongs to team

* Add `findAcceptedMembershipsByUserIdsInTeam` to `MembershipRepository`

* Validate that passed `userIds` belong to a team

* Add tests

* Typo fix
This commit is contained in:
Joe Au-Yeung
2025-11-21 10:26:22 -05:00
committed by GitHub
parent 5cb1d4aad0
commit dd7c553cc3
3 changed files with 375 additions and 1 deletions
@@ -134,6 +134,22 @@ export class MembershipRepository {
});
}
static async findAcceptedMembershipsByUserIdsInTeam({
userIds,
teamId,
}: {
userIds: number[];
teamId: number;
}) {
return prisma.membership.findMany({
where: {
userId: { in: userIds },
accepted: true,
teamId,
},
});
}
static async createMany(data: IMembership[]) {
return await prisma.membership.createMany({
data: data.map((item) => ({
@@ -0,0 +1,346 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, beforeEach, vi, expect } from "vitest";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import prisma from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../types";
import removeHostsFromEventTypesHandler from "./removeHostsFromEventTypes.handler";
vi.mock("@calcom/prisma", () => ({
default: {
host: {
deleteMany: vi.fn(),
},
},
}));
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
PermissionCheckService: vi.fn(),
}));
vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({
MembershipRepository: {
findAcceptedMembershipsByUserIdsInTeam: vi.fn(),
},
}));
describe("removeHostsFromEventTypesHandler", () => {
const mockUser = {
id: 1,
name: "Test User",
} as NonNullable<TrpcSessionUser>;
const mockInput = {
userIds: [101, 102],
eventTypeIds: [201, 202],
teamId: 300,
};
beforeEach(() => {
vi.clearAllMocks();
});
it("throws UNAUTHORIZED if user does not have eventType.update permission", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(false);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
await expect(
removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: mockInput,
})
).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: mockUser.id,
teamId: mockInput.teamId,
permission: "eventType.update",
fallbackRoles: ["OWNER", "ADMIN"],
});
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).not.toHaveBeenCalled();
expect(prisma.host.deleteMany).not.toHaveBeenCalled();
});
it("deletes hosts when user has permission and all users are team members", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Mock that all userIds are valid team members
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 101 },
{ userId: 102 },
]);
const mockDeleteResult = { count: 3 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: mockInput,
});
expect(result).toEqual(mockDeleteResult);
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: mockUser.id,
teamId: mockInput.teamId,
permission: "eventType.update",
fallbackRoles: ["OWNER", "ADMIN"],
});
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).toHaveBeenCalledWith({
userIds: mockInput.userIds,
teamId: mockInput.teamId,
});
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: mockInput.eventTypeIds,
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: mockInput.userIds,
},
},
});
});
it("only removes hosts for userIds that are team members (filters out non-members)", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Mock that only userId 101 is a team member, 102 is not
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 101 },
]);
const mockDeleteResult = { count: 1 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: mockInput,
});
expect(result).toEqual(mockDeleteResult);
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).toHaveBeenCalledWith({
userIds: mockInput.userIds,
teamId: mockInput.teamId,
});
// Should only delete for userId 101, not 102
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: mockInput.eventTypeIds,
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: [101], // Only 101, not 102
},
},
});
});
it("handles empty userIds array", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Empty array means no memberships to validate
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([]);
const mockDeleteResult = { count: 0 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const emptyUsersInput = {
...mockInput,
userIds: [],
};
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: emptyUsersInput,
});
expect(result).toEqual(mockDeleteResult);
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: mockInput.eventTypeIds,
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: [],
},
},
});
});
it("handles empty eventTypeIds array", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Mock that all userIds are valid team members
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 101 },
{ userId: 102 },
]);
const mockDeleteResult = { count: 0 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const emptyEventTypesInput = {
...mockInput,
eventTypeIds: [],
};
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: emptyEventTypesInput,
});
expect(result).toEqual(mockDeleteResult);
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: [],
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: mockInput.userIds,
},
},
});
});
it("returns count of 0 when no hosts match the criteria", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Mock that all userIds are valid team members
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 101 },
{ userId: 102 },
]);
const mockDeleteResult = { count: 0 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: mockInput,
});
expect(result.count).toBe(0);
});
it("returns count of 0 when userId is a team member but not a host on the specified event types", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// User 999 is a valid team member
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 999 },
]);
// But they're not a host on any of the event types
const mockDeleteResult = { count: 0 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const nonHostInput = {
...mockInput,
userIds: [999],
};
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: nonHostInput,
});
expect(result.count).toBe(0);
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: mockInput.eventTypeIds,
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: [999],
},
},
});
});
it("returns count of 0 when event types do not belong to the specified team", async () => {
const mockCheckPermission = vi.fn().mockResolvedValue(true);
(PermissionCheckService as any).mockImplementation(() => ({
checkPermission: mockCheckPermission,
}));
// Mock that all userIds are valid team members
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
{ userId: 101 },
{ userId: 102 },
]);
// Event types belong to a different team, so no hosts should be deleted
const mockDeleteResult = { count: 0 };
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
const result = await removeHostsFromEventTypesHandler({
ctx: { user: mockUser },
input: mockInput,
});
expect(result.count).toBe(0);
// Verify the query includes the teamId check
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
where: {
eventTypeId: {
in: mockInput.eventTypeIds,
},
eventType: {
teamId: mockInput.teamId,
},
userId: {
in: mockInput.userIds,
},
},
});
});
});
@@ -1,3 +1,4 @@
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -28,13 +29,24 @@ export async function removeHostsFromEventTypesHandler({ ctx, input }: RemoveHos
// check if user has permission to update event types
if (!hasEventTypeUpdatePermission) throw new TRPCError({ code: "UNAUTHORIZED" });
// verify that all userIds are members of the team
const teamMemberIds = await MembershipRepository.findAcceptedMembershipsByUserIdsInTeam({
userIds,
teamId,
});
const filteredUserIds = teamMemberIds.map((teamMember) => teamMember.userId);
return await prisma.host.deleteMany({
where: {
eventTypeId: {
in: eventTypeIds,
},
eventType: {
teamId: teamId,
},
userId: {
in: userIds,
in: filteredUserIds,
},
},
});