Skip to content

Commit dd7c553

Browse files
authored
fix: Remove hosts - verify event type belongs to event type (calcom#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
1 parent 5cb1d4a commit dd7c553

3 files changed

Lines changed: 375 additions & 1 deletion

File tree

packages/features/membership/repositories/MembershipRepository.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,22 @@ export class MembershipRepository {
134134
});
135135
}
136136

137+
static async findAcceptedMembershipsByUserIdsInTeam({
138+
userIds,
139+
teamId,
140+
}: {
141+
userIds: number[];
142+
teamId: number;
143+
}) {
144+
return prisma.membership.findMany({
145+
where: {
146+
userId: { in: userIds },
147+
accepted: true,
148+
teamId,
149+
},
150+
});
151+
}
152+
137153
static async createMany(data: IMembership[]) {
138154
return await prisma.membership.createMany({
139155
data: data.map((item) => ({
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, beforeEach, vi, expect } from "vitest";
3+
4+
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
5+
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
6+
import prisma from "@calcom/prisma";
7+
8+
import type { TrpcSessionUser } from "../../../types";
9+
import removeHostsFromEventTypesHandler from "./removeHostsFromEventTypes.handler";
10+
11+
vi.mock("@calcom/prisma", () => ({
12+
default: {
13+
host: {
14+
deleteMany: vi.fn(),
15+
},
16+
},
17+
}));
18+
19+
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
20+
PermissionCheckService: vi.fn(),
21+
}));
22+
23+
vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ({
24+
MembershipRepository: {
25+
findAcceptedMembershipsByUserIdsInTeam: vi.fn(),
26+
},
27+
}));
28+
29+
describe("removeHostsFromEventTypesHandler", () => {
30+
const mockUser = {
31+
id: 1,
32+
name: "Test User",
33+
} as NonNullable<TrpcSessionUser>;
34+
35+
const mockInput = {
36+
userIds: [101, 102],
37+
eventTypeIds: [201, 202],
38+
teamId: 300,
39+
};
40+
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
});
44+
45+
it("throws UNAUTHORIZED if user does not have eventType.update permission", async () => {
46+
const mockCheckPermission = vi.fn().mockResolvedValue(false);
47+
(PermissionCheckService as any).mockImplementation(() => ({
48+
checkPermission: mockCheckPermission,
49+
}));
50+
51+
await expect(
52+
removeHostsFromEventTypesHandler({
53+
ctx: { user: mockUser },
54+
input: mockInput,
55+
})
56+
).rejects.toMatchObject({
57+
code: "UNAUTHORIZED",
58+
});
59+
60+
expect(mockCheckPermission).toHaveBeenCalledWith({
61+
userId: mockUser.id,
62+
teamId: mockInput.teamId,
63+
permission: "eventType.update",
64+
fallbackRoles: ["OWNER", "ADMIN"],
65+
});
66+
67+
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).not.toHaveBeenCalled();
68+
expect(prisma.host.deleteMany).not.toHaveBeenCalled();
69+
});
70+
71+
it("deletes hosts when user has permission and all users are team members", async () => {
72+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
73+
(PermissionCheckService as any).mockImplementation(() => ({
74+
checkPermission: mockCheckPermission,
75+
}));
76+
77+
// Mock that all userIds are valid team members
78+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
79+
{ userId: 101 },
80+
{ userId: 102 },
81+
]);
82+
83+
const mockDeleteResult = { count: 3 };
84+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
85+
86+
const result = await removeHostsFromEventTypesHandler({
87+
ctx: { user: mockUser },
88+
input: mockInput,
89+
});
90+
91+
expect(result).toEqual(mockDeleteResult);
92+
93+
expect(mockCheckPermission).toHaveBeenCalledWith({
94+
userId: mockUser.id,
95+
teamId: mockInput.teamId,
96+
permission: "eventType.update",
97+
fallbackRoles: ["OWNER", "ADMIN"],
98+
});
99+
100+
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).toHaveBeenCalledWith({
101+
userIds: mockInput.userIds,
102+
teamId: mockInput.teamId,
103+
});
104+
105+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
106+
where: {
107+
eventTypeId: {
108+
in: mockInput.eventTypeIds,
109+
},
110+
eventType: {
111+
teamId: mockInput.teamId,
112+
},
113+
userId: {
114+
in: mockInput.userIds,
115+
},
116+
},
117+
});
118+
});
119+
120+
it("only removes hosts for userIds that are team members (filters out non-members)", async () => {
121+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
122+
(PermissionCheckService as any).mockImplementation(() => ({
123+
checkPermission: mockCheckPermission,
124+
}));
125+
126+
// Mock that only userId 101 is a team member, 102 is not
127+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
128+
{ userId: 101 },
129+
]);
130+
131+
const mockDeleteResult = { count: 1 };
132+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
133+
134+
const result = await removeHostsFromEventTypesHandler({
135+
ctx: { user: mockUser },
136+
input: mockInput,
137+
});
138+
139+
expect(result).toEqual(mockDeleteResult);
140+
141+
expect(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam).toHaveBeenCalledWith({
142+
userIds: mockInput.userIds,
143+
teamId: mockInput.teamId,
144+
});
145+
146+
// Should only delete for userId 101, not 102
147+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
148+
where: {
149+
eventTypeId: {
150+
in: mockInput.eventTypeIds,
151+
},
152+
eventType: {
153+
teamId: mockInput.teamId,
154+
},
155+
userId: {
156+
in: [101], // Only 101, not 102
157+
},
158+
},
159+
});
160+
});
161+
162+
it("handles empty userIds array", async () => {
163+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
164+
(PermissionCheckService as any).mockImplementation(() => ({
165+
checkPermission: mockCheckPermission,
166+
}));
167+
168+
// Empty array means no memberships to validate
169+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([]);
170+
171+
const mockDeleteResult = { count: 0 };
172+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
173+
174+
const emptyUsersInput = {
175+
...mockInput,
176+
userIds: [],
177+
};
178+
179+
const result = await removeHostsFromEventTypesHandler({
180+
ctx: { user: mockUser },
181+
input: emptyUsersInput,
182+
});
183+
184+
expect(result).toEqual(mockDeleteResult);
185+
186+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
187+
where: {
188+
eventTypeId: {
189+
in: mockInput.eventTypeIds,
190+
},
191+
eventType: {
192+
teamId: mockInput.teamId,
193+
},
194+
userId: {
195+
in: [],
196+
},
197+
},
198+
});
199+
});
200+
201+
it("handles empty eventTypeIds array", async () => {
202+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
203+
(PermissionCheckService as any).mockImplementation(() => ({
204+
checkPermission: mockCheckPermission,
205+
}));
206+
207+
// Mock that all userIds are valid team members
208+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
209+
{ userId: 101 },
210+
{ userId: 102 },
211+
]);
212+
213+
const mockDeleteResult = { count: 0 };
214+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
215+
216+
const emptyEventTypesInput = {
217+
...mockInput,
218+
eventTypeIds: [],
219+
};
220+
221+
const result = await removeHostsFromEventTypesHandler({
222+
ctx: { user: mockUser },
223+
input: emptyEventTypesInput,
224+
});
225+
226+
expect(result).toEqual(mockDeleteResult);
227+
228+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
229+
where: {
230+
eventTypeId: {
231+
in: [],
232+
},
233+
eventType: {
234+
teamId: mockInput.teamId,
235+
},
236+
userId: {
237+
in: mockInput.userIds,
238+
},
239+
},
240+
});
241+
});
242+
243+
it("returns count of 0 when no hosts match the criteria", async () => {
244+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
245+
(PermissionCheckService as any).mockImplementation(() => ({
246+
checkPermission: mockCheckPermission,
247+
}));
248+
249+
// Mock that all userIds are valid team members
250+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
251+
{ userId: 101 },
252+
{ userId: 102 },
253+
]);
254+
255+
const mockDeleteResult = { count: 0 };
256+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
257+
258+
const result = await removeHostsFromEventTypesHandler({
259+
ctx: { user: mockUser },
260+
input: mockInput,
261+
});
262+
263+
expect(result.count).toBe(0);
264+
});
265+
266+
it("returns count of 0 when userId is a team member but not a host on the specified event types", async () => {
267+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
268+
(PermissionCheckService as any).mockImplementation(() => ({
269+
checkPermission: mockCheckPermission,
270+
}));
271+
272+
// User 999 is a valid team member
273+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
274+
{ userId: 999 },
275+
]);
276+
277+
// But they're not a host on any of the event types
278+
const mockDeleteResult = { count: 0 };
279+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
280+
281+
const nonHostInput = {
282+
...mockInput,
283+
userIds: [999],
284+
};
285+
286+
const result = await removeHostsFromEventTypesHandler({
287+
ctx: { user: mockUser },
288+
input: nonHostInput,
289+
});
290+
291+
expect(result.count).toBe(0);
292+
293+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
294+
where: {
295+
eventTypeId: {
296+
in: mockInput.eventTypeIds,
297+
},
298+
eventType: {
299+
teamId: mockInput.teamId,
300+
},
301+
userId: {
302+
in: [999],
303+
},
304+
},
305+
});
306+
});
307+
308+
it("returns count of 0 when event types do not belong to the specified team", async () => {
309+
const mockCheckPermission = vi.fn().mockResolvedValue(true);
310+
(PermissionCheckService as any).mockImplementation(() => ({
311+
checkPermission: mockCheckPermission,
312+
}));
313+
314+
// Mock that all userIds are valid team members
315+
(MembershipRepository.findAcceptedMembershipsByUserIdsInTeam as any).mockResolvedValue([
316+
{ userId: 101 },
317+
{ userId: 102 },
318+
]);
319+
320+
// Event types belong to a different team, so no hosts should be deleted
321+
const mockDeleteResult = { count: 0 };
322+
(prisma.host.deleteMany as any).mockResolvedValue(mockDeleteResult);
323+
324+
const result = await removeHostsFromEventTypesHandler({
325+
ctx: { user: mockUser },
326+
input: mockInput,
327+
});
328+
329+
expect(result.count).toBe(0);
330+
331+
// Verify the query includes the teamId check
332+
expect(prisma.host.deleteMany).toHaveBeenCalledWith({
333+
where: {
334+
eventTypeId: {
335+
in: mockInput.eventTypeIds,
336+
},
337+
eventType: {
338+
teamId: mockInput.teamId,
339+
},
340+
userId: {
341+
in: mockInput.userIds,
342+
},
343+
},
344+
});
345+
});
346+
});

0 commit comments

Comments
 (0)