fix(auth): серверная проверка длины пароля, loading-guard и различение ошибок на auth-страницах
This commit is contained in:
@@ -60,6 +60,12 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusBadRequest, "email and password are required")
|
writeErr(w, http.StatusBadRequest, "email and password are required")
|
||||||
return
|
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)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
// TestAuthRegister_DuplicateEmailReturns409 verifies the fix for the
|
||||||
// duplicate-registration gap: RegisterUser reporting store.ErrEmailTaken
|
// duplicate-registration gap: RegisterUser reporting store.ErrEmailTaken
|
||||||
// must surface as 409, not a generic 500.
|
// must surface as 409, not a generic 500.
|
||||||
|
|||||||
@@ -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 userEvent from "@testing-library/user-event"
|
||||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
@@ -38,7 +38,9 @@ describe("LoginPage", () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
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.type(screen.getByLabelText(/пароль/i), "secret123")
|
||||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||||
|
|
||||||
@@ -52,13 +54,44 @@ describe("LoginPage", () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
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.type(screen.getByLabelText(/пароль/i), "wrong-password")
|
||||||
await user.click(screen.getByRole("button", { name: /войти/i }))
|
await user.click(screen.getByRole("button", { name: /войти/i }))
|
||||||
|
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Неверный email или пароль")
|
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 () => {
|
it("содержит ссылку на регистрацию", async () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from "zod"
|
|||||||
import { Link, Navigate } from "react-router-dom"
|
import { Link, Navigate } from "react-router-dom"
|
||||||
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
|
import { KeyRound, Loader2, LogIn, SquareTerminal } from "lucide-react"
|
||||||
import { useAuth } from "@/auth/AuthContext"
|
import { useAuth } from "@/auth/AuthContext"
|
||||||
|
import { UnauthorizedError } from "@/api/client"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +25,27 @@ const loginSchema = z.object({
|
|||||||
|
|
||||||
type LoginForm = z.infer<typeof loginSchema>
|
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() {
|
export function LoginPage() {
|
||||||
const { user, login } = useAuth()
|
const { user, loading, login } = useAuth()
|
||||||
const [authError, setAuthError] = useState<string | null>(null)
|
const [authError, setAuthError] = useState<string | null>(null)
|
||||||
const emailFieldId = useId()
|
const emailFieldId = useId()
|
||||||
const passwordFieldId = useId()
|
const passwordFieldId = useId()
|
||||||
@@ -39,6 +59,10 @@ export function LoginPage() {
|
|||||||
defaultValues: { email: "", password: "" },
|
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) —
|
// Already authenticated (fresh session on mount, or just logged in below) —
|
||||||
// don't show the login form, go straight to the app.
|
// don't show the login form, go straight to the app.
|
||||||
if (user) return <Navigate to="/domains" replace />
|
if (user) return <Navigate to="/domains" replace />
|
||||||
@@ -47,8 +71,8 @@ export function LoginPage() {
|
|||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
try {
|
try {
|
||||||
await login(values.email, values.password)
|
await login(values.email, values.password)
|
||||||
} catch {
|
} catch (err) {
|
||||||
setAuthError("Неверный email или пароль")
|
setAuthError(describeLoginError(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,22 @@ const registerSchema = z.object({
|
|||||||
|
|
||||||
type RegisterForm = z.infer<typeof registerSchema>
|
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() {
|
export function RegisterPage() {
|
||||||
const { user, register: registerUser } = useAuth()
|
const { user, loading, register: registerUser } = useAuth()
|
||||||
const [authError, setAuthError] = useState<string | null>(null)
|
const [authError, setAuthError] = useState<string | null>(null)
|
||||||
const emailFieldId = useId()
|
const emailFieldId = useId()
|
||||||
const passwordFieldId = useId()
|
const passwordFieldId = useId()
|
||||||
@@ -39,6 +53,11 @@ export function RegisterPage() {
|
|||||||
defaultValues: { email: "", password: "" },
|
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
|
// Already authenticated — skip straight to the app instead of showing the
|
||||||
// registration form again.
|
// registration form again.
|
||||||
if (user) return <Navigate to="/domains" replace />
|
if (user) return <Navigate to="/domains" replace />
|
||||||
@@ -48,7 +67,7 @@ export function RegisterPage() {
|
|||||||
try {
|
try {
|
||||||
await registerUser(values.email, values.password)
|
await registerUser(values.email, values.password)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAuthError(err instanceof Error ? err.message : "Не удалось зарегистрироваться")
|
setAuthError(describeRegisterError(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user