package handlers import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "building-mgmt/internal/auth" "building-mgmt/internal/ratelimit" ) // newAuthPostContext — контекст POST с JSON-телом к auth-хендлеру // (newPostContext из users_test.go тоже подошёл бы, но путь другой). func newAuthPostContext(t *testing.T, path, body string) (*gin.Context, *httptest.ResponseRecorder) { t.Helper() gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") return c, w } // --- refresh-кука: атрибуты безопасности --- // TestRefreshCookieFlags — кука ровно такая, как задумано: HttpOnly // (JS не читает), SameSite=Lax (CSRF), Path=/auth (только к эндпоинтам // сессии), Secure ровно по COOKIE_SECURE (браузер отверг бы Secure-куку // по HTTP). func TestRefreshCookieFlags(t *testing.T) { for _, tc := range []struct { name string secure bool }{ {"локально без Secure", false}, {"в проде за TLS — Secure", true}, } { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/auth/login", nil) h := &AuthHandler{cookieSecure: tc.secure} h.setRefreshCookie(c, "secret-value") cookies := w.Result().Cookies() if len(cookies) != 1 { t.Fatalf("кук в ответе %d, want 1", len(cookies)) } ck := cookies[0] if ck.Name != "refresh_token" { t.Errorf("Name = %q", ck.Name) } if ck.Value != "secret-value" { t.Errorf("Value = %q", ck.Value) } if !ck.HttpOnly { t.Error("кука не HttpOnly — XSS утащит сессию") } if ck.Secure != tc.secure { t.Errorf("Secure = %v, want %v", ck.Secure, tc.secure) } if ck.Path != "/auth" { t.Errorf("Path = %q, want /auth", ck.Path) } if ck.SameSite != http.SameSiteLaxMode { t.Errorf("SameSite = %v, want Lax", ck.SameSite) } if want := int(auth.RefreshTTL.Seconds()); ck.MaxAge != want { t.Errorf("MaxAge = %d, want %d", ck.MaxAge, want) } }) } } // TestClearRefreshCookie — logout ставит пустую куку с MaxAge<0: // браузер удаляет её немедленно, а не по истечении срока. func TestClearRefreshCookie(t *testing.T) { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/auth/logout", nil) h := &AuthHandler{} h.clearRefreshCookie(c) cookies := w.Result().Cookies() if len(cookies) != 1 { t.Fatalf("кук в ответе %d, want 1", len(cookies)) } ck := cookies[0] if ck.MaxAge >= 0 { t.Errorf("MaxAge = %d, want < 0 (мгновенное удаление)", ck.MaxAge) } if ck.Value != "" { t.Errorf("Value = %q, want пусто", ck.Value) } } // --- поведение без куки: до базы не доходим, репозиторий nil --- // TestRefreshWithoutCookie — без refresh-куки refresh отвечает 401 // так же, как с неизвестным токеном: никаких лишних подробностей. func TestRefreshWithoutCookie(t *testing.T) { c, w := newAuthPostContext(t, "/auth/refresh", `{}`) h := &AuthHandler{} h.Refresh(c) if w.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401 (body %q)", w.Code, w.Body.String()) } if got := errorBody(t, w); got != "invalid refresh token" { t.Errorf("error = %q", got) } } // TestLogoutWithoutCookie — выход идемпотентен: куки нет — гасить // нечего, ответ 200 и кука всё равно очищается. func TestLogoutWithoutCookie(t *testing.T) { c, w := newAuthPostContext(t, "/auth/logout", `{}`) h := &AuthHandler{} h.Logout(c) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body %q)", w.Code, w.Body.String()) } if cookies := w.Result().Cookies(); len(cookies) != 1 || cookies[0].MaxAge >= 0 { t.Errorf("кука очистки не поставлена: %+v", w.Result().Cookies()) } } // --- политика пароля на установке (до базы) --- // TestResetPasswordPolicy — тот же контроль на смене пароля по токену. func TestResetPasswordPolicy(t *testing.T) { c, w := newAuthPostContext(t, "/auth/reset-password", `{"token":"t","password":"short123"}`) h := &AuthHandler{} h.ResetPassword(c) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body %q)", w.Code, w.Body.String()) } if got := errorBody(t, w); !strings.Contains(got, "password must be at least 10") { t.Errorf("error = %q", got) } } // --- POST /auth/forgot-password: проверки до базы --- // denyLimiter — лимитер, который всегда запрещает (превышен потолок). type denyLimiter struct{} func (denyLimiter) Allow(string) bool { return false } // TestForgotPasswordValidation — порядок проверок до базы: кривое тело // — 400 (лимит и репозиторий не трогаются), исчерпанный лимит ящика — // 429 (репозиторий не трогается), валидное тело доходит до поиска в // базе (паника nil-репозитория — доказательство). // Одинаковый ответ для существующих и несуществующих ящиков — // интеграционно, здесь только порядок веток. func TestForgotPasswordValidation(t *testing.T) { t.Run("кривое тело", func(t *testing.T) { for _, body := range []string{ `{}`, `{"email":"not-an-email"}`, } { c, w := newAuthPostContext(t, "/auth/forgot-password", body) h := &AuthHandler{forgotMail: denyLimiter{}} // до лимита не дошли h.ForgotPassword(c) if w.Code != http.StatusBadRequest { t.Errorf("body %s: status = %d, want 400 (body %q)", body, w.Code, w.Body.String()) } } }) t.Run("ящик исчерпал лимит — 429 до базы", func(t *testing.T) { c, w := newAuthPostContext(t, "/auth/forgot-password", `{"email":"user@example.test"}`) h := &AuthHandler{forgotMail: denyLimiter{}} // nil-репозиторий: до базы не дошли h.ForgotPassword(c) if w.Code != http.StatusTooManyRequests { t.Fatalf("status = %d, want 429 (body %q)", w.Code, w.Body.String()) } if got := errorBody(t, w); !strings.Contains(got, "too many attempts") { t.Errorf("error = %q", got) } }) t.Run("валидное тело доходит в репозиторий", func(t *testing.T) { c, _ := newAuthPostContext(t, "/auth/forgot-password", `{"email":"user@example.test"}`) defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } }() h := &AuthHandler{forgotMail: ratelimit.New(1, time.Minute)} h.ForgotPassword(c) }) }