* 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
461 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|