axe/internal/handlers/auth_test.go
2026-10-18 22:07:25 +03:00

214 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
})
}