fix: handle existing users on invite token flow (#26217)

* fix(auth): validate user before signup with invite token

Validate if user already exists before creating account when
signing up with team or organization invite tokens. Existing users
are redirected to login to accept the invitation.

- Add user existence check in signup handlers
- Return 409 for existing users with redirect to login
- Extract signup fetch logic to dedicated module
- Add e2e test coverage

* fix(auth): address code review feedback

- Fix fetchSignup tests to use vi.spyOn for proper mock restoration
- Add content-type validation before parsing JSON response
- Guard against undefined error in Stripe callback
- Use t() for localized error message
- Fix race condition in handlers by catching P2002 on create

* fix(auth): address additional code review feedback

- Add INVALID_SERVER_RESPONSE constant to follow established pattern
- Check error.meta.target includes email before returning USER_ALREADY_EXISTS
to avoid false positives from other unique constraint violations
- Add select: { id: true } to user.create calls since downstream functions only
need the user id

* test: add unit tests for P2002 handling in signup handlers

- Add shared test suite covering all P2002 edge cases
- Ensure 409 only for email constraint violations
- Fix non-token paths to use atomic create + catch pattern

* fix: update error message copy per review feedback

* fix(auth): address code review feedback and prevent orphan Stripe customers

- Add user existence check before Stripe customer creation (token flow)
- Add select clause to user.create for consistency
- Fix showToast argument order (pre-existing bug)
- Use toHaveURL instead of waitForURL in E2E tests

* fix(auth): resolve 500 errors by fixing Prisma error detection across module boundaries

The instanceof check for PrismaClientKnownRequestError fails when different
Prisma client instances are loaded. Added fallback check by constructor name

* fix(auth): validate invitedTo before upsert on team invite signup

* test(auth): update P2002 tests for new invite flow

P2002 tests now use non-token flow since token flow uses upsert
Added tests for invitedTo validation on invite signup

* fix(auth): add guards and P2002 handling per review feedback

- Guard existingUser check with if (foundToken?.teamId)
- Guard username check with if (username) for premium flow
- Add `select` clause to findFirst/findUnique queries
- Add try-catch on upsert for race condition P2002 errors

* fix(auth): narrow P2002 handling to email/username targets
This commit is contained in:
Pedro Castro
2026-01-06 22:10:36 +00:00
committed by GitHub
parent 8b5d9202db
commit 170203051f
15 changed files with 985 additions and 163 deletions
+80 -83
View File
@@ -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<FormValues> = 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;
}
+83 -2
View File
@@ -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);
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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];
@@ -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<typeof import("./mocks/prisma.mocks")>;
const {
createNextServerMock,
createNextHeadersMock,
} = (await vi.hoisted(
async () => await import("./mocks/next.mocks")
)) as Awaited<typeof import("./mocks/next.mocks")>;
const {
createMockTeam,
createMockFoundToken,
} = (await vi.hoisted(
async () => await import("./mocks/signup.factories")
)) as Awaited<typeof import("./mocks/signup.factories")>;
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<string, string>, status: UsernameStatus) => Promise<MockResponse>;
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<string, string | undefined>): Promise<MockResponse> {
if (!capturedHandler) throw new Error("Handler not captured");
return capturedHandler(body as Record<string, string>, {
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);
});
@@ -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<unknown>;
}
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()),
};
}
@@ -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<PrismaClient> = mockDeep<PrismaClient>();
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<PrismaClient>;
prisma: DeepMockProxy<PrismaClient>;
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: {},
});
}
@@ -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> = {}): MockTeam {
return {
id: 1,
isOrganization: true,
parent: null,
organizationSettings: null,
...overrides,
};
}
export function createMockFoundToken(overrides: Partial<MockFoundToken> = {}): MockFoundToken {
return {
id: 1,
teamId: 1,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
...overrides,
};
}
export function createMockUser(overrides: Partial<MockUser> = {}): MockUser {
return {
id: 1,
...overrides,
};
}
export function createSignupBody(overrides: Partial<SignupBody> = {}): SignupBody {
return {
email: "test@example.com",
password: "ValidPassword123!",
username: "testuser",
language: "en",
...overrides,
};
}
@@ -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<MockResponse>;
// 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);
});
});
});
}
@@ -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<typeof import("./mocks/prisma.mocks")>;
const { createNextServerMock } = (await vi.hoisted(
async () => await import("./mocks/next.mocks")
)) as Awaited<typeof import("./mocks/next.mocks")>;
const {
createMockTeam,
createMockFoundToken,
} = (await vi.hoisted(
async () => await import("./mocks/signup.factories")
)) as Awaited<typeof import("./mocks/signup.factories")>;
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<string, string | undefined>): ReturnType<typeof handler> {
return handler(body as Record<string, string>);
}
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);
});
@@ -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 });
}
@@ -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<string, string>) {
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<string, string>) {
}
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<string, string>) {
);
}
}
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 });
@@ -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<typeof vi.spyOn>;
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);
});
});
@@ -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<SignupResult> {
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;
}
@@ -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 };