Files
calendar/packages/trpc/server/routers/viewer/workflows/sendVerificationCode.handler.test.ts
T
Pedro CastroandGitHub 5d7738e39d 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
2026-02-04 17:42:21 -03:00

301 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { CreditsRepository } from "@calcom/features/credits/repositories/CreditsRepository";
import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { TRPCError } from "@trpc/server";
import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler";
import { sendVerificationCodeHandler } from "./sendVerificationCode.handler";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/features/credits/repositories/CreditsRepository");
vi.mock("@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber");
vi.mock("../teams/hasTeamPlan.handler");
describe("sendVerificationCodeHandler", () => {
const mockUser = {
id: 123,
name: "Test User",
email: "test@example.com",
metadata: {},
};
const mockCtx = {
user: mockUser,
};
const mockInput = {
phoneNumber: "+1234567890",
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue({
success: true,
remaining: 99,
limit: 100,
reset: Date.now() + 60 * 1000,
});
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: 100,
balance: 100,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
vi.mocked(sendVerificationCode).mockResolvedValue({ status: "pending" });
});
describe("Rate Limiting", () => {
it("should check SMS rate limit with 'sms' type before processing", async () => {
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
identifier: `sms:verification:${mockUser.id}`,
rateLimitingType: "sms",
});
});
it("should check SMS rate limit with 'smsMonth' type before processing", async () => {
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
identifier: `sms:verification:${mockUser.id}`,
rateLimitingType: "smsMonth",
});
});
it("should check both rate limits in order (sms first, then smsMonth)", async () => {
const callOrder: string[] = [];
vi.mocked(checkRateLimitAndThrowError).mockImplementation(async ({ rateLimitingType }) => {
callOrder.push(rateLimitingType as string);
return { success: true, remaining: 99, limit: 100, reset: Date.now() + 60 * 1000 };
});
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(callOrder).toEqual(["sms", "smsMonth"]);
});
it("should check rate limits before billing gate (credits check)", async () => {
const callOrder: string[] = [];
vi.mocked(checkRateLimitAndThrowError).mockImplementation(async () => {
callOrder.push("rateLimit");
return { success: true, remaining: 99, limit: 100, reset: Date.now() + 60 * 1000 };
});
vi.mocked(CreditsRepository.findCreditBalance).mockImplementation(async () => {
callOrder.push("credits");
return {
id: 1,
additionalCredits: 100,
balance: 100,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
};
});
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(callOrder.indexOf("rateLimit")).toBeLessThan(callOrder.indexOf("credits"));
});
it("should throw and not check billing when sms rate limit fails", async () => {
vi.mocked(checkRateLimitAndThrowError).mockRejectedValueOnce(
new Error("Rate limit exceeded. Try again in 60 seconds.")
);
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
"Rate limit exceeded"
);
expect(CreditsRepository.findCreditBalance).not.toHaveBeenCalled();
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
expect(sendVerificationCode).not.toHaveBeenCalled();
});
it("should throw and not check billing when smsMonth rate limit fails", async () => {
vi.mocked(checkRateLimitAndThrowError)
.mockResolvedValueOnce({ success: true, remaining: 99, limit: 100, reset: Date.now() })
.mockRejectedValueOnce(new Error("Rate limit exceeded. Try again in 2592000 seconds."));
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
"Rate limit exceeded"
);
expect(CreditsRepository.findCreditBalance).not.toHaveBeenCalled();
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
expect(sendVerificationCode).not.toHaveBeenCalled();
});
});
describe("Authorization - Premium Users", () => {
it("should allow premium users to send verification code", async () => {
const premiumUser = {
...mockUser,
metadata: { isPremium: true },
};
await sendVerificationCodeHandler({ ctx: { user: premiumUser }, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
// Premium users skip team plan check
expect(hasTeamPlanHandler).not.toHaveBeenCalled();
});
it("should allow premium users even with no additional credits", async () => {
const premiumUser = {
...mockUser,
metadata: { isPremium: true },
};
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: 0,
balance: 0,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
await sendVerificationCodeHandler({ ctx: { user: premiumUser }, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
});
describe("Authorization - Team Plan Users", () => {
it("should allow team plan users to send verification code", async () => {
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: 0,
balance: 0,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
it("should check team plan for non-premium users", async () => {
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(hasTeamPlanHandler).toHaveBeenCalledWith({ ctx: mockCtx });
});
});
describe("Authorization - Users with Credits", () => {
it("should allow users with additional credits to send verification code", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: 50,
balance: 50,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
});
describe("Authorization - Unauthorized Users", () => {
it("should throw UNAUTHORIZED when user is not premium, has no team plan, and no credits", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: 0,
balance: 0,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" })
);
expect(sendVerificationCode).not.toHaveBeenCalled();
});
it("should throw UNAUTHORIZED when creditBalance has negative additionalCredits", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: 1,
additionalCredits: -5,
balance: 0,
userId: mockUser.id,
teamId: null,
limitReachedAt: null,
warningSentAt: null,
lockedAt: null,
});
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: false });
await expect(sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput })).rejects.toThrow(
new TRPCError({ code: "UNAUTHORIZED" })
);
});
});
describe("Send Verification Code", () => {
it("should call sendVerificationCode with the phone number from input", async () => {
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
it("should return the result from sendVerificationCode", async () => {
const expectedResult = { status: "pending", sid: "verification-sid" };
vi.mocked(sendVerificationCode).mockResolvedValue(expectedResult);
const result = await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(result).toEqual(expectedResult);
});
});
describe("Edge Cases", () => {
it("should handle null credit balance gracefully", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue(null);
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
await sendVerificationCodeHandler({ ctx: mockCtx, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
it("should handle user without metadata gracefully", async () => {
const userWithoutMetadata = {
...mockUser,
metadata: undefined,
};
vi.mocked(hasTeamPlanHandler).mockResolvedValue({ hasTeamPlan: true });
await sendVerificationCodeHandler({ ctx: { user: userWithoutMetadata }, input: mockInput });
expect(sendVerificationCode).toHaveBeenCalledWith(mockInput.phoneNumber);
});
});
});