axe/internal/handlers/users_test.go

368 lines
14 KiB
Go
Raw Normal View History

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