diff --git a/apps/web/modules/signup-view.tsx b/apps/web/modules/signup-view.tsx index 10f33c8a44..4ca7c48932 100644 --- a/apps/web/modules/signup-view.tsx +++ b/apps/web/modules/signup-view.tsx @@ -16,6 +16,7 @@ import { z } from "zod"; import getStripe from "@calcom/app-store/stripepayment/lib/client"; import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils"; +import { fetchSignup, isUserAlreadyExistsError, hasCheckoutSession } from "@calcom/features/auth/signup/lib/fetchSignup"; import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import ServerTrans from "@calcom/lib/components/ServerTrans"; @@ -222,23 +223,6 @@ export default function Signup({ const loadingSubmitState = isSubmitSuccessful || isSubmitting; const displayBackButton = token ? false : displayEmailForm; - const handleErrorsAndStripe = async (resp: Response) => { - if (!resp.ok) { - const err = await resp.json(); - if (err.checkoutSessionId) { - const stripe = await getStripe(); - if (stripe) { - const { error } = await stripe.redirectToCheckout({ - sessionId: err.checkoutSessionId, - }); - console.warn(error.message); - } - } else { - throw new Error(err.message); - } - } - }; - const isPlatformUser = redirectUrl?.includes("platform") && redirectUrl?.includes("new"); const signUp: SubmitHandler = async (_data) => { @@ -252,77 +236,90 @@ export default function Signup({ username_taken: usernameTaken, }); - await fetch("/api/auth/signup", { - body: JSON.stringify({ - ...data, - language: i18n.language, - token, - }), - headers: { - "Content-Type": "application/json", - "cf-access-token": cfToken ?? "invalid-token", - }, - method: "POST", - }) - .then(handleErrorsAndStripe) - .then(async () => { - if (process.env.NEXT_PUBLIC_GTM_ID) - pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language }); - - // telemetry.event(telemetryEventTypes.signup, collectPageParameters()); - - const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started"; - const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath; - const gettingStartedWithPlatform = "settings/platform/new"; - - const constructCallBackIfUrlPresent = () => { - if (isOrgInviteByLink) { - return `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`; - } - - return addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup"); - }; - - const constructCallBackIfUrlNotPresent = () => { - if (isPlatformUser) { - return `${WEBAPP_URL}/${gettingStartedWithPlatform}?from=signup`; - } - - return `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`; - }; - - const constructCallBackUrl = () => { - const callbackUrlSearchParams = searchParams?.get("callbackUrl"); - - return callbackUrlSearchParams - ? constructCallBackIfUrlPresent() - : constructCallBackIfUrlNotPresent(); - }; - - const callBackUrl = constructCallBackUrl(); - - await signIn<"credentials">("credentials", { + try { + const result = await fetchSignup( + { ...data, - callbackUrl: callBackUrl, - }); - }) - .catch((err) => { - setTurnstileKey((k) => k + 1); - formMethods.setValue("cfToken", undefined); + language: i18n.language, + token, + }, + cfToken + ); - if (err.message === INVALID_CLOUDFLARE_TOKEN_ERROR) { + if (!result.ok) { + if (isUserAlreadyExistsError(result)) { + showToast(t("account_already_exists_please_login"), "warning"); + const callbackUrl = token ? `/teams?token=${token}` : "/event-types"; + setTimeout(() => { + router.push(`/auth/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); + }, 3000); return; } - posthog.capture("signup_form_submit_error", { - has_token: !!token, - is_org_invite: isOrgInviteByLink, - org_slug: orgSlug, - is_premium_username: premiumUsername, - error_message: err.message, - }); - formMethods.setError("apiError", { message: err.message }); + if (hasCheckoutSession(result)) { + const stripe = await getStripe(); + if (stripe) { + const { error } = await stripe.redirectToCheckout({ + sessionId: result.error.checkoutSessionId, + }); + if (error) console.warn(error.message); + } + return; + } + + throw new Error(result.error.message); + } + + if (process.env.NEXT_PUBLIC_GTM_ID) { + pushGTMEvent("create_account", { email: data.email, user: data.username, lang: data.language }); + } + + const gettingStartedPath = onboardingV3Enabled ? "onboarding/getting-started" : "getting-started"; + const verifyOrGettingStarted = emailVerificationEnabled ? "auth/verify-email" : gettingStartedPath; + const gettingStartedWithPlatform = "settings/platform/new"; + + const constructCallBackIfUrlPresent = () => { + if (isOrgInviteByLink) { + return `${WEBAPP_URL}/${searchParams.get("callbackUrl")}`; + } + return addOrUpdateQueryParam(`${WEBAPP_URL}/${searchParams.get("callbackUrl")}`, "from", "signup"); + }; + + const constructCallBackIfUrlNotPresent = () => { + if (isPlatformUser) { + return `${WEBAPP_URL}/${gettingStartedWithPlatform}?from=signup`; + } + return `${WEBAPP_URL}/${verifyOrGettingStarted}?from=signup`; + }; + + const constructCallBackUrl = () => { + const callbackUrlSearchParams = searchParams?.get("callbackUrl"); + return callbackUrlSearchParams ? constructCallBackIfUrlPresent() : constructCallBackIfUrlNotPresent(); + }; + + await signIn<"credentials">("credentials", { + ...data, + callbackUrl: constructCallBackUrl(), }); + } catch (err) { + setTurnstileKey((k) => k + 1); + formMethods.setValue("cfToken", undefined); + + const errorMessage = err instanceof Error ? err.message : t("unexpected_error_try_again"); + + if (errorMessage === INVALID_CLOUDFLARE_TOKEN_ERROR) { + return; + } + + posthog.capture("signup_form_submit_error", { + has_token: !!token, + is_org_invite: isOrgInviteByLink, + org_slug: orgSlug, + is_premium_username: premiumUsername, + error_message: errorMessage, + }); + formMethods.setError("apiError", { message: errorMessage }); + } }; return ( @@ -563,7 +560,7 @@ export default function Signup({ const username = formMethods.getValues("username"); if (!username) { // should not be reached but needed to bypass type errors - showToast("error", t("username_required")); + showToast(t("username_required"), "error"); return; } diff --git a/apps/web/playwright/signup.e2e.ts b/apps/web/playwright/signup.e2e.ts index 2d7131c1dc..ddfa820bf0 100644 --- a/apps/web/playwright/signup.e2e.ts +++ b/apps/web/playwright/signup.e2e.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { hashSync } from "bcryptjs"; import { randomBytes } from "node:crypto"; import { APP_NAME, IS_PREMIUM_USERNAME_ENABLED, IS_MAILHOG_ENABLED } from "@calcom/lib/constants"; @@ -115,6 +116,86 @@ test.describe("Email Signup Flow Test", async () => { expect(alertMessageInner).toContain(alertMessageInner); }); }); + + test("Signup with org invite token for existing user redirects to login without overwriting password", async ({ + page, + prisma, + }) => { + const originalPassword = "OriginalPass99!"; + const attackerPassword = "AttackerPass99!"; + const testEmail = `existing-user-${Date.now()}@example.com`; + + // Create existing user without emailVerified to bypass server-side check + const hashedPassword = hashSync(originalPassword, 12); + const existingUser = await prisma.user.create({ + data: { + email: testEmail, + username: `existing-user-${Date.now()}`, + password: { create: { hash: hashedPassword } }, + emailVerified: null, + }, + }); + + // Create org invite token for the existing user's email + const token = randomBytes(32).toString("hex"); + const org = await prisma.team.create({ + data: { + name: "Test Org", + slug: `test-org-${Date.now()}`, + isOrganization: true, + }, + }); + + await prisma.verificationToken.create({ + data: { + identifier: existingUser.email, + token, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + teamId: org.id, + }, + }); + + // Clear any existing session before attempting signup + await page.context().clearCookies(); + + // Try to signup with the invite token using a different password + await page.goto(`/signup?token=${token}`); + await expect(page.getByTestId("signup-submit-button")).toBeVisible(); + + await page.locator('input[name="password"]').fill(attackerPassword); + + // Intercept the signup API request to verify 409 response + const responsePromise = page.waitForResponse( + (response) => response.url().includes("/api/auth/signup") && response.request().method() === "POST" + ); + + const submitButton = page.getByTestId("signup-submit-button"); + await submitButton.click(); + + // Verify API returns 409 (user already exists) + const response = await responsePromise; + expect(response.status()).toBe(409); + + const responseBody = await response.json(); + expect(responseBody.message).toBe("user_already_exists"); + + // Should redirect to login (toast shows and redirects after 3s) + await expect(page).toHaveURL(/\/auth\/login/, { timeout: 8000 }); + + // Verify original password still works by logging in + await page.locator('input[name="email"]').fill(existingUser.email); + await page.locator('input[name="password"]').fill(originalPassword); + await page.locator('button[type="submit"]').click(); + + // Should successfully login with original password + await expect(page).toHaveURL(/\/(getting-started|event-types|teams)/, { timeout: 8000 }); + + // Cleanup + await prisma.verificationToken.deleteMany({ where: { token } }); + await prisma.user.delete({ where: { id: existingUser.id } }); + await prisma.team.delete({ where: { id: org.id } }); + }); + test("Premium Username Flow - creates stripe checkout", async ({ page, users, prisma }) => { // eslint-disable-next-line playwright/no-skipped-test test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com"); @@ -182,7 +263,7 @@ test.describe("Email Signup Flow Test", async () => { // Verify that the username is the same as the one provided and isn't accidentally changed to email derived username - That happens only for organization member signup expect(dbUser?.username).toBe(userToCreate.username); }); - test("Signup fields prefilled with query params", async ({ page, users }) => { + test("Signup fields prefilled with query params", async ({ page, users: _users }) => { const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com"; await page.goto(signupUrlWithParams); await preventFlakyTest(page); @@ -354,7 +435,7 @@ test.describe("Email Signup Flow Test", async () => { }); }); - test("Checkbox for cookie consent does not need to be checked", async ({ page, users }) => { + test("Checkbox for cookie consent does not need to be checked", async ({ page, users: _users }) => { await page.goto("/signup"); await preventFlakyTest(page); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index ccc4558feb..4b37268f81 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -4185,5 +4185,6 @@ "audit_logs_owner_not_in_organization": "The booking owner is not a member of your organization.", "audit_logs_permission_denied": "You do not have permission to view audit logs for this booking.", "audit_logs_permission_check_error": "An error occurred while checking permissions.", + "account_already_exists_please_login": "An account with this email already exists. Please log in to accept the invitation.", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/auth/signup/constants.ts b/packages/features/auth/signup/constants.ts new file mode 100644 index 0000000000..9d8f9b4722 --- /dev/null +++ b/packages/features/auth/signup/constants.ts @@ -0,0 +1,6 @@ +export const SIGNUP_ERROR_CODES = { + USER_ALREADY_EXISTS: "user_already_exists", + INVALID_SERVER_RESPONSE: "invalid_server_response", +} as const; + +export type SignupErrorCode = (typeof SIGNUP_ERROR_CODES)[keyof typeof SIGNUP_ERROR_CODES]; diff --git a/packages/features/auth/signup/handlers/__tests__/calcomHandler.test.ts b/packages/features/auth/signup/handlers/__tests__/calcomHandler.test.ts new file mode 100644 index 0000000000..a8ba326548 --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/calcomHandler.test.ts @@ -0,0 +1,105 @@ +import type { Mock } from "vitest"; +import { vi } from "vitest"; + +import type { MockResponse } from "./mocks/next.mocks"; + +// Hoisted imports for proper mock initialization +const { + prismaMock, + resetPrismaMock, + createPrismaMock, +} = (await vi.hoisted( + async () => await import("./mocks/prisma.mocks") +)) as Awaited; + +const { + createNextServerMock, + createNextHeadersMock, +} = (await vi.hoisted( + async () => await import("./mocks/next.mocks") +)) as Awaited; + +const { + createMockTeam, + createMockFoundToken, +} = (await vi.hoisted( + async () => await import("./mocks/signup.factories") +)) as Awaited; + +const mockFindTokenByToken: Mock = vi.fn(); +const mockValidateAndGetCorrectedUsernameForTeam: Mock = vi.fn(); + +type UsernameStatus = { + statusCode: 200 | 402 | 418; + requestedUserName: string; + json: { available: boolean; premium: boolean }; +}; + +type InnerHandler = (body: Record, status: UsernameStatus) => Promise; + +let capturedHandler: InnerHandler | null = null; + +vi.mock("next/server", createNextServerMock); +vi.mock("next/headers", createNextHeadersMock); +vi.mock("@calcom/prisma", createPrismaMock); +vi.mock("@calcom/prisma/client", createPrismaMock); +vi.mock("@calcom/lib/logger", () => ({ + default: { getSubLogger: () => ({ warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }) }, +})); +vi.mock("@calcom/lib/auth/hashPassword", () => ({ hashPassword: vi.fn().mockResolvedValue("hashed") })); +vi.mock("@calcom/lib/constants", () => ({ WEBAPP_URL: "http://localhost:3000" })); +vi.mock("@calcom/lib/tracking", () => ({ getTrackingFromCookies: vi.fn().mockReturnValue({}) })); +vi.mock("@calcom/app-store/stripepayment/lib/utils", () => ({ getPremiumMonthlyPlanPriceId: vi.fn() })); +vi.mock("@calcom/features/auth/lib/getLocaleFromRequest", () => ({ getLocaleFromRequest: vi.fn().mockResolvedValue("en") })); +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ sendEmailVerification: vi.fn() })); +vi.mock("@calcom/features/auth/signup/utils/createOrUpdateMemberships", () => ({ createOrUpdateMemberships: vi.fn() })); +vi.mock("@calcom/features/auth/signup/utils/prefillAvatar", () => ({ prefillAvatar: vi.fn() })); +vi.mock("@calcom/features/auth/signup/utils/validateUsername", () => ({ + validateAndGetCorrectedUsernameAndEmail: vi.fn().mockResolvedValue({ isValid: true, username: "testuser" }), +})); +vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: vi.fn().mockReturnValue({ + createCustomer: vi.fn().mockResolvedValue({ stripeCustomerId: "cus_123" }), + }), +})); +vi.mock("@calcom/features/watchlist/lib/telemetry", () => ({ sentrySpan: {} })); +vi.mock("@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller", () => ({ + checkIfEmailIsBlockedInWatchlistController: vi.fn().mockResolvedValue(false), +})); +vi.mock("@calcom/web/lib/buildLegacyCtx", () => ({ buildLegacyRequest: vi.fn() })); +vi.mock("../../utils/organization", () => ({ joinAnyChildTeamOnOrgInvite: vi.fn() })); +vi.mock("../../utils/token", () => ({ + findTokenByToken: (...args: unknown[]) => mockFindTokenByToken(...args), + throwIfTokenExpired: vi.fn(), + validateAndGetCorrectedUsernameForTeam: (...args: unknown[]) => mockValidateAndGetCorrectedUsernameForTeam(...args), +})); + +// Capture inner handler from usernameHandler wrapper +vi.mock("@calcom/lib/server/username", () => ({ + usernameHandler: (handler: InnerHandler) => { + capturedHandler = handler; + return handler; + }, +})); + +// Import after mocks +await import("../calcomHandler"); +import { runP2002TestSuite } from "./p2002.test-suite"; + +function callHandler(body: Record): Promise { + if (!capturedHandler) throw new Error("Handler not captured"); + return capturedHandler(body as Record, { + statusCode: 200, + requestedUserName: body.username || "testuser", + json: { available: true, premium: false }, + }); +} + +runP2002TestSuite("calcomHandler", callHandler, () => { + vi.clearAllMocks(); + resetPrismaMock(); + mockFindTokenByToken.mockResolvedValue(createMockFoundToken()); + mockValidateAndGetCorrectedUsernameForTeam.mockResolvedValue("testuser"); + prismaMock.team.findUnique.mockResolvedValue(createMockTeam() as never); + prismaMock.verificationToken.delete.mockResolvedValue({} as never); +}); diff --git a/packages/features/auth/signup/handlers/__tests__/mocks/next.mocks.ts b/packages/features/auth/signup/handlers/__tests__/mocks/next.mocks.ts new file mode 100644 index 0000000000..35459474e7 --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/mocks/next.mocks.ts @@ -0,0 +1,40 @@ +import type { Mock } from "vitest"; +import { vi } from "vitest"; + +// biome-ignore lint/style/useExportsLast: interface needed before function that uses it +export interface MockResponse { + status: number; + body: unknown; + json: () => Promise; +} + +function createMockNextResponse(body: unknown, init?: { status?: number }): MockResponse { + if (init?.status === undefined) { + throw new Error("NextResponse.json() called without explicit status"); + } + return { + status: init.status, + body, + json: () => Promise.resolve(body), + }; +} + +export function createNextServerMock(): { + NextResponse: { json: (body: unknown, init?: { status?: number }) => MockResponse }; +} { + return { + NextResponse: { + json: (body: unknown, init?: { status?: number }) => createMockNextResponse(body, init), + }, + }; +} + +export function createNextHeadersMock(): { + cookies: Mock; + headers: Mock; +} { + return { + cookies: vi.fn().mockResolvedValue({ getAll: () => [] }), + headers: vi.fn().mockResolvedValue(new Map()), + }; +} diff --git a/packages/features/auth/signup/handlers/__tests__/mocks/prisma.mocks.ts b/packages/features/auth/signup/handlers/__tests__/mocks/prisma.mocks.ts new file mode 100644 index 0000000000..c0e166dbda --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/mocks/prisma.mocks.ts @@ -0,0 +1,57 @@ +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; + +import type { PrismaClient } from "@calcom/prisma"; + +// Prisma mock instance (singleton) +export const prismaMock: DeepMockProxy = mockDeep(); + +export function resetPrismaMock(): void { + mockReset(prismaMock); +} + +// Custom P2002 error class for testing unique constraint violations +export class MockPrismaClientKnownRequestError extends Error { + code: string; + meta?: { target?: string[] }; + + constructor(message: string, { code, meta }: { code: string; meta?: { target?: string[] } }) { + super(message); + this.name = "PrismaClientKnownRequestError"; + this.code = code; + this.meta = meta; + } +} + +export function createPrismaMock(): { + default: DeepMockProxy; + prisma: DeepMockProxy; + Prisma: { PrismaClientKnownRequestError: typeof MockPrismaClientKnownRequestError }; +} { + return { + default: prismaMock, + prisma: prismaMock, + Prisma: { PrismaClientKnownRequestError: MockPrismaClientKnownRequestError }, + }; +} + +// P2002 error factories +export function createP2002Error(target: string[]): MockPrismaClientKnownRequestError { + return new MockPrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: { target }, + }); +} + +export function createP2002ErrorWithoutTarget(): MockPrismaClientKnownRequestError { + return new MockPrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + meta: {}, + }); +} + +export function createGenericPrismaError(): MockPrismaClientKnownRequestError { + return new MockPrismaClientKnownRequestError("Record not found", { + code: "P2025", + meta: {}, + }); +} diff --git a/packages/features/auth/signup/handlers/__tests__/mocks/signup.factories.ts b/packages/features/auth/signup/handlers/__tests__/mocks/signup.factories.ts new file mode 100644 index 0000000000..2057b74cdd --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/mocks/signup.factories.ts @@ -0,0 +1,60 @@ +export interface MockTeam { + id: number; + isOrganization: boolean; + parent: { id: number; slug: string; organizationSettings: null } | null; + organizationSettings: null; +} + +export interface MockFoundToken { + id: number; + teamId: number | null; + expires: Date; +} + +export interface MockUser { + id: number; +} + +export interface SignupBody { + email: string; + password: string; + username: string; + language: string; + token?: string; +} + +export function createMockTeam(overrides: Partial = {}): MockTeam { + return { + id: 1, + isOrganization: true, + parent: null, + organizationSettings: null, + ...overrides, + }; +} + +export function createMockFoundToken(overrides: Partial = {}): MockFoundToken { + return { + id: 1, + teamId: 1, + expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + ...overrides, + }; +} + +export function createMockUser(overrides: Partial = {}): MockUser { + return { + id: 1, + ...overrides, + }; +} + +export function createSignupBody(overrides: Partial = {}): SignupBody { + return { + email: "test@example.com", + password: "ValidPassword123!", + username: "testuser", + language: "en", + ...overrides, + }; +} diff --git a/packages/features/auth/signup/handlers/__tests__/p2002.test-suite.ts b/packages/features/auth/signup/handlers/__tests__/p2002.test-suite.ts new file mode 100644 index 0000000000..76ea04e24e --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/p2002.test-suite.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +import { SIGNUP_ERROR_CODES } from "../../constants"; +import type { MockResponse } from "./mocks/next.mocks"; +import { + prismaMock, + createP2002Error, + createP2002ErrorWithoutTarget, + createGenericPrismaError, +} from "./mocks/prisma.mocks"; +import { createSignupBody, createMockUser } from "./mocks/signup.factories"; + +import type { SignupBody } from "./mocks/signup.factories"; + +type CallHandler = (body: SignupBody) => Promise; + +// biome-ignore lint/complexity/noExcessiveLinesPerFunction: test suite contains multiple test cases +export function runP2002TestSuite( + handlerName: string, + callHandler: CallHandler, + setupMocks: () => void +): void { + describe(`${handlerName} – signup P2002 contract`, () => { + describe("P2002 Error Handling (non-token flow)", () => { + beforeEach(setupMocks); + + it("returns 409 when target includes 'email'", async () => { + prismaMock.user.create.mockRejectedValue(createP2002Error(["email"])); + + const response = await callHandler(createSignupBody()); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS, + }); + }); + + it("returns 409 when target includes 'email' among other fields", async () => { + prismaMock.user.create.mockRejectedValue(createP2002Error(["id", "email", "username"])); + + const response = await callHandler(createSignupBody()); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS, + }); + }); + + it("returns 409 when target is 'username' only", async () => { + prismaMock.user.create.mockRejectedValue(createP2002Error(["username"])); + + const response = await callHandler(createSignupBody()); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS, + }); + }); + + it("re-throws when target is empty", async () => { + const error = createP2002ErrorWithoutTarget(); + prismaMock.user.create.mockRejectedValue(error); + + await expect(callHandler(createSignupBody())).rejects.toThrow(error); + }); + + it("re-throws when target is undefined", async () => { + const error = createP2002Error([]); + error.meta = undefined; + prismaMock.user.create.mockRejectedValue(error); + + await expect(callHandler(createSignupBody())).rejects.toThrow(error); + }); + + it("re-throws non-P2002 Prisma errors", async () => { + const error = createGenericPrismaError(); + prismaMock.user.create.mockRejectedValue(error); + + await expect(callHandler(createSignupBody())).rejects.toThrow(error); + }); + + it("re-throws generic errors", async () => { + const error = new Error("Database connection failed"); + prismaMock.user.create.mockRejectedValue(error); + + await expect(callHandler(createSignupBody())).rejects.toThrow(error); + }); + }); + + describe("Successful Creation", () => { + beforeEach(setupMocks); + + it("returns 201 when user is created (non-token flow)", async () => { + prismaMock.user.create.mockResolvedValue(createMockUser()); + + const response = await callHandler(createSignupBody()); + + expect(response.status).toBe(201); + }); + + it("returns 201 when user is created via invite (token flow)", async () => { + prismaMock.user.findUnique.mockResolvedValue(null as never); + prismaMock.user.findFirst.mockResolvedValue(null as never); + prismaMock.user.upsert.mockResolvedValue(createMockUser() as never); + + const response = await callHandler(createSignupBody({ token: "valid-token" })); + + expect(response.status).toBe(201); + }); + }); + + describe("Invite flow - existing user validation", () => { + beforeEach(setupMocks); + + it("returns 409 when user exists but was not invited to this team", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 99, invitedTo: 999 } as never); + + const response = await callHandler(createSignupBody({ token: "valid-token" })); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS, + }); + }); + + it("returns 201 when user exists and was invited to this team", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 99, invitedTo: 1 } as never); + prismaMock.user.findFirst.mockResolvedValue(null as never); + prismaMock.user.upsert.mockResolvedValue(createMockUser() as never); + + const response = await callHandler(createSignupBody({ token: "valid-token" })); + + expect(response.status).toBe(201); + }); + }); + }); +} diff --git a/packages/features/auth/signup/handlers/__tests__/selfHostedHandler.test.ts b/packages/features/auth/signup/handlers/__tests__/selfHostedHandler.test.ts new file mode 100644 index 0000000000..7fcb740bec --- /dev/null +++ b/packages/features/auth/signup/handlers/__tests__/selfHostedHandler.test.ts @@ -0,0 +1,65 @@ +import type { Mock } from "vitest"; +import { vi } from "vitest"; + +// Hoisted imports for proper mock initialization +const { + prismaMock, + resetPrismaMock, + createPrismaMock, +} = (await vi.hoisted( + async () => await import("./mocks/prisma.mocks") +)) as Awaited; + +const { createNextServerMock } = (await vi.hoisted( + async () => await import("./mocks/next.mocks") +)) as Awaited; + +const { + createMockTeam, + createMockFoundToken, +} = (await vi.hoisted( + async () => await import("./mocks/signup.factories") +)) as Awaited; + +const mockFindTokenByToken: Mock = vi.fn(); +const mockValidateAndGetCorrectedUsernameForTeam: Mock = vi.fn(); + +vi.mock("next/server", createNextServerMock); +vi.mock("@calcom/prisma", createPrismaMock); +vi.mock("@calcom/prisma/client", createPrismaMock); +vi.mock("@calcom/lib/logger", () => ({ + default: { getSubLogger: () => ({ warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }) }, +})); +vi.mock("@calcom/lib/auth/hashPassword", () => ({ hashPassword: vi.fn().mockResolvedValue("hashed") })); +vi.mock("@calcom/lib/slugify", () => ({ default: vi.fn((s: string) => s.toLowerCase()) })); +vi.mock("@calcom/lib/constants", () => ({ IS_PREMIUM_USERNAME_ENABLED: false })); +vi.mock("@calcom/lib/server/username", () => ({ isUsernameReservedDueToMigration: vi.fn().mockResolvedValue(false) })); +vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({ sendEmailVerification: vi.fn() })); +vi.mock("@calcom/features/auth/signup/utils/createOrUpdateMemberships", () => ({ createOrUpdateMemberships: vi.fn() })); +vi.mock("@calcom/features/auth/signup/utils/validateUsername", () => ({ + validateAndGetCorrectedUsernameAndEmail: vi.fn().mockResolvedValue({ isValid: true, username: "testuser" }), +})); +vi.mock("../../utils/organization", () => ({ joinAnyChildTeamOnOrgInvite: vi.fn() })); +vi.mock("../../utils/prefillAvatar", () => ({ prefillAvatar: vi.fn() })); +vi.mock("../../utils/token", () => ({ + findTokenByToken: (...args: unknown[]) => mockFindTokenByToken(...args), + throwIfTokenExpired: vi.fn(), + validateAndGetCorrectedUsernameForTeam: (...args: unknown[]) => mockValidateAndGetCorrectedUsernameForTeam(...args), +})); + +// Import after mocks +import handler from "../selfHostedHandler"; +import { runP2002TestSuite } from "./p2002.test-suite"; + +function callHandler(body: Record): ReturnType { + return handler(body as Record); +} + +runP2002TestSuite("selfHostedHandler", callHandler, () => { + vi.clearAllMocks(); + resetPrismaMock(); + mockFindTokenByToken.mockResolvedValue(createMockFoundToken()); + mockValidateAndGetCorrectedUsernameForTeam.mockResolvedValue("testuser"); + prismaMock.team.findUnique.mockResolvedValue(createMockTeam() as never); + prismaMock.verificationToken.delete.mockResolvedValue({} as never); +}); diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index 6573e4ddd2..3d9d1a33a6 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -14,15 +14,17 @@ import { hashPassword } from "@calcom/lib/auth/hashPassword"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; +import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown"; import type { CustomNextApiHandler } from "@calcom/lib/server/username"; import { usernameHandler } from "@calcom/lib/server/username"; import { getTrackingFromCookies } from "@calcom/lib/tracking"; -import { prisma } from "@calcom/prisma"; +import prisma from "@calcom/prisma"; import { CreationSource } from "@calcom/prisma/enums"; import { IdentityProvider } from "@calcom/prisma/enums"; import { signupSchema } from "@calcom/prisma/zod-utils"; import { buildLegacyRequest } from "@calcom/web/lib/buildLegacyCtx"; +import { SIGNUP_ERROR_CODES } from "../constants"; import { joinAnyChildTeamOnOrgInvite } from "../utils/organization"; import { findTokenByToken, @@ -83,6 +85,16 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { teamId: foundToken?.teamId ?? null, isSignup: true, }); + + if (foundToken?.teamId) { + const existingUser = await prisma.user.findUnique({ + where: { email }, + select: { invitedTo: true }, + }); + if (existingUser && existingUser.invitedTo !== foundToken.teamId) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + } } else { const usernameAndEmailValidation = await validateAndGetCorrectedUsernameAndEmail({ username, @@ -167,29 +179,57 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { }); if (team) { const organizationId = team.isOrganization ? team.id : team.parent?.id ?? null; - const user = await prisma.user.upsert({ - where: { email }, - update: { - username, - emailVerified: new Date(Date.now()), - identityProvider: IdentityProvider.CAL, - password: { - upsert: { - create: { hash: hashedPassword }, - update: { hash: hashedPassword }, - }, + + if (username) { + const existingUserByUsername = await prisma.user.findFirst({ + where: { + username, + organizationId, + NOT: { email }, }, - organizationId, - }, - create: { - username, - email, - identityProvider: IdentityProvider.CAL, - password: { create: { hash: hashedPassword } }, - organizationId, - }, - }); - // Wrapping in a transaction as if one fails we want to rollback the whole thing to preventa any data inconsistencies + select: { id: true }, + }); + if (existingUserByUsername) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + } + + let user: { id: number }; + try { + user = await prisma.user.upsert({ + where: { email }, + update: { + username, + emailVerified: new Date(Date.now()), + identityProvider: IdentityProvider.CAL, + password: { + upsert: { + create: { hash: hashedPassword }, + update: { hash: hashedPassword }, + }, + }, + organizationId, + }, + create: { + username, + email, + emailVerified: new Date(Date.now()), + identityProvider: IdentityProvider.CAL, + password: { create: { hash: hashedPassword } }, + organizationId, + }, + select: { id: true }, + }); + } catch (error) { + if (isPrismaError(error) && error.code === "P2002") { + const target = String(error.meta?.target ?? ""); + if (target.includes("email") || target.includes("username")) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + } + throw error; + } + await createOrUpdateMemberships({ user, team, @@ -212,19 +252,33 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { }); } else { // Create the user - await prisma.user.create({ - data: { - username, - email, - locked: shouldLockByDefault, - password: { create: { hash: hashedPassword } }, - metadata: { - stripeCustomerId: customer.stripeCustomerId, - checkoutSessionId, + try { + await prisma.user.create({ + data: { + username, + email, + locked: shouldLockByDefault, + password: { create: { hash: hashedPassword } }, + metadata: { + stripeCustomerId: customer.stripeCustomerId, + checkoutSessionId, + }, + creationSource: CreationSource.WEBAPP, }, - creationSource: CreationSource.WEBAPP, - }, - }); + }); + } catch (error) { + // Fallback for race conditions where user was created between our check and create + if (isPrismaError(error) && error.code === "P2002") { + const target = String(error.meta?.target ?? ""); + if (target.includes("email") || target.includes("username")) { + return NextResponse.json( + { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, + { status: 409 } + ); + } + } + throw error; + } if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) { await prefillAvatar({ email }); } diff --git a/packages/features/auth/signup/handlers/selfHostedHandler.ts b/packages/features/auth/signup/handlers/selfHostedHandler.ts index 69d88ccc23..e5252e52cf 100644 --- a/packages/features/auth/signup/handlers/selfHostedHandler.ts +++ b/packages/features/auth/signup/handlers/selfHostedHandler.ts @@ -7,12 +7,14 @@ import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/s import { hashPassword } from "@calcom/lib/auth/hashPassword"; import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { isPrismaError } from "@calcom/lib/server/getServerErrorFromUnknown"; import { isUsernameReservedDueToMigration } from "@calcom/lib/server/username"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; import { IdentityProvider } from "@calcom/prisma/enums"; import { signupSchema } from "@calcom/prisma/zod-utils"; +import { SIGNUP_ERROR_CODES } from "../constants"; import { joinAnyChildTeamOnOrgInvite } from "../utils/organization"; import { prefillAvatar } from "../utils/prefillAvatar"; import { @@ -42,6 +44,16 @@ export default async function handler(body: Record) { teamId: foundToken?.teamId, isSignup: true, }); + + if (foundToken?.teamId) { + const existingUser = await prisma.user.findUnique({ + where: { email: userEmail }, + select: { invitedTo: true }, + }); + if (existingUser && existingUser.invitedTo !== foundToken.teamId) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + } } else { const userValidation = await validateAndGetCorrectedUsernameAndEmail({ username, @@ -89,30 +101,56 @@ export default async function handler(body: Record) { } const organizationId = team.isOrganization ? team.id : team.parent?.id ?? null; - const user = await prisma.user.upsert({ - where: { email: userEmail }, - update: { - username: correctedUsername, - password: { - upsert: { - create: { hash: hashedPassword }, - update: { hash: hashedPassword }, - }, - }, - emailVerified: new Date(Date.now()), - identityProvider: IdentityProvider.CAL, - organizationId, - }, - create: { - username: correctedUsername, - email: userEmail, - password: { create: { hash: hashedPassword } }, - identityProvider: IdentityProvider.CAL, - organizationId, - }, - }); - const { membership } = await createOrUpdateMemberships({ + const existingUserByUsername = await prisma.user.findFirst({ + where: { + username: correctedUsername, + organizationId, + NOT: { email: userEmail }, + }, + select: { id: true }, + }); + if (existingUserByUsername) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + + let user: { id: number }; + try { + user = await prisma.user.upsert({ + where: { email: userEmail }, + update: { + username: correctedUsername, + emailVerified: new Date(Date.now()), + identityProvider: IdentityProvider.CAL, + password: { + upsert: { + create: { hash: hashedPassword }, + update: { hash: hashedPassword }, + }, + }, + organizationId, + }, + create: { + username: correctedUsername, + email: userEmail, + emailVerified: new Date(Date.now()), + identityProvider: IdentityProvider.CAL, + password: { create: { hash: hashedPassword } }, + organizationId, + }, + select: { id: true }, + }); + } catch (error) { + if (isPrismaError(error) && error.code === "P2002") { + const target = String(error.meta?.target ?? ""); + if (target.includes("email") || target.includes("username")) { + return NextResponse.json({ message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, { status: 409 }); + } + } + throw error; + } + + await createOrUpdateMemberships({ user, team, }); @@ -146,26 +184,30 @@ export default async function handler(body: Record) { ); } } - await prisma.user.upsert({ - where: { email: userEmail }, - update: { - username: correctedUsername, - password: { - upsert: { - create: { hash: hashedPassword }, - update: { hash: hashedPassword }, - }, + + try { + await prisma.user.create({ + data: { + username: correctedUsername, + email: userEmail, + password: { create: { hash: hashedPassword } }, + identityProvider: IdentityProvider.CAL, }, - emailVerified: new Date(Date.now()), - identityProvider: IdentityProvider.CAL, - }, - create: { - username: correctedUsername, - email: userEmail, - password: { create: { hash: hashedPassword } }, - identityProvider: IdentityProvider.CAL, - }, - }); + select: { id: true }, + }); + } catch (error) { + // Fallback for race conditions where user was created between our check and create + if (isPrismaError(error) && error.code === "P2002") { + const target = String(error.meta?.target ?? ""); + if (target.includes("email") || target.includes("username")) { + return NextResponse.json( + { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, + { status: 409 } + ); + } + } + throw error; + } if (process.env.AVATARAPI_USERNAME && process.env.AVATARAPI_PASSWORD) { await prefillAvatar({ email: userEmail }); diff --git a/packages/features/auth/signup/lib/fetchSignup.test.ts b/packages/features/auth/signup/lib/fetchSignup.test.ts new file mode 100644 index 0000000000..d1d394cee3 --- /dev/null +++ b/packages/features/auth/signup/lib/fetchSignup.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { SIGNUP_ERROR_CODES } from "../constants"; +import { fetchSignup, isUserAlreadyExistsError, hasCheckoutSession } from "./fetchSignup"; + +function createJsonResponse(json: unknown, status = 200) { + return new Response(JSON.stringify(json), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("fetchSignup", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("returns success when signup completes", async () => { + fetchSpy.mockResolvedValue(createJsonResponse({ message: "Created user" })); + + const result = await fetchSignup({ + email: "test@example.com", + password: "password123", + language: "en", + }); + + expect(result.ok).toBe(true); + }); + + it("returns error with status code when signup fails", async () => { + fetchSpy.mockResolvedValue(createJsonResponse({ message: "Username is already taken" }, 409)); + + const result = await fetchSignup({ + email: "test@example.com", + password: "password123", + language: "en", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(409); + } + }); +}); + +describe("isUserAlreadyExistsError", () => { + it("returns true for 409 with user_already_exists message", () => { + const result = { + ok: false as const, + status: 409, + error: { message: SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS }, + }; + + expect(isUserAlreadyExistsError(result)).toBe(true); + }); + + it("returns false for other error messages", () => { + const result = { + ok: false as const, + status: 409, + error: { message: "Username is already taken" }, + }; + + expect(isUserAlreadyExistsError(result)).toBe(false); + }); + + it("returns false for success responses", () => { + const result = { + ok: true as const, + data: { message: "Created user" }, + }; + + expect(isUserAlreadyExistsError(result)).toBe(false); + }); +}); + +describe("hasCheckoutSession", () => { + it("returns true when checkoutSessionId exists", () => { + const result = { + ok: false as const, + status: 402, + error: { message: "Payment required", checkoutSessionId: "cs_123" }, + }; + + expect(hasCheckoutSession(result)).toBe(true); + }); + + it("returns false when checkoutSessionId is absent", () => { + const result = { + ok: false as const, + status: 409, + error: { message: "Error" }, + }; + + expect(hasCheckoutSession(result)).toBe(false); + }); +}); diff --git a/packages/features/auth/signup/lib/fetchSignup.ts b/packages/features/auth/signup/lib/fetchSignup.ts new file mode 100644 index 0000000000..c665d8359f --- /dev/null +++ b/packages/features/auth/signup/lib/fetchSignup.ts @@ -0,0 +1,68 @@ +import { SIGNUP_ERROR_CODES } from "../constants"; + +type SignupData = { + username?: string; + email: string; + password: string; + language: string; + token?: string; +}; + +type SignupSuccessResponse = { + message: string; + stripeCustomerId?: string; +}; + +type SignupErrorResponse = { + message: string; + checkoutSessionId?: string; +}; + +type SignupResponse = SignupSuccessResponse | SignupErrorResponse; + +export type SignupResult = + | { ok: true; data: SignupSuccessResponse } + | { ok: false; status: number; error: SignupErrorResponse }; + +export async function fetchSignup(data: SignupData, cfToken?: string): Promise { + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + "cf-access-token": cfToken ?? "invalid-token", + }, + body: JSON.stringify(data), + }); + + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + return { + ok: false, + status: response.status, + error: { message: SIGNUP_ERROR_CODES.INVALID_SERVER_RESPONSE }, + }; + } + + const json = (await response.json()) as SignupResponse; + + if (!response.ok) { + return { + ok: false, + status: response.status, + error: json as SignupErrorResponse, + }; + } + + return { + ok: true, + data: json as SignupSuccessResponse, + }; +} + +export function isUserAlreadyExistsError(result: SignupResult): boolean { + return !result.ok && result.status === 409 && result.error.message === SIGNUP_ERROR_CODES.USER_ALREADY_EXISTS; +} + +export function hasCheckoutSession(result: SignupResult): result is { ok: false; status: number; error: SignupErrorResponse & { checkoutSessionId: string } } { + return !result.ok && !!result.error.checkoutSessionId; +} diff --git a/packages/lib/server/getServerErrorFromUnknown.ts b/packages/lib/server/getServerErrorFromUnknown.ts index 36ed769be4..8009f23a7d 100644 --- a/packages/lib/server/getServerErrorFromUnknown.ts +++ b/packages/lib/server/getServerErrorFromUnknown.ts @@ -18,8 +18,12 @@ function isZodError(cause: unknown): cause is ZodError { return cause instanceof ZodError || (hasName(cause) && cause.name === "ZodError"); } +// Fallback to code check when instanceof fails due to different Prisma client instances function isPrismaError(cause: unknown): cause is Prisma.PrismaClientKnownRequestError { - return cause instanceof Prisma.PrismaClientKnownRequestError; + return ( + cause instanceof Prisma.PrismaClientKnownRequestError || + (cause instanceof Error && "code" in cause && typeof cause.code === "string" && cause.code.startsWith("P")) + ); } function parseZodErrorIssues(issues: ZodIssue[]): string { @@ -201,3 +205,5 @@ function getServerErrorFromPrismaError( } return getHttpError({ statusCode: 400, cause, traceId, tracedData }); } + +export { isPrismaError };