Skip to content

Commit 5d7738e

Browse files
authored
fix: use SMS-specific rate limits for verification code (#27635)
Replace core rate limit with sms + smsMonth types to align with sendSMS handler and move check before billing gate for early rejection
1 parent 1a0bf47 commit 5d7738e

2 files changed

Lines changed: 311 additions & 5 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
import { CreditsRepository } from "@calcom/features/credits/repositories/CreditsRepository";
4+
import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
5+
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
6+
7+
import { TRPCError } from "@trpc/server";
8+
9+
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
10+
import { sendVerificationCodeHandler } from "./sendVerificationCode.handler";
11+
12+
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
13+
vi.mock("@calcom/features/credits/repositories/CreditsRepository");
14+
vi.mock("@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber");
15+
vi.mock("../teams/hasTeamPlan.handler");
16+
17+
describe("sendVerificationCodeHandler", () => {
18+
const mockUser = {
19+
id: 123,
20+
name: "Test User",
21+
email: "test@example.com",
22+
metadata: {},
23+
};
24+
25+
const mockCtx = {
26+
user: mockUser,
27+
};
28+
29+
const mockInput = {
30+
phoneNumber: "+1234567890",
31+
};
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue({
36+
success: true,
37+
remaining: 99,
38+
limit: 100,
39+
reset: Date.now() + 60 * 1000,
40+
});
41+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
42+
id: 1,
43+
additionalCredits: 100,
44+
balance: 100,
45+
userId: mockUser.id,
46+
teamId: null,
47+
limitReachedAt: null,
48+
warningSentAt: null,
49+
lockedAt: null,
50+
});
51+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
52+
vi.mocked(sendVerificationCode).mockResolvedValue({ status: "pending" });
53+
});
54+
55+
describe("Rate Limiting", () => {
56+
it("should check SMS rate limit with 'sms' type before processing", async () => {
57+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
58+
59+
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
60+
identifier: `sms:verification:${mockUser.id}`,
61+
rateLimitingType: "sms",
62+
});
63+
});
64+
65+
it("should check SMS rate limit with 'smsMonth' type before processing", async () => {
66+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
67+
68+
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
69+
identifier: `sms:verification:${mockUser.id}`,
70+
rateLimitingType: "smsMonth",
71+
});
72+
});
73+
74+
it("should check both rate limits in order (sms first, then smsMonth)", async () => {
75+
const callOrder: string[] = [];
76+
vi.mocked(checkRateLimitAndThrowError).mockImplementation(async ({ rateLimitingType }) => {
77+
callOrder.push(rateLimitingType as string);
78+
return { success: true, remaining: 99, limit: 100, reset: Date.now() + 60 * 1000 };
79+
});
80+
81+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
82+
83+
expect(callOrder).toEqual(["sms", "smsMonth"]);
84+
});
85+
86+
it("should check rate limits before billing gate (credits check)", async () => {
87+
const callOrder: string[] = [];
88+
vi.mocked(checkRateLimitAndThrowError).mockImplementation(async () => {
89+
callOrder.push("rateLimit");
90+
return { success: true, remaining: 99, limit: 100, reset: Date.now() + 60 * 1000 };
91+
});
92+
vi.mocked(CreditsRepository.findCreditBalance).mockImplementation(async () => {
93+
callOrder.push("credits");
94+
return {
95+
id: 1,
96+
additionalCredits: 100,
97+
balance: 100,
98+
userId: mockUser.id,
99+
teamId: null,
100+
limitReachedAt: null,
101+
warningSentAt: null,
102+
lockedAt: null,
103+
};
104+
});
105+
106+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
107+
108+
expect(callOrder.indexOf("rateLimit")).toBeLessThan(callOrder.indexOf("credits"));
109+
});
110+
111+
it("should throw and not check billing when sms rate limit fails", async () => {
112+
vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce(
113+
new Error("Rate limit exceeded. Try again in 60 seconds.")
114+
);
115+
116+
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
117+
"Rate limit exceeded"
118+
);
119+
120+
expect(CreditsRepository.findCreditBalance).not.toHaveBeenCalled();
121+
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
122+
expect(sendVerificationCode).not.toHaveBeenCalled();
123+
});
124+
125+
it("should throw and not check billing when smsMonth rate limit fails", async () => {
126+
vi.mocked(checkRateLimitAndThrowError)
127+
.mockResolvedValueOnce({ success: true, remaining: 99, limit: 100, reset: Date.now() })
128+
.mockRejectedValueOnce(new Error("Rate limit exceeded. Try again in 2592000 seconds."));
129+
130+
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
131+
"Rate limit exceeded"
132+
);
133+
134+
expect(CreditsRepository.findCreditBalance).not.toHaveBeenCalled();
135+
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
136+
expect(sendVerificationCode).not.toHaveBeenCalled();
137+
});
138+
});
139+
140+
describe("Authorization - Premium Users", () => {
141+
it("should allow premium users to send verification code", async () => {
142+
const premiumUser = {
143+
...mockUser,
144+
metadata: { isPremium: true },
145+
};
146+
147+
await sendVerificationCodeHandler({ ctx: { user: premiumUser }, input: mockInput });
148+
149+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
150+
// Premium users skip team plan check
151+
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
152+
});
153+
154+
it("should allow premium users even with no additional credits", async () => {
155+
const premiumUser = {
156+
...mockUser,
157+
metadata: { isPremium: true },
158+
};
159+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
160+
id: 1,
161+
additionalCredits: 0,
162+
balance: 0,
163+
userId: mockUser.id,
164+
teamId: null,
165+
limitReachedAt: null,
166+
warningSentAt: null,
167+
lockedAt: null,
168+
});
169+
170+
await sendVerificationCodeHandler({ ctx: { user: premiumUser }, input: mockInput });
171+
172+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
173+
});
174+
});
175+
176+
describe("Authorization - Team Plan Users", () => {
177+
it("should allow team plan users to send verification code", async () => {
178+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
179+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
180+
id: 1,
181+
additionalCredits: 0,
182+
balance: 0,
183+
userId: mockUser.id,
184+
teamId: null,
185+
limitReachedAt: null,
186+
warningSentAt: null,
187+
lockedAt: null,
188+
});
189+
190+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
191+
192+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
193+
});
194+
195+
it("should check team plan for non-premium users", async () => {
196+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
197+
198+
expect(hasTeamPlanHandler).toHaveBeenCalledWith({ ctx: mockCtx });
199+
});
200+
});
201+
202+
describe("Authorization - Users with Credits", () => {
203+
it("should allow users with additional credits to send verification code", async () => {
204+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
205+
id: 1,
206+
additionalCredits: 50,
207+
balance: 50,
208+
userId: mockUser.id,
209+
teamId: null,
210+
limitReachedAt: null,
211+
warningSentAt: null,
212+
lockedAt: null,
213+
});
214+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
215+
216+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
217+
218+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
219+
});
220+
});
221+
222+
describe("Authorization - Unauthorized Users", () => {
223+
it("should throw UNAUTHORIZED when user is not premium, has no team plan, and no credits", async () => {
224+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
225+
id: 1,
226+
additionalCredits: 0,
227+
balance: 0,
228+
userId: mockUser.id,
229+
teamId: null,
230+
limitReachedAt: null,
231+
warningSentAt: null,
232+
lockedAt: null,
233+
});
234+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
235+
236+
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
237+
new TRPCError({ code: "UNAUTHORIZED" })
238+
);
239+
240+
expect(sendVerificationCode).not.toHaveBeenCalled();
241+
});
242+
243+
it("should throw UNAUTHORIZED when creditBalance has negative additionalCredits", async () => {
244+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
245+
id: 1,
246+
additionalCredits: -5,
247+
balance: 0,
248+
userId: mockUser.id,
249+
teamId: null,
250+
limitReachedAt: null,
251+
warningSentAt: null,
252+
lockedAt: null,
253+
});
254+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
255+
256+
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
257+
new TRPCError({ code: "UNAUTHORIZED" })
258+
);
259+
});
260+
});
261+
262+
describe("Send Verification Code", () => {
263+
it("should call sendVerificationCode with the phone number from input", async () => {
264+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
265+
266+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
267+
});
268+
269+
it("should return the result from sendVerificationCode", async () => {
270+
const expectedResult = { status: "pending", sid: "verification-sid" };
271+
vi.mocked(sendVerificationCode).mockResolvedValue(expectedResult);
272+
273+
const result = await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
274+
275+
expect(result).toEqual(expectedResult);
276+
});
277+
});
278+
279+
describe("Edge Cases", () => {
280+
it("should handle null credit balance gracefully", async () => {
281+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue(null);
282+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
283+
284+
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
285+
286+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
287+
});
288+
289+
it("should handle user without metadata gracefully", async () => {
290+
const userWithoutMetadata = {
291+
...mockUser,
292+
metadata: undefined,
293+
};
294+
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
295+
296+
await sendVerificationCodeHandler({ ctx: { user: userWithoutMetadata }, input: mockInput });
297+
298+
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
299+
});
300+
});
301+
});

packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ type SendVerificationCodeOptions = {
1919
export const sendVerificationCodeHandler = async ({ ctx, input }: SendVerificationCodeOptions) => {
2020
const { user } = ctx;
2121

22+
await checkRateLimitAndThrowError({
23+
identifier: `sms:verification:${user.id}`,
24+
rateLimitingType: "sms",
25+
});
26+
27+
await checkRateLimitAndThrowError({
28+
identifier: `sms:verification:${user.id}`,
29+
rateLimitingType: "smsMonth",
30+
});
31+
2232
const isCurrentUsernamePremium =
2333
user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
2434

@@ -35,11 +45,6 @@ export const sendVerificationCodeHandler = async ({ ctx, input }: SendVerificati
3545
throw new TRPCError({ code: "UNAUTHORIZED" });
3646
}
3747

38-
await checkRateLimitAndThrowError({
39-
identifier: `sendVerificationCode:${user.id}`,
40-
rateLimitingType: "core",
41-
});
42-
4348
const { phoneNumber } = input;
4449
return sendVerificationCode(phoneNumber);
4550
};

0 commit comments

Comments
 (0)