Files
glchat/internal/server/auth_test.go
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

127 lines
4.1 KiB
Go

package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func doJSON(t *testing.T, srv *Server, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
func TestRegisterEndpointCreatesSession(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"api_user","email":"api@example.com","password":"correct-horse-battery"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body: %v", err)
}
if payload.User.Username != "api_user" || payload.User.ID == "" {
t.Fatalf("unexpected user payload: %s", rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
t.Fatalf("session cookie is missing: %v", cookies)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
// С полученной cookie доступен профиль.
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0])
if me.Code != http.StatusOK {
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
}
}
func TestLoginEndpointErrors(t *testing.T) {
srv, _ := newTestServer(t)
doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"login_user","email":"login@example.com","password":"correct-horse-battery"}`)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"login@example.com","password":"wrong-password"}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong password status = %d, want 401", rec.Code)
}
if !strings.Contains(rec.Body.String(), "invalid credentials") {
t.Fatalf("unexpected error body: %s", rec.Body.String())
}
rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"login@example.com","password":"correct-horse-battery"}`)
if rec.Code != http.StatusOK {
t.Fatalf("valid login status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestAuthenticatedEndpointsRequireSession(t *testing.T) {
srv, _ := newTestServer(t)
for _, path := range []string{"/api/v1/users/@me", "/api/v1/auth/sessions"} {
rec := doJSON(t, srv, http.MethodGet, path, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("GET %s without session = %d, want 401", path, rec.Code)
}
}
}
func TestValidationRejectsShortPassword(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"short_user","email":"short@example.com","password":"short"}`)
if rec.Code == http.StatusOK {
t.Fatalf("short password accepted: %s", rec.Body.String())
}
}
func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/openapi.json", "")
if rec.Code != http.StatusOK {
t.Fatalf("openapi status = %d", rec.Code)
}
var doc struct {
Paths map[string]any `json:"paths"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode openapi: %v", err)
}
paths := []string{
"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions",
"/users/@me", "/auth/2fa/setup", "/meta",
// Ручки Фазы 1, сгенерированные huma.
"/users/@me/guilds", "/guilds", "/guilds/{guild_id}", "/guilds/{guild_id}/channels",
"/guilds/{guild_id}/members", "/guilds/{guild_id}/roles", "/instance",
"/instance/settings", "/instance/guilds", "/instance/users", "/instance/audit",
}
for _, path := range paths {
if _, ok := doc.Paths[path]; !ok {
t.Errorf("openapi is missing %s", path)
}
}
}