diff --git a/apps/web/modules/auth/verify-view.tsx b/apps/web/modules/auth/verify-view.tsx index 9e2bc88234..f9a07aafd2 100644 --- a/apps/web/modules/auth/verify-view.tsx +++ b/apps/web/modules/auth/verify-view.tsx @@ -10,14 +10,11 @@ import { WEBAPP_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; -import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; -import Loader from "@components/Loader"; - async function sendVerificationLogin(email: string, username: string, t: (key: string) => string) { await signIn("email", { email: email.toLowerCase(), @@ -54,8 +51,9 @@ function useSendFirstVerificationLogin({ } const querySchema = z.object({ - stripeCustomerId: z.string().optional(), - sessionId: z.string().optional(), + email: z.string().optional(), + username: z.string().optional(), + paymentStatus: z.string().optional(), t: z.string().optional(), }); @@ -119,20 +117,20 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) { const pathname = usePathname(); const router = useRouter(); const routerQuery = useRouterQuery(); - const { t: tParam, sessionId, stripeCustomerId } = querySchema.parse(routerQuery); + const { email, username, paymentStatus } = querySchema.parse(routerQuery); const { t } = useLocale(); const [secondsLeft, setSecondsLeft] = useState(30); - const { data } = trpc.viewer.public.stripeCheckoutSession.useQuery( - { - stripeCustomerId, - checkoutSessionId: sessionId, - }, - { - enabled: !!stripeCustomerId || !!sessionId, - staleTime: Infinity, - } - ); - useSendFirstVerificationLogin({ email: data?.customer?.email, username: data?.customer?.username }); + + // Derive payment failed status from payment status + const hasPaymentFailed = paymentStatus !== undefined && paymentStatus !== "paid"; + const isPremiumUsername = !!paymentStatus; // If paymentStatus exists, it's from premium username flow + + // Only send verification login if we DON'T have the email yet (for resend button) + // The email is already sent server-side in paymentCallback + useSendFirstVerificationLogin({ + email: !isPremiumUsername ? email : undefined, + username: !isPremiumUsername ? username : undefined, + }); // @note: check for t=timestamp and apply disabled state and secondsLeft accordingly // to avoid refresh to skip waiting 30 seconds to re-send email useEffect(() => { @@ -159,16 +157,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) { } }, [secondsLeft]); - if (!data) { - // Loading state - return ; - } - const { valid, hasPaymentFailed, customer } = data; - if (!valid) { - throw new Error("Invalid session or customer id"); - } - - if (!stripeCustomerId && !sessionId) { + if (!email) { return
{t("invalid_link")}
; } @@ -176,17 +165,23 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
- {hasPaymentFailed ? : sessionId ? : } + {hasPaymentFailed ? ( + + ) : isPremiumUsername ? ( + + ) : ( + + )}

{hasPaymentFailed ? t("your_payment_failed") - : sessionId + : isPremiumUsername ? t("payment_successful") : t("check_your_inbox")}

{hasPaymentFailed &&

{t("account_created_premium_not_reserved")}

}

- {t("email_sent_with_activation_link", { email: customer?.email })}{" "} + {t("email_sent_with_activation_link", { email })}{" "} {hasPaymentFailed && t("activate_account_to_purchase_username")}

@@ -212,7 +207,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) { )} disabled={secondsLeft > 0} onClick={async (e) => { - if (!customer) { + if (!email || !username) { return; } e.preventDefault(); @@ -221,7 +216,7 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) { const _searchParams = new URLSearchParams(searchParams?.toString()); _searchParams.set("t", `${Date.now()}`); router.replace(`${pathname}?${_searchParams.toString()}`); - return await sendVerificationLogin(customer.email, customer.username, t); + return await sendVerificationLogin(email, username, t); }}> {secondsLeft > 0 ? t("resend_in_seconds", { seconds: secondsLeft }) : t("resend")} diff --git a/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts b/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts new file mode 100644 index 0000000000..b88e2053db --- /dev/null +++ b/packages/app-store/stripepayment/api/__tests__/paymentCallback.test.ts @@ -0,0 +1,266 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest"; +import { HttpError } from "@calcom/lib/http-error"; +import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService"; +import { prisma } from "@calcom/prisma"; + +import { getCustomerAndCheckoutSession } from "../../lib/getCustomerAndCheckoutSession"; + +// Mock dependencies +vi.mock("@calcom/prisma", () => ({ + prisma: { + user: { + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("../../lib/getCustomerAndCheckoutSession"); +vi.mock("@calcom/features/auth/lib/sendVerificationRequest"); +vi.mock("@calcom/lib/server/service/VerificationTokenService"); + +const mockGetCustomerAndCheckoutSession = vi.mocked(getCustomerAndCheckoutSession); +const mockSendVerificationRequest = vi.mocked(sendVerificationRequest); +const mockVerificationTokenService = vi.mocked(VerificationTokenService); + +// Type the mocked prisma properly +const mockPrisma = prisma as unknown as { + user: { + findFirst: ReturnType; + update: ReturnType; + }; +}; + +describe("paymentCallback", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + mockReq = { + query: { + callbackUrl: "/premium-username-checkout", + checkoutSessionId: "cs_test_123", + }, + url: "/api/payment/callback", + method: "GET", + }; + + mockRes = { + redirect: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + + // Default mock implementations + mockGetCustomerAndCheckoutSession.mockResolvedValue({ + stripeCustomer: { + id: "cus_123", + email: "test@example.com", + metadata: { + username: "premium-user", + }, + }, + checkoutSession: { + payment_status: "paid", + }, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockPrisma.user.findFirst.mockResolvedValue({ + id: 1, + email: "test@example.com", + locale: "en", + metadata: {}, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockPrisma.user.update.mockResolvedValue({ + id: 1, + username: "premium-user", + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + mockVerificationTokenService.create.mockResolvedValue("test-token-123"); + mockSendVerificationRequest.mockResolvedValue(undefined); + }); + + describe("VerificationTokenService integration", () => { + it("should call VerificationTokenService.create with correct parameters", async () => { + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(mockVerificationTokenService.create).toHaveBeenCalledWith({ + identifier: "test@example.com", + expires: expect.any(Date), + }); + + const callArgs = mockVerificationTokenService.create.mock.calls[0][0]; + const expiresDate = callArgs.expires; + const now = Date.now(); + const oneDayInMs = 86400 * 1000; + + // Verify expires is approximately 1 day from now (within 1 second tolerance) + expect(expiresDate.getTime()).toBeGreaterThan(now); + expect(expiresDate.getTime()).toBeLessThanOrEqual(now + oneDayInMs + 1000); + }); + + it("should send verification email with token from service", async () => { + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(mockSendVerificationRequest).toHaveBeenCalledWith({ + identifier: "test@example.com", + url: expect.stringContaining("token=test-token-123"), + }); + }); + + it("should create verification token before sending email", async () => { + const { default: handler } = await import("../paymentCallback"); + const callOrder: string[] = []; + + mockVerificationTokenService.create.mockImplementation(async () => { + callOrder.push("create-token"); + return "test-token"; + }); + + mockSendVerificationRequest.mockImplementation(async () => { + callOrder.push("send-email"); + }); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(callOrder).toEqual(["create-token", "send-email"]); + }); + + it("should create verification token only after payment is confirmed", async () => { + mockGetCustomerAndCheckoutSession.mockResolvedValue({ + stripeCustomer: { + id: "cus_123", + email: "test@example.com", + metadata: { username: "premium-user" }, + }, + checkoutSession: { + payment_status: "unpaid", + }, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(mockVerificationTokenService.create).not.toHaveBeenCalled(); + expect(mockSendVerificationRequest).not.toHaveBeenCalled(); + }); + + it("should handle user found by stripeCustomerId", async () => { + mockPrisma.user.findFirst + .mockResolvedValueOnce(null) // First call by email returns null + .mockResolvedValueOnce({ + // Second call by stripeCustomerId succeeds + id: 2, + email: "different@example.com", + locale: "en", + metadata: { stripeCustomerId: "cus_123" }, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(mockVerificationTokenService.create).toHaveBeenCalledWith({ + identifier: "different@example.com", // Should use user.email from found user + expires: expect.any(Date), + }); + }); + + it("should update user with premium username before creating token", async () => { + const { default: handler } = await import("../paymentCallback"); + const callOrder: string[] = []; + + mockPrisma.user.update.mockImplementation(async () => { + callOrder.push("update-user"); + return {} as any; // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + mockVerificationTokenService.create.mockImplementation(async () => { + callOrder.push("create-token"); + return "token"; + }); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(callOrder).toEqual(["update-user", "create-token"]); + }); + + it("should redirect with correct parameters after successful payment", async () => { + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const redirectUrl = (mockRes.redirect as any).mock.calls[0][0] as string; + expect(redirectUrl).toContain("email=test%40example.com"); + expect(redirectUrl).toContain("username=premium-user"); + expect(redirectUrl).toContain("paymentStatus=paid"); + }); + + it("should not create verification token when stripe customer is not found", async () => { + mockGetCustomerAndCheckoutSession.mockResolvedValue({ + stripeCustomer: null, + checkoutSession: { payment_status: "paid" }, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { default: handler } = await import("../paymentCallback"); + + try { + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).statusCode).toBe(404); + } + + expect(mockVerificationTokenService.create).not.toHaveBeenCalled(); + }); + + it("should not create verification token when user is not found", async () => { + mockPrisma.user.findFirst.mockResolvedValue(null); + + const { default: handler } = await import("../paymentCallback"); + + try { + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + } catch (error) { + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).statusCode).toBe(404); + } + + expect(mockVerificationTokenService.create).not.toHaveBeenCalled(); + }); + + it("should use user email if stripe customer email is missing", async () => { + mockGetCustomerAndCheckoutSession.mockResolvedValue({ + stripeCustomer: { + id: "cus_123", + email: null, + metadata: { username: "premium-user" }, + }, + checkoutSession: { payment_status: "paid" }, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const { default: handler } = await import("../paymentCallback"); + + await handler(mockReq as NextApiRequest, mockRes as NextApiResponse); + + expect(mockVerificationTokenService.create).toHaveBeenCalledWith({ + identifier: "test@example.com", // Should use user.email + expires: expect.any(Date), + }); + }); + }); +}); diff --git a/packages/app-store/stripepayment/api/paymentCallback.ts b/packages/app-store/stripepayment/api/paymentCallback.ts index a6fc7819d2..4c646f2a9f 100644 --- a/packages/app-store/stripepayment/api/paymentCallback.ts +++ b/packages/app-store/stripepayment/api/paymentCallback.ts @@ -2,10 +2,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; import z from "zod"; import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession"; +import sendVerificationRequest from "@calcom/features/auth/lib/sendVerificationRequest"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import { VerificationTokenService } from "@calcom/lib/server/service/VerificationTokenService"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -22,43 +25,76 @@ const querySchema = z.object({ // It handles premium user payment success/failure async function getHandler(req: NextApiRequest, res: NextApiResponse) { const { callbackUrl, checkoutSessionId } = querySchema.parse(req.query); + const log = logger.getSubLogger({ prefix: [`[paymentCallback] checkoutSessionId: ${checkoutSessionId}`] }); const { stripeCustomer, checkoutSession } = await getCustomerAndCheckoutSession(checkoutSessionId); - if (!stripeCustomer) + if (!stripeCustomer) { + log.error("Could not find stripeCustomer"); throw new HttpError({ statusCode: 404, - message: "Stripe customer not found or deleted", + message: + "Stripe customer not found or deleted. Please contact support@cal.com and mention your premium username", url: req.url, method: req.method, }); + } - // first let's try to find user by metadata stripeCustomerId - let user = await prisma.user.findFirst({ - where: { - metadata: { - path: ["stripeCustomerId"], - equals: stripeCustomer.id, - }, - }, - }); + let user; - if (!user && stripeCustomer.email) { - // if user not found, let's try to find user by email + if (stripeCustomer?.email) { user = await prisma.user.findFirst({ where: { email: stripeCustomer.email, }, + select: { + id: true, + email: true, + locale: true, + metadata: true, + }, }); } - if (!user) - throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method }); + // If we cannot find the user via email, query via stripeCustomerId + if (!user) { + user = await prisma.user.findFirst({ + where: { + metadata: { + path: ["stripeCustomerId"], + equals: stripeCustomer.id, + }, + }, + select: { + id: true, + email: true, + locale: true, + metadata: true, + }, + }); + } - if (checkoutSession.payment_status === "paid" && stripeCustomer.metadata.username) { + if (!user) { + log.error("Could not find user"); + throw new HttpError({ statusCode: 404, message: "User not found", url: req.url, method: req.method }); + } + + const username = stripeCustomer.metadata.username; + const email = user.email ?? stripeCustomer.email; + + // If payment hasn't been completed, redirect to verify page with failure status + if (checkoutSession.payment_status !== "paid") { + log.warn("Payment not completed", { paymentStatus: checkoutSession.payment_status }); + callbackUrl.searchParams.set("email", email || ""); + callbackUrl.searchParams.set("username", username || ""); + callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status); + return res.redirect(callbackUrl.toString()).end(); + } + + if (stripeCustomer.metadata.username) { try { await prisma.user.update({ data: { - username: stripeCustomer.metadata.username, + username, metadata: { ...(user.metadata as Prisma.JsonObject), isPremium: true, @@ -69,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { }, }); } catch (error) { - console.error(error); + log.error(error); throw new HttpError({ statusCode: 400, url: req.url, @@ -79,7 +115,29 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) { }); } } + + const expires = new Date(Date.now() + 86400 * 1000); // 1 day + + const token = await VerificationTokenService.create({ identifier: email, expires }); + + // Generate the callback URL with token + const params = new URLSearchParams({ + callbackUrl: WEBAPP_URL || "https://app.cal.com", + token, + email, + }); + const url = `${WEBAPP_URL}/api/auth/callback/email?${params.toString()}`; + + await sendVerificationRequest({ + identifier: email, + url, + }); + + // Pass email, username, and payment status in the redirect URL + callbackUrl.searchParams.set("email", email || ""); + callbackUrl.searchParams.set("username", username); callbackUrl.searchParams.set("paymentStatus", checkoutSession.payment_status); + return res.redirect(callbackUrl.toString()).end(); } diff --git a/packages/features/auth/lib/sendVerificationRequest.ts b/packages/features/auth/lib/sendVerificationRequest.ts index 205da0cadf..ffa07a98c3 100644 --- a/packages/features/auth/lib/sendVerificationRequest.ts +++ b/packages/features/auth/lib/sendVerificationRequest.ts @@ -12,7 +12,10 @@ const transporter = nodemailer.createTransport({ ...(serverConfig.transport as TransportOptions), } as TransportOptions); -const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => { +const sendVerificationRequest = async ({ + identifier, + url, +}: Pick) => { const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates"); const originalUrl = new URL(url); const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index dc204f4a93..dae38c2f95 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -213,11 +213,15 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) { await prefillAvatar({ email }); } - sendEmailVerification({ - email, - language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())), - username: username || "", - }); + // Only send verification email for non-premium usernames + // Premium usernames will get a magic link after payment in paymentCallback + if (!checkoutSessionId) { + sendEmailVerification({ + email, + language: await getLocaleFromRequest(buildLegacyRequest(await headers(), await cookies())), + username: username || "", + }); + } } if (checkoutSessionId) { diff --git a/packages/lib/server/repository/__tests__/verificationToken.test.ts b/packages/lib/server/repository/__tests__/verificationToken.test.ts new file mode 100644 index 0000000000..9d03f9db61 --- /dev/null +++ b/packages/lib/server/repository/__tests__/verificationToken.test.ts @@ -0,0 +1,164 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +import type { VerificationToken } from "@calcom/prisma/client"; + +import { VerificationTokenRepository } from "../verificationToken"; + +vi.mock("@calcom/prisma", () => ({ + default: { + verificationToken: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})); + +const prismaMock = vi.hoisted(() => ({ + verificationToken: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock("@calcom/prisma", () => ({ + default: prismaMock, +})); + +describe("VerificationTokenRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("create", () => { + test("should create a verification token", async () => { + const identifier = "test@example.com"; + const token = "hashed-token-value"; + const expires = new Date(Date.now() + 86400 * 1000); + + const mockVerificationToken: VerificationToken = { + identifier, + token, + expires, + }; + + prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken); + + const result = await VerificationTokenRepository.create({ + identifier, + token, + expires, + }); + + expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({ + data: { + identifier, + token, + expires, + }, + }); + expect(result).toEqual(mockVerificationToken); + }); + + test("should create a verification token with correct data types", async () => { + const identifier = "user@example.com"; + const token = "sha256-hashed-token"; + const expires = new Date("2025-12-31T23:59:59.000Z"); + + const mockVerificationToken: VerificationToken = { + identifier, + token, + expires, + }; + + prismaMock.verificationToken.create.mockResolvedValue(mockVerificationToken); + + await VerificationTokenRepository.create({ + identifier, + token, + expires, + }); + + expect(prismaMock.verificationToken.create).toHaveBeenCalledWith({ + data: { + identifier, + token, + expires, + }, + }); + }); + }); + + describe("updateTeamInviteTokenExpirationDate", () => { + test("should update team invite token expiration date", async () => { + const email = "team@example.com"; + const teamId = 123; + const expiresInDays = 7; + + const existingToken: VerificationToken = { + identifier: email, + token: "existing-token", + expires: new Date(), + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken); + prismaMock.verificationToken.update.mockResolvedValue({ + ...existingToken, + expires: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000), + }); + + const result = await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({ + email, + teamId, + expiresInDays, + }); + + expect(prismaMock.verificationToken.findFirst).toHaveBeenCalledWith({ + where: { + identifier: email, + teamId, + }, + }); + + expect(prismaMock.verificationToken.update).toHaveBeenCalledWith({ + where: { + identifier: email, + teamId, + token: existingToken.token, + }, + data: { expires: expect.any(Date) }, + }); + + expect(result.token).toBe(existingToken.token); + expect(result.expires).toBeInstanceOf(Date); + }); + + test("should calculate correct expiration date", async () => { + const email = "team@example.com"; + const teamId = 456; + const expiresInDays = 3; + + const existingToken: VerificationToken = { + identifier: email, + token: "token-123", + expires: new Date(), + }; + + prismaMock.verificationToken.findFirst.mockResolvedValue(existingToken); + + const expectedExpires = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000); + + prismaMock.verificationToken.update.mockImplementation(({ data }) => { + expect(data.expires.getTime()).toBeCloseTo(expectedExpires.getTime(), -2); + return Promise.resolve({ ...existingToken, expires: data.expires }); + }); + + await VerificationTokenRepository.updateTeamInviteTokenExpirationDate({ + email, + teamId, + expiresInDays, + }); + }); + }); +}); diff --git a/packages/lib/server/repository/verificationToken.ts b/packages/lib/server/repository/verificationToken.ts index 0875ad1b8f..2ee2cd192b 100644 --- a/packages/lib/server/repository/verificationToken.ts +++ b/packages/lib/server/repository/verificationToken.ts @@ -30,4 +30,22 @@ export class VerificationTokenRepository { return { ...token, expires }; } + + static async create({ + identifier, + token, + expires, + }: { + identifier: string; + token: string; + expires: Date; + }) { + return prisma.verificationToken.create({ + data: { + identifier, + token, + expires, + }, + }); + } } diff --git a/packages/lib/server/service/VerificationTokenService.ts b/packages/lib/server/service/VerificationTokenService.ts new file mode 100644 index 0000000000..eb9753589a --- /dev/null +++ b/packages/lib/server/service/VerificationTokenService.ts @@ -0,0 +1,16 @@ +import { randomBytes, createHash } from "crypto"; + +import { VerificationTokenRepository } from "../repository/verificationToken"; + +export class VerificationTokenService { + static async create({ identifier, expires }: { identifier: string; expires: Date }) { + const token = randomBytes(32).toString("hex"); + const hashedToken = createHash("sha256") + .update(`${token}${process.env.NEXTAUTH_SECRET}`) + .digest("hex"); + + await VerificationTokenRepository.create({ identifier, token: hashedToken, expires }); + + return token; + } +} diff --git a/packages/lib/server/service/__tests__/VerificationTokenService.test.ts b/packages/lib/server/service/__tests__/VerificationTokenService.test.ts new file mode 100644 index 0000000000..490bb2bbb2 --- /dev/null +++ b/packages/lib/server/service/__tests__/VerificationTokenService.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"; + +import { VerificationTokenRepository } from "@calcom/lib/server/repository/verificationToken"; + +import { VerificationTokenService } from "../VerificationTokenService"; + +vi.mock("@calcom/lib/server/repository/verificationToken", () => ({ + VerificationTokenRepository: { + create: vi.fn(), + }, +})); + +describe("VerificationTokenService", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.NEXTAUTH_SECRET = "test-secret"; + + vi.mocked(VerificationTokenRepository.create).mockResolvedValue({ + identifier: "test@example.com", + token: "hashed-token", + expires: new Date(), + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe("create", () => { + test("should create a verification token and return unhashed token", async () => { + const identifier = "test@example.com"; + const expires = new Date(Date.now() + 86400 * 1000); + + const result = await VerificationTokenService.create({ + identifier, + expires, + }); + + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + expect(VerificationTokenRepository.create).toHaveBeenCalled(); + }); + + test("should call repository with correct identifier and expires", async () => { + const identifier = "user@example.com"; + const expires = new Date("2025-12-31T23:59:59.000Z"); + + await VerificationTokenService.create({ + identifier, + expires, + }); + + expect(VerificationTokenRepository.create).toHaveBeenCalledWith({ + identifier, + token: expect.any(String), + expires, + }); + + const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0]; + expect(callArgs.identifier).toBe(identifier); + expect(callArgs.expires).toBe(expires); + expect(callArgs.token).toBeTruthy(); + }); + + test("should hash token with NEXTAUTH_SECRET", async () => { + const identifier = "test@example.com"; + const expires = new Date(Date.now() + 86400 * 1000); + + await VerificationTokenService.create({ + identifier, + expires, + }); + + const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0]; + // The hashed token should be a 64-character hex string (SHA-256) + expect(callArgs.token).toMatch(/^[a-f0-9]{64}$/); + }); + + test("should generate unique tokens on multiple calls", async () => { + const identifier = "test@example.com"; + const expires = new Date(Date.now() + 86400 * 1000); + + const result1 = await VerificationTokenService.create({ + identifier, + expires, + }); + + const result2 = await VerificationTokenService.create({ + identifier, + expires, + }); + + expect(result1).not.toBe(result2); + expect(VerificationTokenRepository.create).toHaveBeenCalledTimes(2); + }); + + test("should handle Date objects for expires", async () => { + const identifier = "test@example.com"; + const expires = new Date(2025, 11, 31, 23, 59, 59); + + await VerificationTokenService.create({ + identifier, + expires, + }); + + expect(VerificationTokenRepository.create).toHaveBeenCalledWith({ + identifier, + token: expect.any(String), + expires: expect.any(Date), + }); + + const callArgs = vi.mocked(VerificationTokenRepository.create).mock.calls[0][0]; + expect(callArgs.expires).toBeInstanceOf(Date); + expect(callArgs.expires.getTime()).toBe(expires.getTime()); + }); + + test("should generate 32-byte random token in hex format", async () => { + const identifier = "test@example.com"; + const expires = new Date(Date.now() + 86400 * 1000); + + const token = await VerificationTokenService.create({ + identifier, + expires, + }); + + // 32 bytes = 64 hex characters + expect(token).toMatch(/^[a-f0-9]{64}$/); + }); + }); +}); diff --git a/packages/trpc/server/routers/publicViewer/_router.tsx b/packages/trpc/server/routers/publicViewer/_router.tsx index 1113149b7b..598ee8fa43 100644 --- a/packages/trpc/server/routers/publicViewer/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/_router.tsx @@ -4,13 +4,8 @@ import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerifica import { ZMarkHostAsNoShowInputSchema } from "./markHostAsNoShow.schema"; import { event } from "./procedures/event"; import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema"; -import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; import { ZSubmitRatingInputSchema } from "./submitRating.schema"; -const NAMESPACE = "publicViewer"; - -const namespaced = (s: string) => `${NAMESPACE}.${s}`; - // things that unauthenticated users can query about themselves export const publicViewerRouter = router({ countryCode: publicProcedure.query(async (opts) => { @@ -29,10 +24,6 @@ export const publicViewerRouter = router({ const { default: handler } = await import("./samlTenantProduct.handler"); return handler(opts); }), - stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async (opts) => { - const { default: handler } = await import("./stripeCheckoutSession.handler"); - return handler(opts); - }), event, ssoConnections: publicProcedure.query(async () => { const { default: handler } = await import("./ssoConnections.handler"); diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts deleted file mode 100644 index 9f1a0375b2..0000000000 --- a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.handler.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { StripeService } from "@calcom/features/ee/payments/server/stripe-service"; - -import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; -import type { TStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema"; - -type StripeCheckoutSessionOptions = { - input: TStripeCheckoutSessionInputSchema; -}; - -export const stripeCheckoutSessionHandler = async ({ input }: StripeCheckoutSessionOptions) => { - const parsedInput = ZStripeCheckoutSessionInputSchema.parse(input); - - return await StripeService.getCheckoutSession(parsedInput); -}; - -export default stripeCheckoutSessionHandler; diff --git a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts b/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts deleted file mode 100644 index db054fe2ab..0000000000 --- a/packages/trpc/server/routers/publicViewer/stripeCheckoutSession.schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; - -export const ZStripeCheckoutSessionInputSchema = z - .object({ - stripeCustomerId: z.string().optional(), - checkoutSessionId: z.string().optional(), - }) - .superRefine((arg, ctx) => { - if (!arg.checkoutSessionId && !arg.stripeCustomerId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Missing checkoutSessionId or stripeCustomerId", - }); - } - if (arg.checkoutSessionId && arg.stripeCustomerId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Both checkoutSessionId and stripeCustomerId provided", - }); - } - }); - -export type TStripeCheckoutSessionInputSchema = z.infer;