axe/internal/handlers/auth_test.go

214 lines
7.3 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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)
})
}