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
+6
View File
@@ -60,6 +60,12 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "email and password are required")
return
}
// Server-side minimum length is the source of truth: the client-side
// zod min(8) check is UX only and can be bypassed with a direct POST.
if len(req.Password) < 8 {
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
+36
View File
@@ -165,6 +165,42 @@ func TestAuthRegister_NormalizesEmail(t *testing.T) {
}
}
// TestAuthRegister_ShortPasswordReturns400 verifies the server-side password
// length floor: the client's zod min(8) is UX only and can be bypassed with a
// direct POST, so the handler itself must reject a password under 8 chars
// before ever calling RegisterUser.
func TestAuthRegister_ShortPasswordReturns400(t *testing.T) {
a, authStore, _ := newTestAuthAPI()
registerCalled := false
authStore.registerUserFn = func(context.Context, string, string) (store.User, store.Project, error) {
registerCalled = true
return store.User{}, store.Project{}, nil
}
router := NewRouter(a)
body := `{"email":"alice@example.com","password":"short"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
var got map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["error"] != "password must be at least 8 characters" {
t.Fatalf(`expected error "password must be at least 8 characters", got %q`, got["error"])
}
if registerCalled {
t.Fatal("expected RegisterUser not to be called for a too-short password")
}
if findCookie(w.Result(), sessionCookieName) != nil {
t.Fatal("expected no session cookie on rejected register")
}
}
// TestAuthRegister_DuplicateEmailReturns409 verifies the fix for the
// duplicate-registration gap: RegisterUser reporting store.ErrEmailTaken
// must surface as 409, not a generic 500.
+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))
}
}