diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go index 60ff2d3..15cd267 100644 --- a/internal/api/auth_handlers.go +++ b/internal/api/auth_handlers.go @@ -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 { diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 5525fc7..0ee8210 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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. diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx index eca92f1..30052db 100644 --- a/web/src/pages/LoginPage.test.tsx +++ b/web/src/pages/LoginPage.test.tsx @@ -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() diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index a9bb91e..7a89ff7 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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 +// 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(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 @@ -47,8 +71,8 @@ export function LoginPage() { setAuthError(null) try { await login(values.email, values.password) - } catch { - setAuthError("Неверный email или пароль") + } catch (err) { + setAuthError(describeLoginError(err)) } } diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx index 3adc8ac..abf7ea2 100644 --- a/web/src/pages/RegisterPage.tsx +++ b/web/src/pages/RegisterPage.tsx @@ -24,8 +24,22 @@ const registerSchema = z.object({ type RegisterForm = z.infer +// 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(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 @@ -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)) } }