fix(auth): серверная проверка длины пароля, loading-guard и различение ошибок на auth-страницах
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
@@ -38,7 +38,9 @@ describe("LoginPage", () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), "a@b.com")
|
||||
// AuthProvider resolves the session check (api.auth.me) asynchronously;
|
||||
// the form only renders once loading flips to false.
|
||||
await user.type(await screen.findByLabelText(/email/i), "a@b.com")
|
||||
await user.type(screen.getByLabelText(/пароль/i), "secret123")
|
||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||
|
||||
@@ -52,13 +54,44 @@ describe("LoginPage", () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), "a@b.com")
|
||||
await user.type(await screen.findByLabelText(/email/i), "a@b.com")
|
||||
await user.type(screen.getByLabelText(/пароль/i), "wrong-password")
|
||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Неверный email или пароль")
|
||||
})
|
||||
|
||||
it("не рендерит форму логина, пока сессия (api.auth.me) не резолвнута", async () => {
|
||||
let rejectMe!: (err: unknown) => void
|
||||
vi.spyOn(api.auth, "me").mockImplementation(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectMe = reject
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
|
||||
expect(screen.queryByRole("button", { name: /войти/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument()
|
||||
|
||||
// Resolve the pending me() so the test doesn't leak an unhandled rejection.
|
||||
await act(async () => {
|
||||
rejectMe(new UnauthorizedError())
|
||||
})
|
||||
})
|
||||
|
||||
it("сетевая ошибка при логине показывает «Сервис недоступен»", async () => {
|
||||
vi.spyOn(api.auth, "login").mockRejectedValue(new TypeError("Failed to fetch"))
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await user.type(await screen.findByLabelText(/email/i), "a@b.com")
|
||||
await user.type(screen.getByLabelText(/пароль/i), "wrong-password")
|
||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Сервис недоступен, попробуйте позже")
|
||||
})
|
||||
|
||||
it("содержит ссылку на регистрацию", async () => {
|
||||
renderPage()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod"
|
||||
import { Link, Navigate } from "react-router-dom"
|
||||
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
|
||||
import { useAuth } from "@/auth/AuthContext"
|
||||
import { UnauthorizedError } from "@/api/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
@@ -24,8 +25,27 @@ const loginSchema = z.object({
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>
|
||||
|
||||
// describeLoginError turns a login() rejection into user-facing Russian
|
||||
// copy. A network failure (TypeError from fetch itself) or a 5xx response
|
||||
// means the service is unreachable/broken — that's a different situation
|
||||
// from wrong credentials and should say so. Everything else (401
|
||||
// UnauthorizedError, or a backend "invalid credentials" message) reads as a
|
||||
// bad email/password.
|
||||
function describeLoginError(err: unknown): string {
|
||||
const isNetworkOrServerError =
|
||||
err instanceof TypeError || (err instanceof Error && err.message.startsWith("HTTP 5"))
|
||||
if (isNetworkOrServerError) return "Сервис недоступен, попробуйте позже"
|
||||
|
||||
const isInvalidCredentials =
|
||||
err instanceof UnauthorizedError ||
|
||||
(err instanceof Error && /invalid credentials/i.test(err.message))
|
||||
if (isInvalidCredentials) return "Неверный email или пароль"
|
||||
|
||||
return "Неверный email или пароль"
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const { user, login } = useAuth()
|
||||
const { user, loading, login } = useAuth()
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const emailFieldId = useId()
|
||||
const passwordFieldId = useId()
|
||||
@@ -39,6 +59,10 @@ export function LoginPage() {
|
||||
defaultValues: { email: "", password: "" },
|
||||
})
|
||||
|
||||
// Session check (api.auth.me()) hasn't resolved yet — don't flash the
|
||||
// login form for a visitor who turns out to already have a valid session.
|
||||
if (loading) return null
|
||||
|
||||
// Already authenticated (fresh session on mount, or just logged in below) —
|
||||
// don't show the login form, go straight to the app.
|
||||
if (user) return <Navigate to="/domains" replace />
|
||||
@@ -47,8 +71,8 @@ export function LoginPage() {
|
||||
setAuthError(null)
|
||||
try {
|
||||
await login(values.email, values.password)
|
||||
} catch {
|
||||
setAuthError("Неверный email или пароль")
|
||||
} catch (err) {
|
||||
setAuthError(describeLoginError(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,22 @@ const registerSchema = z.object({
|
||||
|
||||
type RegisterForm = z.infer<typeof registerSchema>
|
||||
|
||||
// describeRegisterError turns a register() rejection into user-facing
|
||||
// Russian copy. A network failure (TypeError from fetch itself) or a 5xx
|
||||
// response means the service is unreachable/broken, not a validation
|
||||
// problem — surface that distinctly instead of an opaque "HTTP 500". Any
|
||||
// other error (409 email taken, 400 password too short, etc.) already
|
||||
// carries a specific backend message worth showing as-is.
|
||||
function describeRegisterError(err: unknown): string {
|
||||
const isNetworkOrServerError =
|
||||
err instanceof TypeError || (err instanceof Error && err.message.startsWith("HTTP 5"))
|
||||
if (isNetworkOrServerError) return "Сервис недоступен, попробуйте позже"
|
||||
|
||||
return err instanceof Error ? err.message : "Не удалось зарегистрироваться"
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const { user, register: registerUser } = useAuth()
|
||||
const { user, loading, register: registerUser } = useAuth()
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const emailFieldId = useId()
|
||||
const passwordFieldId = useId()
|
||||
@@ -39,6 +53,11 @@ export function RegisterPage() {
|
||||
defaultValues: { email: "", password: "" },
|
||||
})
|
||||
|
||||
// Session check (api.auth.me()) hasn't resolved yet — don't flash the
|
||||
// registration form for a visitor who turns out to already have a valid
|
||||
// session.
|
||||
if (loading) return null
|
||||
|
||||
// Already authenticated — skip straight to the app instead of showing the
|
||||
// registration form again.
|
||||
if (user) return <Navigate to="/domains" replace />
|
||||
@@ -48,7 +67,7 @@ export function RegisterPage() {
|
||||
try {
|
||||
await registerUser(values.email, values.password)
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться")
|
||||
setAuthError(describeRegisterError(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user