Skip to content

Commit 57476ea

Browse files
authored
chore: team update handler refactor (calcom#25332)
* chore: team update handler refactor * add tests * fix
1 parent 9c2d7f9 commit 57476ea

2 files changed

Lines changed: 242 additions & 20 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
4+
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
5+
import { prisma } from "@calcom/prisma";
6+
import { RRTimestampBasis } from "@calcom/prisma/enums";
7+
8+
import type { TrpcSessionUser } from "../../../types";
9+
import { updateHandler } from "./update.handler";
10+
import type { TUpdateInputSchema } from "./update.schema";
11+
12+
vi.mock("@calcom/prisma", () => ({
13+
prisma: {
14+
team: {
15+
findUnique: vi.fn(),
16+
update: vi.fn(),
17+
updateMany: vi.fn(),
18+
},
19+
eventType: {
20+
updateMany: vi.fn(),
21+
},
22+
tempOrgRedirect: {
23+
updateMany: vi.fn(),
24+
},
25+
},
26+
}));
27+
28+
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
29+
PermissionCheckService: vi.fn().mockImplementation(() => ({
30+
checkPermission: vi.fn(),
31+
})),
32+
}));
33+
34+
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository", () => ({
35+
TeamRepository: vi.fn().mockImplementation(() => ({
36+
isSlugAvailableForUpdate: vi.fn().mockResolvedValue(true),
37+
})),
38+
}));
39+
40+
vi.mock("@calcom/lib/server/avatar", () => ({
41+
uploadLogo: vi.fn().mockResolvedValue("https://example.com/logo.png"),
42+
}));
43+
44+
vi.mock("@calcom/lib/intervalLimits/validateIntervalLimitOrder", () => ({
45+
validateIntervalLimitOrder: vi.fn().mockReturnValue(true),
46+
}));
47+
48+
vi.mock("@calcom/lib/constants", () => ({
49+
IS_TEAM_BILLING_ENABLED: false,
50+
}));
51+
52+
vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({
53+
getOrgFullOrigin: vi.fn((slug: string) => `https://${slug}.cal.com`),
54+
}));
55+
56+
describe("updateHandler - Permission Check Tests", () => {
57+
const mockPermissionCheckService = {
58+
checkPermission: vi.fn(),
59+
};
60+
61+
const mockTeamRepository = {
62+
isSlugAvailableForUpdate: vi.fn().mockResolvedValue(true),
63+
};
64+
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
vi.mocked(PermissionCheckService).mockImplementation(
68+
() => mockPermissionCheckService as unknown as InstanceType<typeof PermissionCheckService>
69+
);
70+
vi.mocked(TeamRepository).mockImplementation(
71+
() => mockTeamRepository as unknown as InstanceType<typeof TeamRepository>
72+
);
73+
});
74+
75+
describe("Permission Check Service", () => {
76+
const user: NonNullable<TrpcSessionUser> = {
77+
id: 1,
78+
organizationId: 100,
79+
} as NonNullable<TrpcSessionUser>;
80+
81+
it("should use permission check service for all users", async () => {
82+
const input: TUpdateInputSchema = {
83+
id: 50,
84+
name: "Updated Team",
85+
};
86+
87+
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
88+
89+
vi.mocked(prisma.team.findUnique).mockResolvedValue({
90+
id: 50,
91+
parentId: 100,
92+
slug: "test-team",
93+
metadata: {},
94+
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
95+
} as any);
96+
97+
vi.mocked(prisma.team.update).mockResolvedValue({
98+
id: 50,
99+
name: "Updated Team",
100+
bio: null,
101+
slug: "test-team",
102+
theme: null,
103+
brandColor: null,
104+
darkBrandColor: null,
105+
logoUrl: null,
106+
bookingLimits: null,
107+
includeManagedEventsInLimits: null,
108+
rrResetInterval: null,
109+
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
110+
} as any);
111+
112+
await updateHandler({
113+
ctx: { user },
114+
input,
115+
});
116+
117+
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
118+
userId: user.id,
119+
teamId: input.id,
120+
permission: "team.update",
121+
fallbackRoles: expect.any(Array),
122+
});
123+
expect(prisma.team.update).toHaveBeenCalled();
124+
});
125+
126+
it("should throw UNAUTHORIZED when permission check fails", async () => {
127+
const input: TUpdateInputSchema = {
128+
id: 50,
129+
name: "Unauthorized Update",
130+
};
131+
132+
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
133+
134+
vi.mocked(prisma.team.findUnique).mockResolvedValue({
135+
id: 50,
136+
parentId: 100,
137+
slug: "test-team",
138+
metadata: {},
139+
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
140+
} as any);
141+
142+
await expect(
143+
updateHandler({
144+
ctx: { user },
145+
input,
146+
})
147+
).rejects.toMatchObject({
148+
code: "UNAUTHORIZED",
149+
});
150+
151+
expect(prisma.team.update).not.toHaveBeenCalled();
152+
});
153+
154+
it("should throw UNAUTHORIZED when user has no id", async () => {
155+
const userWithoutId = {
156+
id: undefined,
157+
organizationId: 100,
158+
} as unknown as NonNullable<TrpcSessionUser>;
159+
160+
const input: TUpdateInputSchema = {
161+
id: 50,
162+
name: "Updated Team",
163+
};
164+
165+
vi.mocked(prisma.team.findUnique).mockResolvedValue({
166+
id: 50,
167+
parentId: 100,
168+
slug: "test-team",
169+
metadata: {},
170+
rrTimestampBasis: RRTimestampBasis.CREATED_AT,
171+
} as any);
172+
173+
await expect(
174+
updateHandler({
175+
ctx: { user: userWithoutId },
176+
input,
177+
})
178+
).rejects.toMatchObject({
179+
code: "UNAUTHORIZED",
180+
});
181+
182+
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
183+
expect(prisma.team.update).not.toHaveBeenCalled();
184+
});
185+
});
186+
187+
describe("Team Not Found", () => {
188+
const user: NonNullable<TrpcSessionUser> = {
189+
id: 1,
190+
organizationId: 100,
191+
} as NonNullable<TrpcSessionUser>;
192+
193+
it("should throw NOT_FOUND when team does not exist", async () => {
194+
const input: TUpdateInputSchema = {
195+
id: 999,
196+
name: "Non-existent Team",
197+
};
198+
199+
vi.mocked(prisma.team.findUnique).mockResolvedValue(null);
200+
201+
await expect(
202+
updateHandler({
203+
ctx: { user },
204+
input,
205+
})
206+
).rejects.toMatchObject({
207+
code: "NOT_FOUND",
208+
message: "Team not found.",
209+
});
210+
211+
// Should not check permissions if team doesn't exist
212+
expect(mockPermissionCheckService.checkPermission).not.toHaveBeenCalled();
213+
});
214+
});
215+
});

packages/trpc/server/routers/viewer/teams/update.handler.ts

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,35 @@ type UpdateOptions = {
2323
};
2424

2525
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
26-
const isOrgAdmin = ctx.user?.organization?.isOrgAdmin;
26+
const prevTeam = await prisma.team.findUnique({
27+
where: {
28+
id: input.id,
29+
},
30+
select: {
31+
id: true,
32+
parentId: true,
33+
slug: true,
34+
metadata: true,
35+
rrTimestampBasis: true,
36+
},
37+
});
2738

28-
if (!isOrgAdmin) {
29-
const permissionCheckService = new PermissionCheckService();
30-
const hasTeamUpdatePermission = await permissionCheckService.checkPermission({
31-
userId: ctx.user?.id || 0,
32-
teamId: input.id,
33-
permission: "team.update",
34-
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
35-
});
39+
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
3640

37-
if (!hasTeamUpdatePermission) {
38-
throw new TRPCError({ code: "UNAUTHORIZED" });
39-
}
41+
if (!ctx.user?.id) {
42+
throw new TRPCError({ code: "UNAUTHORIZED" });
43+
}
44+
45+
const permissionCheckService = new PermissionCheckService();
46+
const hasTeamUpdatePermission = await permissionCheckService.checkPermission({
47+
userId: ctx.user.id,
48+
teamId: input.id,
49+
permission: "team.update",
50+
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
51+
});
52+
53+
if (!hasTeamUpdatePermission) {
54+
throw new TRPCError({ code: "UNAUTHORIZED" });
4055
}
4156

4257
if (input.slug) {
@@ -52,14 +67,6 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
5267
}
5368
}
5469

55-
const prevTeam = await prisma.team.findUnique({
56-
where: {
57-
id: input.id,
58-
},
59-
});
60-
61-
if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
62-
6370
if (input.bookingLimits) {
6471
const isValid = validateIntervalLimitOrder(input.bookingLimits);
6572
if (!isValid)

0 commit comments

Comments
 (0)