mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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>
This commit is contained in:
parent
79399d0c22
commit
21fb080530
@ -633,6 +633,11 @@ The bundled Docker, local and Helm nginx configs proxy Live Browser WebSocket up
|
||||
|
||||
Browser login uses `HttpOnly` session cookies. The login page offers a "keep me signed in" option that extends the browser session when the request is HTTPS (including trusted `X-Forwarded-Proto: https`) or localhost HTTP. The localhost exception uses the direct request `Host` and ignores forwarded host headers. Public HTTP deployments, including many temporary sandbox URLs, fall back to session cookies by default. DeerFlow never stores the password in browser storage; the UI may remember only the email address.
|
||||
|
||||
Password forms accept non-ASCII email addresses and trim surrounding email whitespace.
|
||||
Sign-in requires at least six password characters; creating an account or choosing
|
||||
a new password requires eight. These checks run before sending a request.
|
||||
Passwords, including surrounding spaces, are submitted unchanged.
|
||||
|
||||
OIDC/SSO authorization endpoints may include provider query parameters such as `?prompt=login`. DeerFlow preserves those parameters when adding the login request, including for configured `authorization_endpoint` overrides.
|
||||
|
||||
Administrators can suspend and restore accounts from **Settings → Users** (admin-only; the section is hidden for ordinary users). The page lists each account's email, role, and status and exposes the same enable/disable operation as `PATCH /api/v1/admin/users/{id}`; the last remaining active admin cannot be disabled, and the Gateway's rejection detail is shown verbatim. A disabled account is rejected by every authentication surface, and its live sessions end on the next request with a login page that states the account has been disabled. Password login for a disabled account deliberately still answers "incorrect email or password" so the state is not disclosed to whoever holds the credentials.
|
||||
|
||||
@ -113,6 +113,8 @@ Confirmed logout unmounts query consumers immediately; public and unknown-auth
|
||||
routes still render with an independent client. Keep auth outside the query
|
||||
provider. Superseded auth refreshes cannot restore an earlier owner.
|
||||
|
||||
Auth: trim emails; validate raw passwords.
|
||||
|
||||
See [project structure](README.md#project-structure) for the directory map and
|
||||
[src/AGENTS.md](src/AGENTS.md) for component and domain ownership.
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AuthDisabledContext, useAuth } from "@/core/auth/AuthProvider";
|
||||
import { isValidEmail } from "@/core/auth/email";
|
||||
import { resolveAuthNextPath } from "@/core/auth/next-path";
|
||||
import {
|
||||
loadRememberLoginPreference,
|
||||
@ -151,14 +152,31 @@ export default function LoginPage() {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setShowSsoHint(false);
|
||||
setLoading(true);
|
||||
|
||||
if (!isLogin && !regularSignupAllowed) {
|
||||
setError(t.login.adminSetupRequiredDescription);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The form is `noValidate`, so the browser no longer rejects addresses
|
||||
// its `type="email"` rule dislikes (e.g. a non-ASCII local part, which
|
||||
// the backend accepts — #7488). Apply the permissive JS check instead.
|
||||
const address = email.trim();
|
||||
if (!isValidEmail(address)) {
|
||||
setError(t.login.errors.invalid_email);
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < (isLogin ? 6 : 8)) {
|
||||
setError(
|
||||
isLogin
|
||||
? t.login.errors.password_too_short
|
||||
: t.login.errors.registration_password_too_short,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = isLogin
|
||||
? "/api/v1/auth/login/local"
|
||||
@ -167,9 +185,9 @@ export default function LoginPage() {
|
||||
? new URLSearchParams({
|
||||
password,
|
||||
remember_me: String(rememberMe),
|
||||
username: email,
|
||||
username: address,
|
||||
})
|
||||
: JSON.stringify({ email, password, remember_me: rememberMe });
|
||||
: JSON.stringify({ email: address, password, remember_me: rememberMe });
|
||||
|
||||
const headers: HeadersInit = isLogin
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
@ -202,7 +220,7 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
saveRememberLoginPreference({ email, rememberMe });
|
||||
saveRememberLoginPreference({ email: address, rememberMe });
|
||||
|
||||
// Both login and register set a cookie — redirect to workspace
|
||||
router.push(redirectPath);
|
||||
@ -276,7 +294,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
{t.login.email}
|
||||
|
||||
@ -10,6 +10,7 @@ import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getCsrfHeaders } from "@/core/api/fetcher";
|
||||
import { useAuth } from "@/core/auth/AuthProvider";
|
||||
import { isValidEmail } from "@/core/auth/email";
|
||||
import { loadRememberLoginPreference } from "@/core/auth/remember-login";
|
||||
import {
|
||||
fetchSetupStatus,
|
||||
@ -78,10 +79,22 @@ export default function SetupPage() {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// `noValidate` + JS check: the browser's `type="email"` rule would reject
|
||||
// addresses the backend accepts, e.g. a non-ASCII local part (#7488).
|
||||
const address = email.trim();
|
||||
if (!isValidEmail(address)) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -90,7 +103,7 @@ export default function SetupPage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
email: address,
|
||||
password: newPassword,
|
||||
remember_me: rememberMe,
|
||||
}),
|
||||
@ -120,6 +133,13 @@ export default function SetupPage() {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// The address field also carries the account's existing email here, so
|
||||
// the same permissive check applies (#7488).
|
||||
const address = email.trim();
|
||||
if (!isValidEmail(address)) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
@ -129,6 +149,11 @@ export default function SetupPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentPassword) {
|
||||
setError("Please enter your current password");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/auth/change-password", {
|
||||
@ -141,7 +166,7 @@ export default function SetupPage() {
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
new_email: email || undefined,
|
||||
new_email: address,
|
||||
remember_me: rememberMe,
|
||||
}),
|
||||
});
|
||||
@ -226,7 +251,7 @@ export default function SetupPage() {
|
||||
Set up the administrator account to get started.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleInitAdmin} className="space-y-2">
|
||||
<form onSubmit={handleInitAdmin} noValidate className="space-y-2">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
@ -303,7 +328,7 @@ export default function SetupPage() {
|
||||
Set your real email and a new password.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<form onSubmit={handleChangePassword} noValidate className="space-y-4">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
|
||||
32
frontend/src/core/auth/email.ts
Normal file
32
frontend/src/core/auth/email.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Permissive email syntax check used by the auth password forms.
|
||||
* Surrounding whitespace is accepted; callers must submit the trimmed address.
|
||||
*
|
||||
* The backend identity schema deliberately accepts addresses that stricter
|
||||
* validators reject — see `emailSchema` in `@/core/auth/types`, which is a
|
||||
* bare `z.string().min(1)` because API-created accounts, administrators and
|
||||
* OIDC identities may carry addresses such as a non-ASCII local part.
|
||||
*
|
||||
* The browser's `type="email"` constraint validation, however, rejects any
|
||||
* non-ASCII local part before the form's submit handler ever runs, so an
|
||||
* account like `用户@example.com` could never sign in through the password
|
||||
* form even though the auth API happily issues a session for it (#7488).
|
||||
*
|
||||
* The auth forms therefore disable native constraint validation (`noValidate`)
|
||||
* and apply this check in JS instead. It must stay strictly weaker than what
|
||||
* the backend accepts: exactly one "@", a non-empty local part and domain,
|
||||
* and no whitespace. Anything beyond that is left to the backend, which is
|
||||
* the authority on which addresses can hold an account.
|
||||
*/
|
||||
export function isValidEmail(value: string): boolean {
|
||||
const address = value.trim();
|
||||
const at = address.indexOf("@");
|
||||
if (at <= 0 || at !== address.lastIndexOf("@")) {
|
||||
return false;
|
||||
}
|
||||
const domain = address.slice(at + 1);
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
return !/\s/.test(address);
|
||||
}
|
||||
@ -2565,6 +2565,10 @@ export const enUS: Translations = {
|
||||
"SSO login is not allowed for your account. Contact your administrator.",
|
||||
account_disabled:
|
||||
"This account has been disabled. Contact your administrator.",
|
||||
invalid_email: "Please enter a valid email address.",
|
||||
password_too_short: "Password must be at least 6 characters.",
|
||||
registration_password_too_short:
|
||||
"Password must be at least 8 characters.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -2055,6 +2055,9 @@ export interface Translations {
|
||||
sso_account_exists: string;
|
||||
sso_not_allowed: string;
|
||||
account_disabled: string;
|
||||
invalid_email: string;
|
||||
password_too_short: string;
|
||||
registration_password_too_short: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -2406,6 +2406,9 @@ export const zhCN: Translations = {
|
||||
"该邮箱对应的账号已存在。请使用密码登录或联系管理员。",
|
||||
sso_not_allowed: "你的账号不允许使用 SSO 登录。请联系管理员。",
|
||||
account_disabled: "账号已停用,请联系管理员。",
|
||||
invalid_email: "请输入有效的邮箱地址。",
|
||||
password_too_short: "密码至少需要 6 个字符。",
|
||||
registration_password_too_short: "密码至少需要 8 个字符。",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
229
frontend/tests/unit/components/auth/password-forms.dom.test.tsx
Normal file
229
frontend/tests/unit/components/auth/password-forms.dom.test.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
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();
|
||||
},
|
||||
);
|
||||
36
frontend/tests/unit/core/auth/email.test.ts
Normal file
36
frontend/tests/unit/core/auth/email.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "@rstest/core";
|
||||
|
||||
import { isValidEmail } from "@/core/auth/email";
|
||||
|
||||
// The backend identity schema deliberately accepts addresses stricter
|
||||
// validators reject (`emailSchema` in @/core/auth/types is a bare
|
||||
// `z.string().min(1)`). The auth forms used to hand the decision to the
|
||||
// browser's `type="email"` constraint instead, which rejects any non-ASCII
|
||||
// local part before the submit handler runs — an account like
|
||||
// `用户@example.com` (creatable via the API, an admin, or OIDC) could never
|
||||
// sign in through the password form (#7488).
|
||||
describe("auth email validation", () => {
|
||||
test("accepts addresses the backend accepts, including non-ASCII local parts", () => {
|
||||
expect(isValidEmail("用户@example.com")).toBe(true);
|
||||
expect(isValidEmail("wíérd@name.example")).toBe(true);
|
||||
expect(isValidEmail("someone@example.com")).toBe(true);
|
||||
expect(isValidEmail("a@b")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts surrounding whitespace (trimmed before the check)", () => {
|
||||
expect(isValidEmail(" user@example.com ")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects values without a usable local@domain shape", () => {
|
||||
expect(isValidEmail("not-an-email")).toBe(false);
|
||||
expect(isValidEmail("@example.com")).toBe(false);
|
||||
expect(isValidEmail("user@")).toBe(false);
|
||||
expect(isValidEmail("a@b@c.example")).toBe(false);
|
||||
expect(isValidEmail("a b@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty and blank input", () => {
|
||||
expect(isValidEmail("")).toBe(false);
|
||||
expect(isValidEmail(" ")).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user