deer-flow/frontend/tests/unit/components/auth/password-forms.dom.test.tsx
ShaoLOWu 21fb080530
fix(frontend): allow non-ASCII local parts in auth password forms (#7488) (#7498)
* fix(frontend): allow non-ASCII local parts in auth password forms (#7488)

The sign-in, register, admin-setup and change-password forms relied on the
browser's type="email" constraint validation, which rejects any non-ASCII
local part before the submit handler runs. The backend identity schema
deliberately accepts such addresses (see emailSchema in @/core/auth/types),
so an account created via the API, an administrator or OIDC could never sign
in through the password form.

Forms are now noValidate, and the permissive isValidEmail helper (one @,
non-empty local part and domain, no whitespace) runs in the handlers instead,
staying strictly weaker than what the backend accepts.

* fix(auth): normalize emails and restore form password checks

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 22:09:58 +08:00

230 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { createContext } from "react";
import LoginPage from "@/app/(auth)/login/page";
import SetupPage from "@/app/(auth)/setup/page";
import { enUS } from "@/core/i18n/locales/en-US";
const router = { push: rs.fn(), replace: rs.fn() };
type Mode = "login" | "register" | "initialize" | "change-password";
const modes: Mode[] = ["login", "register", "initialize", "change-password"];
let mode: Mode = "login";
rs.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => new URLSearchParams(),
}));
rs.mock("next-themes", () => ({
useTheme: () => ({ theme: "light", resolvedTheme: "light" }),
}));
rs.mock("@/components/ui/flickering-grid", () => ({
FlickeringGrid: () => null,
}));
rs.mock("@/core/auth/AuthProvider", () => ({
AuthDisabledContext: createContext(false),
useAuth: () => ({
isAuthenticated: mode === "change-password",
user: mode === "change-password" ? { needs_setup: true } : null,
}),
}));
rs.mock("@/core/api/fetcher", () => ({ getCsrfHeaders: () => ({}) }));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) }));
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
headers: { "Content-Type": "application/json" },
});
const fetchMock = rs.fn<typeof fetch>();
const posts = () =>
fetchMock.mock.calls.filter(([, init]) => init?.method === "POST");
beforeEach(() => {
mode = "login";
localStorage.clear();
router.push.mockReset();
router.replace.mockReset();
fetchMock.mockReset();
fetchMock.mockImplementation(async (input, init) => {
if (input === "/api/v1/auth/setup-status") {
return jsonResponse({
needs_setup: mode === "initialize",
registration_enabled: true,
});
}
if (input === "/api/v1/auth/providers")
return jsonResponse({ providers: [] });
if (init?.method === "POST") return jsonResponse({});
throw new Error("Unexpected auth request");
});
rs.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
cleanup();
localStorage.clear();
rs.unstubAllGlobals();
});
async function mountForm(
nextMode: Mode,
email: string,
password = " secret9 ",
) {
mode = nextMode;
if (mode === "login") {
localStorage.setItem("deerflow.auth.remembered_email", email);
}
render(
mode === "login" || mode === "register" ? <LoginPage /> : <SetupPage />,
);
if (mode === "register") {
fireEvent.click(
await screen.findByRole("button", { name: enUS.login.noAccountSignUp }),
);
}
const addressInput =
mode === "change-password"
? await screen.findByPlaceholderText("Your email")
: await screen.findByLabelText("Email");
// Preserve a remembered raw value in React state; type=email sanitizes
// typed outer spaces before onChange and would hide the submission bug.
if (mode !== "login")
fireEvent.change(addressInput, { target: { value: email } });
const passwordInput =
mode === "change-password"
? screen.getByPlaceholderText("New password")
: screen.getByLabelText("Password");
fireEvent.change(passwordInput, { target: { value: password } });
if (mode === "initialize" || mode === "change-password") {
fireEvent.change(
mode === "initialize"
? screen.getByLabelText("Confirm Password")
: screen.getByPlaceholderText("Confirm new password"),
{ target: { value: password } },
);
}
if (mode === "change-password") {
fireEvent.change(screen.getByPlaceholderText("Current password"), {
target: { value: "current-password" },
});
}
const form = addressInput.closest("form")!;
expect(form.noValidate).toBe(true);
return form;
}
test.each(modes)(
"%s submits the same trimmed Unicode address it validates",
async (formMode) => {
const password = " safe ";
const form = await mountForm(formMode, " 用户@example.com ", password);
await act(async () => {
fireEvent.submit(form);
});
await waitFor(() => expect(router.push).toHaveBeenCalledWith("/workspace"));
expect(posts()).toHaveLength(1);
const [endpoint, request] = posts()[0]!;
if (formMode === "login") {
expect(endpoint).toBe("/api/v1/auth/login/local");
expect(request!.body).toBeInstanceOf(URLSearchParams);
const body = request!.body as URLSearchParams;
expect(body.get("username")).toBe("用户@example.com");
expect(body.get("password")).toBe(password);
expect(localStorage.getItem("deerflow.auth.remembered_email")).toBe(
"用户@example.com",
);
} else {
expect(typeof request!.body).toBe("string");
const body = JSON.parse(request!.body as string) as Record<
string,
unknown
>;
expect(endpoint).toBe(
`/api/v1/auth/${formMode === "register" ? "register" : formMode === "initialize" ? "initialize" : "change-password"}`,
);
expect(body[formMode === "change-password" ? "new_email" : "email"]).toBe(
"用户@example.com",
);
expect(
body[formMode === "change-password" ? "new_password" : "password"],
).toBe(password);
}
},
);
const invalidPasswords: [Mode, string][] = modes.flatMap((formMode) => [
[formMode, ""],
[formMode, formMode === "login" ? "12345" : "1234567"],
]);
test.each(invalidPasswords)(
"%s blocks password %j before making a request",
async (formMode, password) => {
const form = await mountForm(formMode, "用户@example.com", password);
await act(async () => {
fireEvent.submit(form);
});
expect(posts()).toHaveLength(0);
expect(screen.getByText(/Password must be at least/)).toBeTruthy();
expect(router.push).not.toHaveBeenCalled();
},
);
test.each(modes)(
"%s rejects malformed email before making a request",
async (formMode) => {
const form = await mountForm(formMode, "not-an-email");
await act(async () => {
fireEvent.submit(form);
});
expect(posts()).toHaveLength(0);
expect(screen.getByText(/Please enter a valid email address/)).toBeTruthy();
},
);
test("login keeps supporting a six-character password", async () => {
const form = await mountForm("login", "用户@example.com", "secret");
await act(async () => {
fireEvent.submit(form);
});
await waitFor(() => expect(router.push).toHaveBeenCalledWith("/workspace"));
expect(posts()).toHaveLength(1);
});
test("password change requires the current password before making a request", async () => {
const form = await mountForm("change-password", "用户@example.com");
fireEvent.change(screen.getByPlaceholderText("Current password"), {
target: { value: "" },
});
await act(async () => {
fireEvent.submit(form);
});
expect(posts()).toHaveLength(0);
expect(screen.getByText("Please enter your current password")).toBeTruthy();
});
test.each(["initialize", "change-password"] as const)(
"%s still rejects a password confirmation mismatch",
async (formMode) => {
const form = await mountForm(formMode, "用户@example.com");
fireEvent.change(
formMode === "initialize"
? screen.getByLabelText("Confirm Password")
: screen.getByPlaceholderText("Confirm new password"),
{ target: { value: "different-password" } },
);
await act(async () => {
fireEvent.submit(form);
});
expect(posts()).toHaveLength(0);
expect(screen.getByText("Passwords do not match")).toBeTruthy();
},
);