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:
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user