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).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+158
View File
@@ -0,0 +1,158 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
)
// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена.
type sessionContextValue struct {
User *store.User
Session *store.Session
}
type sessionContextKey struct{}
// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст.
// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI).
func (s *Server) sessionContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
next.ServeHTTP(w, r)
return
}
token := sessionToken(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
user, session, err := s.auth.ResolveSession(r.Context(), token)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop).
func sessionToken(r *http.Request) string {
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
return normalizeBearer(r.Header.Get("Authorization"))
}
func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) {
value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue)
if !ok || value.User == nil || value.Session == nil {
return nil, nil, false
}
return value.User, value.Session, true
}
// requireUser достаёт текущего пользователя или возвращает 401 в конверте API.
func requireUser(ctx context.Context) (*store.User, *store.Session, error) {
user, session, ok := sessionFromContext(ctx)
if !ok {
return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required")
}
return user, session, nil
}
// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5).
func requireInstanceAdmin(ctx context.Context) (*store.User, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !user.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
return user, nil
}
// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}}
// (AGENT.md 8.5): клиент разбирает код, а не текст.
type humaAPIError struct {
status int
code string
message string
}
func (e *humaAPIError) Error() string { return e.code + ": " + e.message }
func (e *humaAPIError) GetStatus() int { return e.status }
func (e *humaAPIError) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any{
"error": map[string]any{"code": e.code, "message": e.message},
})
}
// humaErrorStatus создаёт ошибку с явным кодом.
func humaErrorStatus(status int, code, message string) huma.StatusError {
return &humaAPIError{status: status, code: code, message: message}
}
// humaError переводит доменную ошибку в ответ API с тем же кодом, что и
// chi-ручки (единая таблица в errors.go).
func humaError(err error) huma.StatusError {
if err == nil {
return nil
}
converted := newAPIError(err)
return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message}
}
// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4).
func parseID(name, value string) (uint64, error) {
if value == "" {
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required")
}
parsed, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id")
}
return parsed, nil
}
// guildPermissions считает права пользователя на сервере.
func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) {
resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
}
return permissions.Resolved{}, humaError(err)
}
if !resolved.Has(permissions.ViewGuild) {
// Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7).
return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found")
}
return resolved, nil
}
// requireGuildPermission считает права и требует конкретное разрешение.
func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) {
id, err := parseID("guild_id", guildID)
if err != nil {
return 0, permissions.Resolved{}, err
}
resolved, err := s.guildPermissions(ctx, id, user)
if err != nil {
return 0, permissions.Resolved{}, err
}
if !resolved.Has(permission) {
return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied")
}
return id, resolved, nil
}
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
package server
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/bootstrap"
"glchat/internal/store"
)
// instancePayload — публичная информация об инстансе (AGENT.md 6.5).
type instancePayload struct {
Name string `json:"name"`
Version string `json:"version"`
RegistrationEnabled bool `json:"registration_enabled"`
AllowGuildCreation bool `json:"allow_guild_creation"`
VoiceEnabled bool `json:"voice_enabled"`
MaxGuildsPerUser int `json:"max_guilds_per_user"`
MaxMembersPerGuild int `json:"max_members_per_guild"`
MaxMessageLength int `json:"max_message_length"`
MainGuildID string `json:"main_guild_id,omitempty"`
UserCount int `json:"user_count"`
GuildCount int `json:"guild_count"`
}
type instanceOutput struct {
Body struct {
Instance instancePayload `json:"instance"`
}
}
type instanceGuildPayload struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name,omitempty"`
IsMain bool `json:"is_main"`
MemberCount int `json:"member_count"`
CreatedAt string `json:"created_at"`
}
type instanceGuildListOutput struct {
Body struct {
Guilds []instanceGuildPayload `json:"guilds"`
}
}
type instanceUserPayload struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
IsInstanceAdmin bool `json:"is_instance_admin"`
Badges []string `json:"badges"`
CreatedAt string `json:"created_at"`
}
type instanceUserListOutput struct {
Body struct {
Users []instanceUserPayload `json:"users"`
}
}
type instanceSettingsPayload struct {
RegistrationEnabled bool `json:"registration_enabled"`
AllowGuildCreation bool `json:"allow_guild_creation"`
MaxGuildsPerUser int `json:"max_guilds_per_user"`
MaxMembersPerGuild int `json:"max_members_per_guild"`
MaxMessageLength int `json:"max_message_length"`
}
type instanceSettingsOutput struct {
Body struct {
Settings instanceSettingsPayload `json:"settings"`
}
}
// registerInstanceRoutes описывает публичную информацию об инстансе и
// админ-панель администратора инстанса (AGENT.md 6.5, 7.19).
func (s *Server) registerInstanceRoutes(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "getInstance",
Method: http.MethodGet,
Path: "/instance",
Summary: "Публичная информация об инстансе",
Tags: []string{"Instance"},
}, func(ctx context.Context, _ *struct{}) (*instanceOutput, error) {
payload, err := s.instancePayload(ctx)
if err != nil {
return nil, err
}
// Лимиты и число пользователей видны только администратору инстанса.
if user, _, ok := sessionFromContext(ctx); !ok || !user.IsInstanceAdmin {
payload.MaxGuildsPerUser = 0
payload.MaxMembersPerGuild = 0
payload.UserCount = 0
payload.GuildCount = 0
}
output := &instanceOutput{}
output.Body.Instance = payload
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getInstanceSettings",
Method: http.MethodGet,
Path: "/instance/settings",
Summary: "Настройки инстанса (только администратор)",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceSettingsOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceSettingsOutput{}
output.Body.Settings = instanceSettingsPayload{
RegistrationEnabled: settings.RegistrationEnabled,
AllowGuildCreation: settings.AllowGuildCreation,
MaxGuildsPerUser: settings.MaxGuildsPerUser,
MaxMembersPerGuild: settings.MaxMembersPerGuild,
MaxMessageLength: settings.MaxMessageLength,
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateInstanceSettings",
Method: http.MethodPatch,
Path: "/instance/settings",
Summary: "Изменить настройки инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Body struct {
RegistrationEnabled *bool `json:"registration_enabled,omitempty"`
AllowGuildCreation *bool `json:"allow_guild_creation,omitempty"`
MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"`
MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"`
MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"`
}
},
) (*instanceSettingsOutput, error) {
user, err := requireInstanceAdmin(ctx)
if err != nil {
return nil, err
}
updates := map[string]string{}
if input.Body.RegistrationEnabled != nil {
updates["registration_enabled"] = strconv.FormatBool(*input.Body.RegistrationEnabled)
}
if input.Body.AllowGuildCreation != nil {
updates["allow_guild_creation"] = strconv.FormatBool(*input.Body.AllowGuildCreation)
}
if input.Body.MaxGuildsPerUser != nil {
updates["max_guilds_per_user"] = strconv.Itoa(*input.Body.MaxGuildsPerUser)
}
if input.Body.MaxMembersPerGuild != nil {
updates["max_members_per_guild"] = strconv.Itoa(*input.Body.MaxMembersPerGuild)
}
if input.Body.MaxMessageLength != nil {
updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength)
}
for key, value := range updates {
if err := s.store.SetInstanceSetting(ctx, key, value); err != nil {
return nil, humaError(err)
}
}
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "")
output := &instanceSettingsOutput{}
output.Body.Settings = instanceSettingsPayload{
RegistrationEnabled: settings.RegistrationEnabled,
AllowGuildCreation: settings.AllowGuildCreation,
MaxGuildsPerUser: settings.MaxGuildsPerUser,
MaxMembersPerGuild: settings.MaxMembersPerGuild,
MaxMessageLength: settings.MaxMessageLength,
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listInstanceGuilds",
Method: http.MethodGet,
Path: "/instance/guilds",
Summary: "Все серверы инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
guilds, err := s.store.ListAllGuilds(ctx)
if err != nil {
return nil, humaError(err)
}
output := &instanceGuildListOutput{}
output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds))
for _, guild := range guilds {
payload := instanceGuildPayload{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
}
if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil {
payload.OwnerName = owner.Username
}
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
payload.MemberCount = count
}
output.Body.Guilds = append(output.Body.Guilds, payload)
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "adminCreateGuild",
Method: http.MethodPost,
Path: "/instance/guilds",
Summary: "Создать сервер от имени администратора (лимиты обходятся)",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Body struct {
Name string `json:"name" minLength:"1" maxLength:"64"`
OwnerID string `json:"owner_id,omitempty"`
}
},
) (*instanceGuildListOutput, error) {
admin, err := requireInstanceAdmin(ctx)
if err != nil {
return nil, err
}
owner := admin
if input.Body.OwnerID != "" {
ownerID, err := parseID("owner_id", input.Body.OwnerID)
if err != nil {
return nil, err
}
owner, err = s.store.GetUser(ctx, ownerID)
if err != nil {
return nil, humaError(err)
}
}
name := strings.TrimSpace(input.Body.Name)
if name == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty")
}
// Администратор инстанса создаёт сервер в обход лимитов: причина
// фиксируется в аудите отдельной записью (AGENT.md 6.5).
guild, err := s.createGuildAsAdmin(ctx, admin, owner, name)
if err != nil {
return nil, err
}
output := &instanceGuildListOutput{}
output.Body.Guilds = []instanceGuildPayload{{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339),
}}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "adminDeleteGuild",
Method: http.MethodDelete,
Path: "/instance/guilds/{guild_id}",
Summary: "Удалить сервер (администратор инстанса)",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
GuildID string `path:"guild_id"`
},
) (*okOutput, error) {
admin, err := requireInstanceAdmin(ctx)
if err != nil {
return nil, err
}
guildID, err := parseID("guild_id", input.GuildID)
if err != nil {
return nil, err
}
if err := s.deleteGuild(ctx, admin, guildID); err != nil {
return nil, err
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "listInstanceUsers",
Method: http.MethodGet,
Path: "/instance/users",
Summary: "Пользователи инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
Offset int `query:"offset" default:"0" minimum:"0"`
},
) (*instanceUserListOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
users, err := s.store.ListUsers(ctx, input.Limit, input.Offset)
if err != nil {
return nil, humaError(err)
}
output := &instanceUserListOutput{}
output.Body.Users = make([]instanceUserPayload, 0, len(users))
for _, user := range users {
badges := user.Badges
if badges == nil {
badges = []string{}
}
output.Body.Users = append(output.Body.Users, instanceUserPayload{
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: badges,
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
})
}
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "setInstanceAdmin",
Method: http.MethodPost,
Path: "/instance/users/{user_id}/admin",
Summary: "Выдать или снять права администратора инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
Body struct {
Admin bool `json:"admin"`
// StepUpPassword подтверждает действие: смена администраторов —
// чувствительная операция (AGENT.md 7.1).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*userOutput, error) {
admin, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if !admin.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required")
}
if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
userID, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
if userID == admin.ID && !input.Body.Admin {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot revoke your own administrator rights")
}
if err := s.store.SetInstanceAdmin(ctx, userID, input.Body.Admin); err != nil {
return nil, humaError(err)
}
updated, err := s.store.GetUser(ctx, userID)
if err != nil {
return nil, humaError(err)
}
action := "instance.admin_grant"
if !input.Body.Admin {
action = "instance.admin_revoke"
}
s.recordAudit(ctx, admin, 0, action, "user", &userID, "")
s.dispatchUserUpdate(updated)
output := &userOutput{}
output.Body.User = profileFromUser(updated, false)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listInstanceAudit",
Method: http.MethodGet,
Path: "/instance/audit",
Summary: "Журнал действий администраторов инстанса",
Tags: []string{"Instance"},
Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}},
}, func(ctx context.Context, input *struct {
Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"`
},
) (*auditListOutput, error) {
if _, err := requireInstanceAdmin(ctx); err != nil {
return nil, err
}
entries, err := s.store.ListInstanceAudit(ctx, input.Limit)
if err != nil {
return nil, humaError(err)
}
output := &auditListOutput{}
output.Body.Entries = auditPayloads(entries)
return output, nil
})
}
// instancePayload собирает публичные сведения об инстансе.
func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) {
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return instancePayload{}, humaError(err)
}
users, err := s.store.CountUsers(ctx)
if err != nil {
return instancePayload{}, humaError(err)
}
guilds, err := s.store.ListAllGuilds(ctx)
if err != nil {
return instancePayload{}, humaError(err)
}
payload := instancePayload{
Name: s.cfg.InstanceName,
Version: s.cfg.Version,
RegistrationEnabled: settings.RegistrationEnabled,
AllowGuildCreation: settings.AllowGuildCreation,
MaxGuildsPerUser: settings.MaxGuildsPerUser,
MaxMembersPerGuild: settings.MaxMembersPerGuild,
MaxMessageLength: settings.MaxMessageLength,
UserCount: users,
GuildCount: len(guilds),
}
if settings.MainGuildID != 0 {
payload.MainGuildID = formatSnowflake(settings.MainGuildID)
}
return payload, nil
}
// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите.
func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) {
settings, err := s.store.InstanceSettings(ctx)
if err != nil {
return nil, humaError(err)
}
guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{
Name: name,
OwnerID: owner.ID,
IsMain: settings.MainGuildID == 0,
IsDiscoverable: false,
})
if err != nil {
return nil, humaError(err)
}
if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil {
return nil, humaError(err)
}
if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
}); err != nil {
return nil, humaError(err)
}
if guild.IsMain {
if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
return nil, humaError(err)
}
}
s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin")
s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits")
if s.gateway != nil {
s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)})
}
return guild, nil
}
+631
View File
@@ -0,0 +1,631 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"glchat/internal/permissions"
"glchat/internal/store"
)
// permissionViewChannel — сокращение для оверрайдов в тестах.
const permissionViewChannel = permissions.ViewChannel
// registerAndLogin регистрирует пользователя и возвращает cookie сессии.
func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie {
t.Helper()
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`)
if rec.Code != http.StatusOK {
t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("register %s did not return a session cookie", username)
}
return cookies[0]
}
func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var payload T
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String())
}
return payload
}
func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
payload := decodeResponse[struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}](t, rec)
if payload.Error.Code == "" {
t.Fatalf("response has no error code: %s", rec.Body.String())
}
return payload.Error.Code
}
// promoteAdmin делает пользователя администратором инстанса напрямую в БД.
func promoteAdmin(t *testing.T, srv *Server, email string) {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("find user %s: %v", email, err)
}
if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil {
t.Fatalf("promote %s: %v", email, err)
}
}
func TestHumaErrorsUseAPIEnvelope(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if code := errorCodeOf(t, rec); code != "auth.session_expired" {
t.Fatalf("error code = %q, want auth.session_expired", code)
}
rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("instance settings without session = %d, want 401", rec.Code)
}
}
func TestProfileUpdateFlow(t *testing.T) {
srv, _ := newTestServer(t)
cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com")
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
if me.Code != http.StatusOK {
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
}
profile := decodeResponse[struct {
User struct {
Username string `json:"username"`
OnboardingCompleted bool `json:"onboarding_completed"`
Locale string `json:"locale"`
} `json:"user"`
}](t, me)
if profile.User.Username != "profile_user" {
t.Fatalf("username = %q", profile.User.Username)
}
if profile.User.OnboardingCompleted {
t.Fatal("new user must not have onboarding completed")
}
if profile.User.Locale != "ru" {
t.Fatalf("default locale = %q, want ru", profile.User.Locale)
}
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me",
`{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie)
if updated.Code != http.StatusOK {
t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String())
}
payload := decodeResponse[struct {
User struct {
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
Status string `json:"status"`
Locale string `json:"locale"`
} `json:"user"`
}](t, updated)
if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" {
t.Fatalf("unexpected profile: %+v", payload.User)
}
// Онбординг завершается отдельной ручкой и выставляет флаг.
onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete",
`{"display_name":"Новый ник","bio":"привет"}`, cookie)
if onboarding.Code != http.StatusOK {
t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String())
}
after := decodeResponse[struct {
User struct {
DisplayName string `json:"display_name"`
OnboardingCompleted bool `json:"onboarding_completed"`
} `json:"user"`
}](t, onboarding)
if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" {
t.Fatalf("unexpected onboarding result: %+v", after.User)
}
// Публичный профиль доступен другому пользователю и не содержит приватных полей.
otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com")
user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie)
if public.Code != http.StatusOK {
t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String())
}
if strings.Contains(public.Body.String(), "@example.com") {
t.Fatalf("public profile must not leak email: %s", public.Body.String())
}
}
func TestGuildLifecycleAndPermissions(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com")
memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie)
if created.Code != http.StatusOK {
t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String())
}
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
Name string `json:"name"`
IsMain bool `json:"is_main"`
Roles []struct {
ID string `json:"id"`
IsDefault bool `json:"is_default"`
} `json:"roles"`
Channels []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"channels"`
MyPermissions []string `json:"my_permissions"`
} `json:"guild"`
}](t, created)
if guild.Guild.Name != "Тестовый сервер" {
t.Fatalf("guild name = %q", guild.Guild.Name)
}
if len(guild.Guild.Roles) != 2 {
t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles))
}
if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" {
t.Fatalf("default channels = %+v", guild.Guild.Channels)
}
if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") {
t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions)
}
// Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7).
stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
if stranger.Code != http.StatusNotFound {
t.Fatalf("stranger channels = %d, want 404", stranger.Code)
}
// Главный сервер открыт для присоединения.
join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
if join.Code != http.StatusOK {
t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String())
}
// Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS.
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
if channels.Code != http.StatusOK {
t.Fatalf("member channels = %d", channels.Code)
}
list := decodeResponse[struct {
Channels []struct {
Name string `json:"name"`
CanView bool `json:"can_view"`
CanSend bool `json:"can_send"`
} `json:"channels"`
}](t, channels)
if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend {
t.Fatalf("unexpected member channels: %+v", list.Channels)
}
denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
`{"name":"секретная","type":"text"}`, memberCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("member create channel = %d, want 403", denied.Code)
}
if code := errorCodeOf(t, denied); code != "perm.denied" {
t.Fatalf("error code = %q, want perm.denied", code)
}
// Владелец создаёт комнату и роль.
newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
`{"name":"флудилка","type":"voice"}`, ownerCookie)
if newChannel.Code != http.StatusOK {
t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String())
}
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
`{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie)
if roleRec.Code != http.StatusOK {
t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String())
}
role := decodeResponse[struct {
Role struct {
ID string `json:"id"`
Permissions string `json:"permissions"`
} `json:"role"`
}](t, roleRec)
if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") {
t.Fatalf("role permissions = %q", role.Role.Permissions)
}
// Роль можно выдать участнику.
member, err := srv.auth.UserByEmail(t.Context(), "member@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
assign := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
if assign.Code != http.StatusOK {
t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String())
}
members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie)
roster := decodeResponse[struct {
Members []struct {
UserID string `json:"user_id"`
RoleIDs []string `json:"role_ids"`
} `json:"members"`
}](t, members)
if len(roster.Members) != 2 {
t.Fatalf("members = %d, want 2", len(roster.Members))
}
found := false
for _, entry := range roster.Members {
if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) {
found = true
}
}
if !found {
t.Fatalf("role was not assigned: %+v", roster.Members)
}
// Участник не может исключить владельца даже с правом KICK_MEMBERS.
owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
kickOwner := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie)
if kickOwner.Code != http.StatusForbidden {
t.Fatalf("kick owner = %d, want 403", kickOwner.Code)
}
// Владелец исключает участника.
kick := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie)
if kick.Code != http.StatusOK {
t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String())
}
// Журнал аудита содержит действие исключения.
audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie)
entries := decodeResponse[struct {
Entries []struct {
Action string `json:"action"`
} `json:"entries"`
}](t, audit)
if !hasAction(entries.Entries, "member.kick") {
t.Fatalf("audit log has no member.kick: %+v", entries.Entries)
}
// Главный сервер удалить нельзя.
deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie)
if deleteMain.Code != http.StatusForbidden {
t.Fatalf("delete main guild = %d, want 403", deleteMain.Code)
}
}
func TestInstanceAdminEndpointsAndLimits(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com")
promoteAdmin(t, srv, "admin@example.com")
userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com")
// Публичная информация об инстансе доступна без сессии.
public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "")
if public.Code != http.StatusOK {
t.Fatalf("GET /instance = %d", public.Code)
}
publicPayload := decodeResponse[struct {
Instance struct {
RegistrationEnabled bool `json:"registration_enabled"`
UserCount int `json:"user_count"`
} `json:"instance"`
}](t, public)
if !publicPayload.Instance.RegistrationEnabled {
t.Fatal("registration must be enabled by default")
}
if publicPayload.Instance.UserCount != 0 {
t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount)
}
// Обычный пользователь не имеет доступа к админ-ручкам.
for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} {
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("GET %s as user = %d, want 403", path, rec.Code)
}
if code := errorCodeOf(t, rec); code != "instance.admin_required" {
t.Fatalf("GET %s code = %q", path, code)
}
}
// Лимит в один сервер на пользователя.
patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_guilds_per_user":1}`, adminCookie)
if patch.Code != http.StatusOK {
t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String())
}
first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie)
if first.Code != http.StatusOK {
t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String())
}
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie)
if second.Code != http.StatusForbidden {
t.Fatalf("second guild = %d, want 403", second.Code)
}
if code := errorCodeOf(t, second); code != "limits.guilds_reached" {
t.Fatalf("limit error code = %q, want limits.guilds_reached", code)
}
// Администратор инстанса обходит лимит, и это попадает в аудит.
adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie)
if adminGuild.Code != http.StatusOK {
t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String())
}
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie)
guildList := decodeResponse[struct {
Guilds []struct {
Name string `json:"name"`
} `json:"guilds"`
}](t, guilds)
if len(guildList.Guilds) != 2 {
t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds))
}
audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie)
entries := decodeResponse[struct {
Entries []struct {
Action string `json:"action"`
ActorInstanceAdmin bool `json:"actor_instance_admin"`
} `json:"entries"`
}](t, audit)
if !hasAction(entries.Entries, "limits.bypass") {
t.Fatalf("audit has no limits.bypass: %+v", entries.Entries)
}
for _, entry := range entries.Entries {
if !entry.ActorInstanceAdmin {
t.Fatalf("instance audit entry without admin flag: %+v", entry)
}
}
// Права администратора выдаются только со step-up.
user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
noStepUp := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code)
}
grant := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin",
`{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie)
if grant.Code != http.StatusOK {
t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String())
}
// Смена настроек регистрации закрывает регистрацию.
off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie)
if off.Code != http.StatusOK {
t.Fatalf("disable registration = %d", off.Code)
}
blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`)
if blocked.Code == http.StatusOK {
t.Fatal("registration must be rejected when disabled")
}
}
func TestMemberTimeoutRequiresPermission(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com")
memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
// У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён.
denied := doJSON(t, srv, http.MethodPatch,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
`{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie)
if denied.Code != http.StatusForbidden {
t.Fatalf("member self timeout = %d, want 403", denied.Code)
}
// Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются.
ok := doJSON(t, srv, http.MethodPatch,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID),
`{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie)
if ok.Code != http.StatusOK {
t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String())
}
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
list := decodeResponse[struct {
Channels []struct {
CanSend bool `json:"can_send"`
} `json:"channels"`
}](t, channels)
if len(list.Channels) == 0 || list.Channels[0].CanSend {
t.Fatalf("timed out member must not be able to send: %+v", list.Channels)
}
}
func TestRoleHierarchyProtectsHigherRoles(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com")
modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie)
mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
// Роль модератора с правом управления ролями.
roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles",
`{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie)
role := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, roleRec)
assign := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie)
if assign.Code != http.StatusOK {
t.Fatalf("assign moderator role = %d", assign.Code)
}
// Модератор пытается снять собственную роль: себе модерировать нельзя.
self := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie)
if self.Code != http.StatusForbidden {
t.Fatalf("self moderation = %d, want 403", self.Code)
}
// Модератор пытается выдать себе роль администратора: она выше его роли.
roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie)
roleList := decodeResponse[struct {
Roles []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"roles"`
}](t, roles)
var adminRoleID string
for _, candidate := range roleList.Roles {
if candidate.Name == "Администратор" {
adminRoleID = candidate.ID
}
}
if adminRoleID == "" {
t.Fatal("administrator role is missing")
}
escalate := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie)
if escalate.Code != http.StatusForbidden {
t.Fatalf("privilege escalation = %d, want 403", escalate.Code)
}
}
func TestChannelOverridesHideChannel(t *testing.T) {
srv, _ := newTestServer(t)
ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com")
memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com")
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
Roles []struct {
ID string `json:"id"`
IsDefault bool `json:"is_default"`
} `json:"roles"`
} `json:"guild"`
}](t, created)
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie)
secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels",
`{"name":"тайная","type":"text"}`, ownerCookie)
channel := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, secret)
// Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён.
channelID, err := parseID("channel_id", channel.Channel.ID)
if err != nil {
t.Fatalf("parse channel id: %v", err)
}
var defaultRoleID uint64
for _, role := range guild.Guild.Roles {
if role.IsDefault {
defaultRoleID, err = parseID("role_id", role.ID)
if err != nil {
t.Fatalf("parse role id: %v", err)
}
}
}
if defaultRoleID == 0 {
t.Fatal("default role not found")
}
if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{
ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID,
Deny: uint64(permissionViewChannel),
}); err != nil {
t.Fatalf("SetChannelOverride: %v", err)
}
srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID))
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie)
list := decodeResponse[struct {
Channels []struct {
Name string `json:"name"`
} `json:"channels"`
}](t, channels)
for _, entry := range list.Channels {
if entry.Name == "тайная" {
t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels)
}
}
}
func guildIDOf(t *testing.T, raw string) uint64 {
t.Helper()
id, err := parseID("guild_id", raw)
if err != nil {
t.Fatalf("parse guild id: %v", err)
}
return id
}
func containsString(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func hasAction[T any](entries []T, action string) bool {
for _, entry := range entries {
encoded, err := json.Marshal(entry)
if err != nil {
continue
}
if strings.Contains(string(encoded), `"`+action+`"`) {
return true
}
}
return false
}
+339
View File
@@ -0,0 +1,339 @@
package server
import (
"context"
"net/http"
"strings"
"github.com/danielgtaylor/huma/v2"
"glchat/internal/permissions"
"glchat/internal/store"
)
// profilePayload — публичный профиль пользователя (AGENT.md 8.2).
type profilePayload struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
Status string `json:"status"`
CustomStatus string `json:"custom_status"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
BannerFileID string `json:"banner_file_id,omitempty"`
IsInstanceAdmin bool `json:"is_instance_admin"`
Badges []string `json:"badges"`
Locale string `json:"locale"`
// OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2).
OnboardingCompleted bool `json:"onboarding_completed"`
}
func profileFromUser(user *store.User, includePrivate bool) profilePayload {
payload := profilePayload{
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
Bio: user.Bio,
Status: user.Status,
CustomStatus: user.CustomStatus,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: user.Badges,
OnboardingCompleted: user.OnboardingCompletedAt != nil,
}
if payload.Badges == nil {
payload.Badges = []string{}
}
if user.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
}
if user.BannerFileID != nil {
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
}
if includePrivate {
payload.Locale = user.Locale
}
return payload
}
type meOutput struct {
Body struct {
User profilePayload `json:"user"`
}
}
type userOutput struct {
Body struct {
User profilePayload `json:"user"`
}
}
type okOutput struct {
Body struct {
OK bool `json:"ok"`
}
}
func newOKOutput() *okOutput {
output := &okOutput{}
output.Body.OK = true
return output
}
type updateProfileInput struct {
Body struct {
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
Bio *string `json:"bio,omitempty" maxLength:"500"`
Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"`
CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"`
CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"`
Locale *string `json:"locale,omitempty" enum:"ru,en"`
}
}
type changePasswordInput struct {
Body struct {
CurrentPassword string `json:"current_password" minLength:"1"`
NewPassword string `json:"new_password" minLength:"1"`
}
}
type onboardingInput struct {
Body struct {
DisplayName *string `json:"display_name,omitempty" maxLength:"32"`
Bio *string `json:"bio,omitempty" maxLength:"500"`
Locale *string `json:"locale,omitempty" enum:"ru,en"`
}
}
type guildSummary struct {
ID string `json:"id"`
Name string `json:"name"`
IconFileID string `json:"icon_file_id,omitempty"`
OwnerID string `json:"owner_id"`
IsMain bool `json:"is_main"`
MemberCount int `json:"member_count"`
MyRoleIDs []string `json:"my_role_ids"`
MyPermissions []string `json:"my_permissions"`
}
type guildListOutput struct {
Body struct {
Guilds []guildSummary `json:"guilds"`
}
}
// registerUserRoutes описывает ручки профиля, онбординга и списка серверов.
func (s *Server) registerUserRoutes(api huma.API) {
security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}
huma.Register(api, huma.Operation{
OperationID: "getCurrentUser",
Method: http.MethodGet,
Path: "/users/@me",
Summary: "Текущий пользователь",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
output := &meOutput{}
output.Body.User = profileFromUser(user, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "updateCurrentUser",
Method: http.MethodPatch,
Path: "/users/@me",
Summary: "Изменить профиль",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
params := store.UpdateUserParams{
DisplayName: input.Body.DisplayName,
Bio: input.Body.Bio,
Status: input.Body.Status,
CustomStatus: input.Body.CustomStatus,
CustomStatusEmoji: input.Body.CustomStatusEmoji,
Locale: input.Body.Locale,
}
if input.Body.DisplayName != nil {
trimmed := strings.TrimSpace(*input.Body.DisplayName)
if trimmed == "" {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty")
}
params.DisplayName = &trimmed
}
updated, err := s.store.UpdateUser(ctx, user.ID, params)
if err != nil {
return nil, humaError(err)
}
// Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3).
s.dispatchUserUpdate(updated)
output := &meOutput{}
output.Body.User = profileFromUser(updated, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "changePassword",
Method: http.MethodPost,
Path: "/users/@me/password",
Summary: "Сменить пароль (требует step-up)",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) {
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil {
return nil, humaError(err)
}
return newOKOutput(), nil
})
huma.Register(api, huma.Operation{
OperationID: "completeOnboarding",
Method: http.MethodPost,
Path: "/users/@me/onboarding/complete",
Summary: "Завершить первичную настройку",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *onboardingInput) (*meOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
params := store.UpdateUserParams{
Bio: input.Body.Bio,
Locale: input.Body.Locale,
}
if input.Body.DisplayName != nil {
if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" {
params.DisplayName = &trimmed
}
}
if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil {
return nil, humaError(err)
}
if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil {
return nil, humaError(err)
}
updated, err := s.store.GetUser(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
s.dispatchUserUpdate(updated)
output := &meOutput{}
output.Body.User = profileFromUser(updated, true)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "getUser",
Method: http.MethodGet,
Path: "/users/{user_id}",
Summary: "Публичный профиль пользователя",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, input *struct {
UserID string `path:"user_id"`
},
) (*userOutput, error) {
if _, _, err := requireUser(ctx); err != nil {
return nil, err
}
id, err := parseID("user_id", input.UserID)
if err != nil {
return nil, err
}
user, err := s.store.GetUser(ctx, id)
if err != nil {
return nil, humaError(err)
}
output := &userOutput{}
output.Body.User = profileFromUser(user, false)
return output, nil
})
huma.Register(api, huma.Operation{
OperationID: "listMyGuilds",
Method: http.MethodGet,
Path: "/users/@me/guilds",
Summary: "Серверы текущего пользователя",
Tags: []string{"Users"},
Security: security,
}, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) {
user, _, err := requireUser(ctx)
if err != nil {
return nil, err
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, humaError(err)
}
summaries := make([]guildSummary, 0, len(guilds))
for _, guild := range guilds {
resolved, err := s.guildPermissions(ctx, guild.ID, user)
if err != nil {
return nil, err
}
if !resolved.Has(permissions.ViewGuild) {
continue
}
summary, err := s.guildSummary(ctx, guild, user.ID, resolved)
if err != nil {
return nil, err
}
summaries = append(summaries, summary)
}
output := &guildListOutput{}
output.Body.Guilds = summaries
return output, nil
})
}
// guildSummary собирает краткую карточку сервера для списка.
func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) {
summary := guildSummary{
ID: formatSnowflake(guild.ID),
Name: guild.Name,
OwnerID: formatSnowflake(guild.OwnerID),
IsMain: guild.IsMain,
MyRoleIDs: []string{},
MyPermissions: permissions.Names(resolved.Guild),
}
if guild.IconFileID != nil {
summary.IconFileID = formatSnowflake(*guild.IconFileID)
}
roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID)
if err != nil {
return guildSummary{}, humaError(err)
}
for _, roleID := range roleIDs {
summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID))
}
count, err := s.store.CountGuildMembers(ctx, guild.ID)
if err != nil {
return guildSummary{}, humaError(err)
}
summary.MemberCount = count
return summary, nil
}
// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя.
func (s *Server) dispatchUserUpdate(user *store.User) {
if s.gateway == nil {
return
}
s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{
"user": profileFromUser(user, true),
})
}
+15 -19
View File
@@ -7,12 +7,14 @@ import (
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/store"
)
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
const sessionCookieName = "__Host-session"
// Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie.
const sessionCookieName = gateway.SessionCookieName
const sessionCookiePath = "/"
@@ -54,7 +56,6 @@ func (s *Server) registerAuthRoutes(router chi.Router) {
router.Post("/auth/step-up", s.handleStepUp)
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
router.Get("/users/@me", s.handleGetMe)
}
type registerRequest struct {
@@ -77,19 +78,22 @@ type currentUserPayload struct {
IsInstanceAdmin bool `json:"is_instance_admin"`
Badges []string `json:"badges"`
Locale string `json:"locale"`
// OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2).
OnboardingCompleted bool `json:"onboarding_completed"`
}
func userPayload(user *store.User) currentUserPayload {
payload := currentUserPayload{
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
Bio: user.Bio,
Status: user.Status,
CustomStatus: user.CustomStatus,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: user.Badges,
Locale: user.Locale,
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
Bio: user.Bio,
Status: user.Status,
CustomStatus: user.CustomStatus,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: user.Badges,
Locale: user.Locale,
OnboardingCompleted: user.OnboardingCompletedAt != nil,
}
if payload.Badges == nil {
payload.Badges = []string{}
@@ -251,14 +255,6 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"sessions": payload})
}
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
writeJSON(w, map[string]any{"user": userPayload(user)})
}
type totpEnableRequest struct {
Code string `json:"code"`
}
+9 -1
View File
@@ -110,7 +110,15 @@ func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode openapi: %v", err)
}
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
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)
}
+24 -4
View File
@@ -17,6 +17,8 @@ import (
"glchat/internal/gateway"
"glchat/internal/httpx"
"glchat/internal/meta"
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
)
@@ -26,6 +28,9 @@ type Deps struct {
Store *store.Store
Auth *auth.Service
Gateway *gateway.Service
// Permissions — общий с Gateway калькулятор прав (необязателен: если не
// передан, сервер создаёт собственный).
Permissions *permissions.Calculator
}
type Server struct {
@@ -34,10 +39,12 @@ type Server struct {
store *store.Store
auth *auth.Service
gateway *gateway.Service
logger *slog.Logger
http *http.Server
static *staticHandler
api huma.API
// perms — движок прав: ручки проверяют права теми же правилами, что Gateway.
perms *permissions.Calculator
logger *slog.Logger
http *http.Server
static *staticHandler
api huma.API
}
func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
@@ -50,12 +57,25 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
logger: logger,
static: newStaticHandler(cfg.WebRoot),
}
switch {
case deps.Permissions != nil:
s.perms = deps.Permissions
case deps.Store != nil:
s.perms = permissions.NewCalculator(source.New(deps.Store))
}
router := chi.NewRouter()
router.Route("/api/v1", func(apiRouter chi.Router) {
// Сессия резолвится один раз на запрос: huma-ручки читают её из контекста.
apiRouter.Use(s.sessionContext)
s.api = s.registerAPI(apiRouter)
s.registerMetaRoutes(s.api)
s.registerAuthRoutes(apiRouter)
if deps.Store != nil {
s.registerUserRoutes(s.api)
s.registerGuildRoutes(s.api)
s.registerInstanceRoutes(s.api)
}
apiRouter.Get("/openapi.json", s.handleOpenAPI)
})
s.registerRoutes(router)
+7 -2
View File
@@ -17,6 +17,8 @@ import (
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/permissions"
"glchat/internal/source"
"glchat/internal/store"
)
@@ -62,8 +64,11 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db
calculator := permissions.NewCalculator(source.New(st))
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
return New(cfg, db, logger, Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
}), db
}
// TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через