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

368 lines
14 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"
"github.com/gin-gonic/gin"
)
// Наборы разбора ?q= в GET /users: короткий запрос отклоняется до базы.
// Хендлер создаётся с nil-репозиторием намеренно — если валидация
// перестанет работать, тест упадёт с паникой, а не молча уйдёт в SQL.
func listUsersQ(t *testing.T, rawQuery, role string) (int, string) {
t.Helper()
c, w := newQueryContext(t, rawQuery)
c.Set("role", role)
c.Set("user_id", "00000000-0000-0000-0000-000000000001")
h := &UserHandler{}
h.List(c)
return w.Code, errorBody(t, w)
}
func TestListUsersRejectsShortQuery(t *testing.T) {
cases := []struct {
name string
query string
role string
}{
{"один символ", "role=worker&q=x", "client"},
{"символ после пробелов", "role=worker&q=%20x", "client"},
{"админ с коротким запросом", "role=worker&q=п", "admin"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, errBody := listUsersQ(t, tc.query, tc.role)
if code != http.StatusBadRequest || errBody != "invalid q" {
t.Errorf("status=%d error=%q, want 400/invalid q", code, errBody)
}
})
}
}
// Пустой q — обычный список, а не ошибка: валидация его пропускает,
// и хендлер доходит до репозитория. Репозиторий в тесте nil — паника
// от него и есть доказательство, что ответа 400 на пустой q не было.
func TestListUsersEmptyQueryPassesValidation(t *testing.T) {
c, w := newQueryContext(t, "role=worker&q=")
c.Set("role", "client")
c.Set("user_id", "00000000-0000-0000-0000-000000000001")
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли")
}
if w.Code == http.StatusBadRequest {
t.Errorf("пустой q не должен быть 400, body %q", w.Body.String())
}
}()
h := &UserHandler{}
h.List(c)
}
// --- POST /users: роль, граница полномочий и пароль — до базы ---
// newPostContext — контекст POST с JSON-телом (для Create).
func newPostContext(t *testing.T, 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, "/users", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
return c, w
}
// TestCreateUserRoleRules — «клиент создать клиента/админа» — 403,
// неизвестная роль — 400, слабый пароль — 400. Все проверки идут до
// HashPassword/SQL, поэтому репозиторий здесь nil: сдвинутая проверка
// упадёт паникой, а не молча создаст пользователя. Успешный путь
// админа (role=client → 201) проверяется интеграционно в API-смоуке.
func TestCreateUserRoleRules(t *testing.T) {
cases := []struct {
name string
actor string
body string
wantCode int
wantSub string // подстрока в error, "" — не проверяем
}{
{
"неизвестная роль", "admin",
`{"email":"a@b.co","password":"password1","name":"X","role":"manager"}`,
http.StatusBadRequest, "must be one of: worker admin client",
},
{
"клиент создаёт клиента", "client",
`{"email":"a@b.co","password":"password1","name":"X","role":"client"}`,
http.StatusForbidden, "forbidden",
},
{
"клиент создаёт админа", "client",
`{"email":"a@b.co","password":"password1","name":"X","role":"admin"}`,
http.StatusForbidden, "forbidden",
},
{
// Политика пароля (мин. 10, буквы и цифры) — после границы
// полномочий и до базы: репозиторий nil.
"слабый пароль", "admin",
`{"email":"a@b.co","password":"short123","name":"X","role":"worker"}`,
http.StatusBadRequest, "password must be at least 10 characters",
},
{
// Граница не изменилась: клиент по-прежнему создаёт только мастеров.
"клиент создаёт мастера — доходит до базы", "client",
`{"email":"a@b.co","password":"password123","name":"X","role":"worker"}`,
0, "паника от nil-репозитория",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, w := newPostContext(t, tc.body)
c.Set("role", tc.actor)
c.Set("user_id", "00000000-0000-0000-0000-000000000001")
h := &UserHandler{}
if tc.wantCode == 0 {
// Успешный путь: останавливает только паника nil-репо.
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли")
}
}()
h.Create(c)
return
}
h.Create(c)
if w.Code != tc.wantCode {
t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String())
}
if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) {
t.Errorf("error = %q, want substring %q", body, tc.wantSub)
}
})
}
}
// --- PATCH /users/:id и DELETE /users/:id: проверки до базы ---
// newIDContext — контекст с path-параметром :id; body пустой — без JSON.
func newIDContext(t *testing.T, method, id, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, "/users/"+id, strings.NewReader(body))
if body != "" {
c.Request.Header.Set("Content-Type", "application/json")
}
c.Params = gin.Params{{Key: "id", Value: id}}
return c, w
}
const ownID = "00000000-0000-0000-0000-000000000001"
const otherID = "00000000-0000-0000-0000-000000000002"
// TestUpdateUserValidation — до базы: кривой id и смена своего статуса.
// Роль-ветку (клиент идёт в SetActiveWorkerForClient) проверяем отдельно:
// nil-репозиторий даёт панику — доказательство, что выбор SQL-правила
// действительно происходит по роли.
func TestUpdateUserValidation(t *testing.T) {
cases := []struct {
name string
actor string
id string
body string
wantCode int
wantSub string
}{
{"кривой id", "admin", "not-a-uuid", `{"is_active":false}`, http.StatusBadRequest, "invalid id"},
{"пустое тело", "admin", otherID, ``, http.StatusBadRequest, ""},
{"свой статус менять нельзя", "admin", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"},
{"клиент меняет свой статус", "client", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, w := newIDContext(t, http.MethodPatch, tc.id, tc.body)
c.Set("role", tc.actor)
c.Set("user_id", ownID)
h := &UserHandler{}
h.Update(c)
if w.Code != tc.wantCode {
t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String())
}
if tc.wantSub != "" {
if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) {
t.Errorf("error = %q, want substring %q", body, tc.wantSub)
}
}
})
}
}
// TestUpdateUserRoleBranchReachesRepo — и админ, и клиент после
// проверок уходят в репозиторий: клиент — в SQL-правило «только мой
// мастер», админ — в обычное SetActive. Паника nil-репо — и есть
// доказательство; если ветка по роли сломается, упадёт тест.
func TestUpdateUserRoleBranchReachesRepo(t *testing.T) {
for _, actor := range []string{"admin", "client"} {
t.Run(actor, func(t *testing.T) {
c, _ := newIDContext(t, http.MethodPatch, otherID, `{"is_active":false}`)
c.Set("role", actor)
c.Set("user_id", ownID)
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, роль-ветка не дошла до SQL")
}
}()
h := &UserHandler{}
h.Update(c)
})
}
}
// TestDeleteUserValidation — до базы: кривой id и удаление самого себя.
func TestDeleteUserValidation(t *testing.T) {
cases := []struct {
name string
id string
wantCode int
wantSub string
}{
{"кривой id", "not-a-uuid", http.StatusBadRequest, "invalid id"},
{"себя удалить нельзя", ownID, http.StatusBadRequest, "cannot delete yourself"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, w := newIDContext(t, http.MethodDelete, tc.id, "")
c.Set("role", "admin")
c.Set("user_id", ownID)
h := &UserHandler{}
h.Delete(c)
if w.Code != tc.wantCode {
t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String())
}
if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) {
t.Errorf("error = %q, want substring %q", body, tc.wantSub)
}
})
}
}
// TestDeleteUserReachesRepo — валидный id после проверок уходит в БД,
// независимо от роли: у админа ветка DeleteProfile, у клиента —
// DeleteWorkerForClient (граница и транзакция — в SQL). Паника
// nil-репозитория — доказательство; успешный путь и 409 «есть история»
// проверяются интеграционно в API-смоуке и репозиториных тестах.
func TestDeleteUserReachesRepo(t *testing.T) {
for _, r := range []string{"admin", "client"} {
t.Run(r, func(t *testing.T) {
c, _ := newIDContext(t, http.MethodDelete, otherID, "")
c.Set("role", r)
c.Set("user_id", ownID)
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли")
}
}()
h := &UserHandler{}
h.Delete(c)
})
}
}
// --- POST /users/:id/confirm: проверки до базы ---
// TestConfirmValidation — до базы: кривой id отбрасывается сразу, валидный
// доходит до репозитория (паника nil-репозитория — доказательство).
// Матрица прав подтверждения — TestUserConfirmationRules в репозитории,
// письмо — в mailer-тестах: здесь только разбор параметра.
func TestConfirmValidation(t *testing.T) {
t.Run("кривой id", func(t *testing.T) {
c, w := newIDContext(t, http.MethodPost, "not-a-uuid", "")
c.Set("role", "admin")
c.Set("user_id", ownID)
h := &UserHandler{}
h.Confirm(c)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body %q)", w.Code, w.Body.String())
}
if body := errorBody(t, w); !strings.Contains(body, "invalid id") {
t.Errorf("error = %q, want \"invalid id\"", body)
}
})
t.Run("валидный id доходит в репозиторий", func(t *testing.T) {
c, _ := newIDContext(t, http.MethodPost, otherID, "")
c.Set("role", "admin")
c.Set("user_id", ownID)
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли")
}
}()
h := &UserHandler{}
h.Confirm(c)
})
}
// --- POST /users/:id/password-set: проверки до базы ---
// TestSetPasswordValidation — до базы: кривой id, пустое тело и слабый
// пароль отклоняются до SQL. Валидный запрос доходит до репозитория
// (паника nil-репозитория — доказательство); успешный путь админа
// (200, вход новым паролем, старые сессии отозваны) проверяется
// интеграционно в API-смоуке.
func TestSetPasswordValidation(t *testing.T) {
cases := []struct {
name string
id string
body string
wantCode int
wantSub string
}{
{"кривой id", "not-a-uuid", `{"password":"password123"}`, http.StatusBadRequest, "invalid id"},
{"пустое тело", otherID, ``, http.StatusBadRequest, "invalid request body"},
{"нет пароля", otherID, `{}`, http.StatusBadRequest, "is required"},
{"слабый пароль", otherID, `{"password":"short123"}`, http.StatusBadRequest, "password must be at least 10 characters"},
{"валидный запрос идёт в базу", otherID, `{"password":"password123"}`, 0, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c, w := newIDContext(t, http.MethodPost, tc.id, tc.body)
c.Set("role", "admin")
c.Set("user_id", ownID)
h := &UserHandler{}
if tc.wantCode == 0 {
defer func() {
if recover() == nil {
t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли")
}
}()
h.SetPassword(c)
return
}
h.SetPassword(c)
if w.Code != tc.wantCode {
t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String())
}
if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) {
t.Errorf("error = %q, want substring %q", body, tc.wantSub)
}
})
}
}