fix: signup (#25334)

* Handle Stripe logic in `paymentCallback`

* Remove endpoint

* Do not send email verification email if premium username

* Remove logic from verify-view

* Callback send verification email

* Add `create` method to `VerificationToken` repository

* Create `VerificationTokenService`

* Early return if payment failed

* Refactor token generation

* Add tests

* Type fixes

* Type fixes
This commit is contained in:
Joe Au-Yeung
2025-11-21 19:51:19 +00:00
committed by GitHub
parent 06348982a3
commit e5ebf7feb4
12 changed files with 714 additions and 104 deletions
+27 -32
View File
@@ -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 <Loader />;
}
const { valid, hasPaymentFailed, customer } = data;
if (!valid) {
throw new Error("Invalid session or customer id");
}
if (!stripeCustomerId && !sessionId) {
if (!email) {
return <div>{t("invalid_link")}</div>;
}
@@ -176,17 +165,23 @@ export default function Verify({ EMAIL_FROM }: { EMAIL_FROM?: string }) {
<div className="text-default bg-muted bg-opacity-90 backdrop-blur-md backdrop-grayscale backdrop-filter">
<div className="flex min-h-screen flex-col items-center justify-center px-6">
<div className="border-subtle bg-default m-10 flex max-w-2xl flex-col items-center rounded-xl border px-8 py-14 text-left">
{hasPaymentFailed ? <PaymentFailedIcon /> : sessionId ? <PaymentSuccess /> : <MailOpenIcon />}
{hasPaymentFailed ? (
<PaymentFailedIcon />
) : isPremiumUsername ? (
<PaymentSuccess />
) : (
<MailOpenIcon />
)}
<h3 className="font-cal text-emphasis my-6 text-2xl font-normal leading-none">
{hasPaymentFailed
? t("your_payment_failed")
: sessionId
: isPremiumUsername
? t("payment_successful")
: t("check_your_inbox")}
</h3>
{hasPaymentFailed && <p className="my-6">{t("account_created_premium_not_reserved")}</p>}
<p className="text-muted dark:text-subtle text-base font-normal">
{t("email_sent_with_activation_link", { email: customer?.email })}{" "}
{t("email_sent_with_activation_link", { email })}{" "}
{hasPaymentFailed && t("activate_account_to_purchase_username")}
</p>
<div className="mt-7">
@@ -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")}
</button>
@@ -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<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
};
};
describe("paymentCallback", () => {
let mockReq: Partial<NextApiRequest>;
let mockRes: Partial<NextApiResponse>;
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),
});
});
});
});
@@ -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();
}
@@ -12,7 +12,10 @@ const transporter = nodemailer.createTransport<TransportOptions>({
...(serverConfig.transport as TransportOptions),
} as TransportOptions);
const sendVerificationRequest = async ({ identifier, url }: SendVerificationRequestParams) => {
const sendVerificationRequest = async ({
identifier,
url,
}: Pick<SendVerificationRequestParams, "identifier" | "url">) => {
const emailsDir = path.resolve(process.cwd(), "..", "..", "packages/emails", "templates");
const originalUrl = new URL(url);
const webappUrl = new URL(process.env.NEXTAUTH_URL || WEBAPP_URL);
@@ -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) {
@@ -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,
});
});
});
});
@@ -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,
},
});
}
}
@@ -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;
}
}
@@ -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}$/);
});
});
});
@@ -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");
@@ -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;
@@ -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<typeof ZStripeCheckoutSessionInputSchema>;