Files
calendar/apps/web/playwright/signup.e2e.ts
T
Pedro CastroandGitHub 170203051f 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
2026-01-06 22:10:36 +00:00

461 lines
18 KiB
TypeScript

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";
import prisma from "@calcom/prisma";
import { test } from "./lib/fixtures";
import { localize } from "./lib/localize";
import { getEmailsReceivedByUser } from "./lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./team/expects";
test.describe.configure({ mode: "parallel" });
const preventFlakyTest = async (page: Page) => {
await expect(page.locator("text=Create your account")).toBeVisible();
};
test.describe("Signup Main Page Test", async () => {
test.beforeEach(async ({ page }) => {
await page.goto("/signup");
await preventFlakyTest(page);
});
test("Continue with Email button must exist / work", async ({ page }) => {
const button = page.getByTestId("continue-with-email-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("signup-back-button")).toBeVisible();
});
test("Continue with google button must exist / work", async ({ page }) => {
const button = page.getByTestId("continue-with-google-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await page.waitForURL("/auth/sso/google");
});
test("Continue with SAML button must exist / work", async ({ page }) => {
const button = page.getByTestId("continue-with-saml-button");
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
await expect(page.getByTestId("signup-back-button")).toBeVisible();
});
});
test.describe("Email Signup Flow Test", async () => {
test.beforeEach(async ({ features }) => {
features.reset(); // This resets to the initial state not an empt yarray
});
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("Username is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
await users.create({
username: "pro",
});
await page.goto("/signup");
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
expect(alertMessageInner).toContain(alertMessageInner);
});
});
test("Email is taken", async ({ page, users }) => {
// log in trail user
await test.step("Sign up", async () => {
const user = await users.create({
username: "pro",
});
await page.goto("/signup");
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
const alertMessage = "Username or email is already taken";
// Fill form
await page.locator('input[name="username"]').fill("randomuserwhodoesntexist");
await page.locator('input[name="email"]').fill(user.email);
await page.locator('input[name="password"]').fill("Password99!");
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
const alert = await page.waitForSelector('[data-testid="alert"]');
const alertMessageInner = await alert.innerText();
expect(alertMessage).toBeDefined();
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");
const userToCreate = users.buildForSignup({
username: "rock",
password: "Password99!",
});
// Ensure the premium username is available
await prisma.user.deleteMany({ where: { username: "rock" } });
// Signup with premium username name
await page.goto("/signup");
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill("rock");
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
// Check that stripe checkout is present
const expectedUrl = "https://checkout.stripe.com";
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
const url = page.url();
// Check that the URL matches the expected URL
expect(url).toContain(expectedUrl);
// TODO: complete the stripe checkout flow
});
test("Signup with valid (non premium) username", async ({ page, users }) => {
const userToCreate = users.buildForSignup({
username: "rick-jones",
password: "Password99!",
// Email intentonally kept as different from username
email: `rickjones${Math.random()}-${Date.now()}@example.com`,
});
await page.goto("/signup");
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
await page.waitForURL("/auth/verify-email**");
// Check that the URL matches the expected URL
expect(page.url()).toContain("/auth/verify-email");
const dbUser = await prisma.user.findUnique({ where: { email: userToCreate.email } });
// 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: _users }) => {
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
await page.goto(signupUrlWithParams);
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
// Fill form
const usernameInput = page.locator('input[name="username"]');
const emailInput = page.locator('input[name="email"]');
expect(await usernameInput.inputValue()).toBe("rick-jones");
expect(await emailInput.inputValue()).toBe("rick-jones@example.com");
});
test("Signup with token prefils correct fields", async ({ page, users, prisma }) => {
//Create a user and create a token
const token = randomBytes(32).toString("hex");
const userToCreate = users.buildForSignup({
username: "rick-team",
});
const createdtoken = await prisma.verificationToken.create({
data: {
identifier: userToCreate.email,
token,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
team: {
create: {
name: "Rick's Team",
slug: `${userToCreate.username}-team`,
},
},
},
});
// create a user with the same email as the token
const rickTeamUser = await prisma.user.create({
data: {
email: userToCreate.email,
username: userToCreate.username,
},
});
// Create provitional membership
await prisma.membership.create({
data: {
teamId: createdtoken.teamId ?? -1,
userId: rickTeamUser.id,
role: "ADMIN",
accepted: false,
},
});
const signupUrlWithToken = `/signup?token=${token}`;
await page.goto(signupUrlWithToken);
await preventFlakyTest(page);
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
const usernameField = page.locator('input[name="username"]');
const emailField = page.locator('input[name="email"]');
expect(await usernameField.inputValue()).toBe(userToCreate.username);
expect(await emailField.inputValue()).toBe(userToCreate.email);
// Cleanup specific to this test
// Clean up the user and token
await prisma.user.deleteMany({ where: { email: userToCreate.email } });
await prisma.verificationToken.deleteMany({ where: { identifier: createdtoken.identifier } });
await prisma.team.deleteMany({ where: { id: createdtoken.teamId! } });
});
test("Email verification sent if enabled", async ({ page, prisma, emails, users, features }) => {
const EmailVerifyFlag = features.get("email-verification")?.enabled;
// eslint-disable-next-line playwright/no-skipped-test
test.skip(!EmailVerifyFlag || !IS_MAILHOG_ENABLED, "Skipping check - Email verify disabled");
// Ensure email verification before testing (TODO: this could break other tests but we can fix that later)
await prisma.feature.update({
where: { slug: "email-verification" },
data: { enabled: true },
});
const userToCreate = users.buildForSignup({
email: users.trackEmail({ username: "email-verify", domain: "example.com" }),
username: "email-verify",
password: "Password99!",
});
await page.goto("/signup");
await preventFlakyTest(page);
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
// Fill form
await page.locator('input[name="username"]').fill(userToCreate.username);
await page.locator('input[name="email"]').fill(userToCreate.email);
await page.locator('input[name="password"]').fill(userToCreate.password);
// Submit form
const submitButton = page.getByTestId("signup-submit-button");
await submitButton.click();
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
// Find the newly created user and add it to the fixture store
const newUser = await users.set(userToCreate.email);
expect(newUser).not.toBeNull();
const receivedEmails = await getEmailsReceivedByUser({
emails,
userEmail: userToCreate.email,
});
expect(receivedEmails?.total).toBe(1);
const verifyEmail = receivedEmails?.items[0];
expect(verifyEmail?.subject).toBe(`${APP_NAME}: Verify your account`);
});
test("If signup is disabled allow team invites", async ({ browser, page, users, emails }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== "true", "Skipping due to signup being enabled");
const t = await localize("en");
const teamOwner = await users.create(undefined, { hasTeam: true });
const { team } = await teamOwner.getFirstTeamMembership();
await teamOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/settings`);
await test.step("Invite User to team", async () => {
// TODO: This invite logic should live in a fixture - its used in team and orgs invites (Duplicated from team/org invites)
const invitedUserEmail = `rick_${Date.now()}@domain-${Date.now()}.com`;
await page.locator(`button:text("${t("add")}")`).click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
const inviteLink = await expectInvitationEmailToBeReceived(
page,
emails,
invitedUserEmail,
`${team.name}'s admin invited you to join the team ${team.name} on Cal.com`,
"signup?token"
);
//Check newly invited member exists and is pending
await expect(
page.locator(`[data-testid="email-${invitedUserEmail.replace("@", "")}-pending"]`)
).toHaveCount(1);
// eslint-disable-next-line playwright/no-conditional-in-test
if (!inviteLink) return;
// Follow invite link to new window
const context = await browser.newContext();
const newPage = await context.newPage();
await newPage.goto(inviteLink);
await expect(newPage.locator("text=Create your account")).toBeVisible();
const url = new URL(newPage.url());
expect(url.pathname).toBe("/signup");
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
await expect(continueWithEmailButton).toBeVisible();
await continueWithEmailButton.click();
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
// Check required fields
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
await newPage.locator("button[type=submit]").click();
await newPage.waitForURL("/getting-started?from=signup");
await newPage.close();
await context.close();
});
});
test("Checkbox for cookie consent does not need to be checked", async ({ page, users: _users }) => {
await page.goto("/signup");
await preventFlakyTest(page);
// Navigate to email form
await page.getByTestId("continue-with-email-button").click();
// Fill form
await page.locator('input[name="username"]').fill("pro");
await page.locator('input[name="email"]').fill("pro@example.com");
await page.locator('input[name="password"]').fill("Password99!");
const submitButton = page.getByTestId("signup-submit-button");
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
await checkbox.check();
await expect(submitButton).toBeEnabled();
// the cookie consent checkbox does not need to be checked for user to proceed
await checkbox.uncheck();
await expect(submitButton).toBeEnabled();
});
});