fix(auth): серверная проверка длины пароля, loading-guard и различение ошибок на auth-страницах

This commit is contained in:
2026-07-03 21:33:03 +07:00
parent 5a4d560e70
commit 901eb51e2a
5 changed files with 126 additions and 8 deletions
+36 -3
View File
@@ -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()
+27 -3
View File
@@ -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))
}
}
+21 -2
View File
@@ -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))
}
}