tests/e2e/auth-ui.spec.ts
Rama main · réplica de lectura del repositorio original.
// Browser coverage for the better-auth UI (/login, /signup, /forgot-password,
// /reset-password). The render tests are database-free; the journey tests
// mock nothing — the forms drive the real /api/auth handlers against real
// Neon rows — and self-skip when no connection string is resolvable.
import crypto from "node:crypto";
import { expect, test } from "@playwright/test";
import { Pool } from "pg";
import { resolveNeonDatabaseUrl } from "./helpers/neon";
import { seedPasswordUser } from "./helpers/password-user";
test.describe("auth pages render", () => {
for (const surface of [
{ path: "/login", fields: [{ id: "signin-password", label: "password" }] },
{
path: "/reset-password?token=visibility-preview",
fields: [
{ id: "reset-password-new", label: "new password" },
{ id: "reset-password-confirm", label: "confirm password" },
],
},
]) {
test(`${surface.path} lets users show and hide passwords without changing the value`, async ({
page,
}) => {
await page.goto(surface.path);
for (const field of surface.fields) {
const input = page.getByTestId(field.id);
await expect(input).toHaveAttribute("type", "password");
const show = page.getByRole("button", {
name: `Show ${field.label}`,
exact: true,
});
await expect(show).toBeVisible();
await input.fill("visibility-preview-123");
await show.click();
await expect(input).toHaveAttribute("type", "text");
await expect(input).toHaveValue("visibility-preview-123");
const hide = page.getByRole("button", {
name: `Hide ${field.label}`,
exact: true,
});
await expect(hide).toHaveAttribute("aria-pressed", "true");
await hide.press("Enter");
await expect(input).toHaveAttribute("type", "password");
await expect(input).toHaveValue("visibility-preview-123");
}
});
}
test("/login shows the sign-in surface with social and SSO options", async ({
page,
}) => {
await page.goto("/login");
await expect(page.getByTestId("signin-form")).toBeVisible();
await expect(page.getByTestId("signin-email")).toBeVisible();
await expect(page.getByTestId("signin-password")).toBeVisible();
await expect(page.getByTestId("auth-social-github")).toBeVisible();
await expect(page.getByTestId("auth-social-google")).toBeVisible();
await expect(page.getByTestId("auth-social-microsoft")).toBeVisible();
await expect(
page.getByRole("link", { name: "Use single sign-on (SSO) →" })
).toBeVisible();
await expect(
page.getByRole("link", { name: "Create an account" })
).toBeVisible();
await expect(
page.getByRole("link", { name: "Use the beta sign-in" })
).toHaveCount(0);
});
test("/signup offers social signup without email or password fields", async ({
page,
}) => {
await page.goto("/signup");
await expect(page.getByTestId("signup-form")).toHaveCount(0);
await expect(page.getByRole("textbox")).toHaveCount(0);
await expect(page.locator('input[type="password"]')).toHaveCount(0);
for (const provider of ["github", "google", "microsoft"]) {
await expect(page.getByTestId(`auth-social-${provider}`)).toBeVisible();
}
await expect(
page.getByRole("main").getByRole("link", { name: "Sign in" })
).toBeVisible();
});
for (const [query, next] of [
["?plan=plus", "/checkout?plan=plus"],
["?next=%2Fwebrenew%2Fcontrol", "/webrenew/control"],
["?next=https%3A%2F%2Fevil.example", "/"],
]) {
test(`social signup preserves a safe destination ${query}`, async ({
page,
}) => {
await page.addInitScript(() => {
window.__mogplexTrackedEvents = [];
});
await page.route("**/api/auth/sign-in/social", (route) =>
route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({ message: "Provider unavailable" }),
})
);
await page.goto(`/signup${query}`);
const request = page.waitForRequest("**/api/auth/sign-in/social");
await page.getByTestId("auth-social-github").click();
expect((await request).postDataJSON()).toMatchObject({
provider: "github",
callbackURL: next,
});
expect(
await page.evaluate(() => window.__mogplexTrackedEvents)
).toContainEqual({
name: "signup_submitted",
properties: { source: "signup_page", provider: "github" },
});
});
}
test("/forgot-password renders the reset request form", async ({ page }) => {
await page.goto("/forgot-password");
await expect(page.getByTestId("forgot-password-form")).toBeVisible();
await expect(page.getByTestId("forgot-password-email")).toBeVisible();
});
test("social login does not count as a signup submission", async ({
page,
}) => {
await page.addInitScript(() => {
window.__mogplexTrackedEvents = [];
});
await page.route("**/api/auth/sign-in/social", (route) => route.abort());
await page.goto("/login");
await page.getByTestId("auth-social-github").click();
await expect(page.getByTestId("auth-social-error")).toBeVisible();
const events = await page.evaluate(
() => window.__mogplexTrackedEvents ?? []
);
expect(events).toContainEqual({
name: "login_started",
properties: { source: "login_page", provider: "github" },
});
expect(events.some((event) => event.name === "signup_submitted")).toBe(
false
);
});
test("/reset-password without a token shows the invalid-link state", async ({
page,
}) => {
await page.goto("/reset-password");
await expect(page.getByTestId("reset-password-invalid")).toBeVisible();
await expect(
page.getByRole("link", { name: "Request a new reset link" })
).toBeVisible();
});
test("/login/sso shows the work-email form", async ({ page }) => {
await page.goto("/login/sso");
await expect(page.getByTestId("sso-form")).toBeVisible();
await expect(page.getByTestId("sso-email")).toBeVisible();
});
test("social sign-in surfaces a friendly error when the request fails", async ({
page,
}) => {
// Abort instead of mocking a response: locally the provider may be fully
// configured and the click would otherwise leave for github.com.
await page.route("**/api/auth/sign-in/social", (route) => route.abort());
await page.goto("/login");
await page.getByTestId("auth-social-github").click();
await expect(page.getByTestId("auth-social-error")).toBeVisible();
await expect(page.getByTestId("auth-social-error")).toContainText(
"Network error"
);
});
});
const databaseUrl = resolveNeonDatabaseUrl();
const password = "e2e-Ui-Password-1234";
function uniqueEmail(): string {
return `e2e-ui-${Date.now()}-${crypto.randomUUID().slice(0, 8)}@e2e.mogplex.test`;
}
test.describe("auth UI against real Neon", () => {
test.skip(
!databaseUrl,
"DATABASE_URL / mogplex_DATABASE_URL not resolvable — skipping real-database auth UI flows"
);
let pool: Pool;
const createdEmails: string[] = [];
test.beforeAll(() => {
pool = new Pool({ connectionString: databaseUrl, max: 1 });
});
test.afterAll(async () => {
// session/account/verification rows cascade from the user delete; the
// profile row provisioned by the user-create hook has no FK and is
// removed explicitly first.
if (createdEmails.length > 0) {
await pool.query(
`delete from profiles where auth_user_id in
(select id from "user" where email = any($1))`,
[createdEmails]
);
await pool.query('delete from "user" where email = any($1)', [
createdEmails,
]);
}
await pool.end();
});
test("existing password user: verification gate, sign-in, sign-out", async ({
page,
}) => {
// Fresh fixture per attempt keeps CI retries independent.
const email = uniqueEmail();
createdEmails.push(email);
await seedPasswordUser(pool, { email, password, verified: false });
// requireEmailVerification gates the sign-in form until verified.
await page.goto("/login");
await page.getByTestId("signin-email").fill(email);
await page.getByTestId("signin-password").fill(password);
await page.getByTestId("signin-submit").click();
await expect(page.getByTestId("signin-error")).toContainText(
"Verify your email"
);
// Verify out-of-band (the emailed link is the production path).
await pool.query(
'update "user" set "emailVerified" = true where email = $1',
[email]
);
await page.getByTestId("signin-password").fill("definitely-not-it-1234");
await page.getByTestId("signin-submit").click();
await expect(page.getByTestId("signin-error")).toContainText(
"Invalid email or password"
);
await page.getByTestId("signin-password").fill(password);
await page.getByTestId("signin-submit").click();
// Successful sign-in leaves for "/" and the proxy bounces the now-authed
// user into their scope's control surface.
await page.waitForURL(/\/control$/);
// Existing password accounts still resolve to their app profiles.
if (process.env.MOGPLEX_DATA_BACKEND === "neon") {
const profile = await pool.query(
`select id, slug from profiles where auth_user_id =
(select id from "user" where email = $1)`,
[email]
);
expect(profile.rowCount).toBe(1);
expect(String(profile.rows[0].slug)).toMatch(/^e2e-ui-/);
const authUser = await page.request.get("/api/auth/user");
expect(authUser.status(), await authUser.text()).toBe(200);
const authUserBody = (await authUser.json()) as {
user?: { id?: string; email?: string } | null;
};
expect(authUserBody.user?.email).toBe(email);
expect(authUserBody.user?.id).toBe(String(profile.rows[0].id));
}
// Back on /login the session-aware panel replaces the form.
await page.goto("/login");
await expect(page.getByTestId("signed-in-panel")).toBeVisible();
await expect(page.getByTestId("signed-in-panel")).toContainText(email);
await page.getByTestId("signout-button").click();
await expect(page.getByTestId("signin-form")).toBeVisible();
});
test("password reset journey: request link, set new password, sign in", async ({
page,
}) => {
const email = uniqueEmail();
createdEmails.push(email);
const newPassword = "e2e-Ui-NewPassword-5678";
const userId = await seedPasswordUser(pool, { email, password });
await page.goto("/forgot-password");
await page.getByTestId("forgot-password-email").fill(email);
await page.getByTestId("forgot-password-submit").click();
await expect(page.getByTestId("forgot-password-sent")).toBeVisible();
// The reset email is log-delivered in e2e; recover the token from the
// verification row better-auth wrote (identifier reset-password:<token>).
const verification = await pool.query(
`select identifier from "verification"
where identifier like 'reset-password:%' and value = $1
order by "createdAt" desc limit 1`,
[userId]
);
expect(verification.rowCount).toBe(1);
const token = (verification.rows[0].identifier as string).replace(
"reset-password:",
""
);
await page.goto(`/reset-password?token=${encodeURIComponent(token)}`);
await page.getByTestId("reset-password-new").fill(newPassword);
await page.getByTestId("reset-password-confirm").fill(newPassword);
await page.getByTestId("reset-password-submit").click();
await page.waitForURL("/login?reset=1");
await expect(page.getByText("password updated — sign in")).toBeVisible();
// The new password signs in; the old one no longer does.
await page.getByTestId("signin-email").fill(email);
await page.getByTestId("signin-password").fill(password);
await page.getByTestId("signin-submit").click();
await expect(page.getByTestId("signin-error")).toContainText(
"Invalid email or password"
);
await page.getByTestId("signin-password").fill(newPassword);
await page.getByTestId("signin-submit").click();
await page.waitForURL(/\/control$/);
});
});