Files
glchat/internal/server/cosmetics_test.go
T
grendervill af03cfb063 feat(security): проверка Origin, строгий CSP и лимиты загрузок
- middleware OriginCheck: state-changing запросы с чужого Origin отклоняются
  (AGENT.md 9.7); запросы без Origin пропускаются — cookie уже SameSite=Lax
- CSP перечисляет директивы явно (script-src/worker-src/manifest-src 'self',
  style-src с inline для React, img-src с внешними https для аватаров вебхуков,
  connect-src с доменом файлов и LiveKit); добавлены Permissions-Policy,
  Cross-Origin-Opener-Policy и X-Permitted-Cross-Domain-Policies
- лимиты по AGENT.md 8.6: загрузки 10/мин и 100/сутки (вложения и аватары),
  реакции 20/мин; администратор инстанса лимиты обходит
- step-up: смена прав роли (в теле PATCH) и удаление сервера (перед вызовом
  /auth/step-up) требуют свежего подтверждения личности
- тесты: Origin (свой/чужой/GET), состав CSP, лимит реакций, step-up на права
  роли; исправлен вызов NewRateLimiter (второй аргумент — burst, не окно)
2026-09-22 00:40:10 +03:00

1086 lines
44 KiB
Go
Raw 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 server
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"glchat/internal/store"
)
// Тесты оформления профиля (AGENT.md 7.2, 7.4): галерея сервера, оформление
// роли, личные стили и поэлементный приоритет с откатом при потере роли.
// uploadCosmetic загружает рамку или иконку в галерею сервера.
func uploadCosmetic(t *testing.T, srv *Server, guildID, kind, name string, cookie *http.Cookie) (id string, status int) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("kind", kind); err != nil {
t.Fatalf("multipart kind: %v", err)
}
if err := writer.WriteField("name", name); err != nil {
t.Fatalf("multipart name: %v", err)
}
part, err := writer.CreateFormFile("file", name+".png")
if err != nil {
t.Fatalf("multipart file: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/guilds/"+guildID+"/cosmetics", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(cookie)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
return "", rec.Code
}
payload := decodeResponse[struct {
Cosmetic struct {
ID string `json:"id"`
} `json:"cosmetic"`
}](t, rec)
return payload.Cosmetic.ID, rec.Code
}
type effectiveCosmetics struct {
FrameID string `json:"frame_id"`
BadgeID string `json:"badge_id"`
NickColor *int64 `json:"nick_color"`
NickEffect string `json:"nick_effect"`
}
// memberCosmetics читает вычисленное оформление участника из списка участников.
func memberCosmetics(t *testing.T, srv *Server, guildID, userID string, cookie *http.Cookie) effectiveCosmetics {
t.Helper()
rec := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", cookie)
if rec.Code != http.StatusOK {
t.Fatalf("members = %d (%s)", rec.Code, rec.Body.String())
}
list := decodeResponse[struct {
Members []struct {
UserID string `json:"user_id"`
Cosmetics *effectiveCosmetics `json:"cosmetics"`
} `json:"members"`
}](t, rec)
for _, member := range list.Members {
if member.UserID == userID {
if member.Cosmetics == nil {
return effectiveCosmetics{}
}
return *member.Cosmetics
}
}
t.Fatalf("участник %s не найден", userID)
return effectiveCosmetics{}
}
// cosmeticsFixture — сервер с владельцем и участником.
func cosmeticsFixture(t *testing.T) (srv *Server, owner, member *http.Cookie, guildID, memberID string) {
t.Helper()
srv, owner, member, guildID, memberID = banFixture(t)
return srv, owner, member, guildID, memberID
}
// styleScopePath собирает путь личного стиля.
func styleScopePath(scope string) string {
return "/api/v1/users/@me/styles/" + url.PathEscape(scope)
}
func TestCosmeticGalleryLifecycle(t *testing.T) {
srv, owner, member, guildID, _ := cosmeticsFixture(t)
frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
if status != http.StatusOK || frameID == "" {
t.Fatalf("загрузка рамки: статус %d", status)
}
badgeID, status := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
if status != http.StatusOK || badgeID == "" {
t.Fatalf("загрузка иконки: статус %d", status)
}
// Галерею видят участники сервера, а не посторонние.
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", member)
if list.Code != http.StatusOK {
t.Fatalf("список галереи = %d (%s)", list.Code, list.Body.String())
}
payload := decodeResponse[struct {
Cosmetics []struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
} `json:"cosmetics"`
}](t, list)
if len(payload.Cosmetics) != 2 {
t.Fatalf("в галерее %d элементов, ожидалось 2", len(payload.Cosmetics))
}
// Участник без MANAGE_ROLES не может пополнять галерею.
if _, status := uploadCosmetic(t, srv, guildID, "frame", "Чужая", member); status != http.StatusForbidden {
t.Fatalf("загрузка участником: статус %d, ожидался 403", status)
}
// Переименование и удаление.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID,
`{"name":"Новый щит"}`, owner)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование = %d (%s)", renamed.Code, renamed.Body.String())
}
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/cosmetics/"+badgeID, "", owner)
if deleted.Code != http.StatusOK {
t.Fatalf("удаление = %d (%s)", deleted.Code, deleted.Body.String())
}
after := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/cosmetics", "", owner)
remaining := decodeResponse[struct {
Cosmetics []struct {
ID string `json:"id"`
} `json:"cosmetics"`
}](t, after)
if len(remaining.Cosmetics) != 1 || remaining.Cosmetics[0].ID != frameID {
t.Fatalf("после удаления осталось %d элементов", len(remaining.Cosmetics))
}
}
func TestRoleCosmeticsAndPriority(t *testing.T) {
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
badgeID, _ := uploadCosmetic(t, srv, guildID, "badge", "Щит", owner)
// Роль с рамкой и цветом ника, вторая роль — с иконкой: элементы
// складываются поэлементно (AGENT.md 7.2, 7.4).
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`","nick_color":15158332}`, owner)
if created.Code != http.StatusOK {
t.Fatalf("создание роли = %d (%s)", created.Code, created.Body.String())
}
role := decodeResponse[struct {
Role struct {
ID string `json:"id"`
CosmeticFrameID string `json:"cosmetic_frame_id"`
NickColor *int64 `json:"nick_color"`
} `json:"role"`
}](t, created)
if role.Role.CosmeticFrameID != frameID || role.Role.NickColor == nil {
t.Fatalf("оформление роли не сохранилось: %s", created.Body.String())
}
second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Щитоносец","cosmetic_badge_id":"`+badgeID+`"}`, owner)
roleWithBadge := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, second).Role.ID
for _, roleID := range []string{role.Role.ID, roleWithBadge} {
granted := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner)
if granted.Code != http.StatusOK {
t.Fatalf("выдача роли %s = %d (%s)", roleID, granted.Code, granted.Body.String())
}
}
// Оформление ролей применяется: рамка, иконка и цвет ника.
effective := memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != frameID || effective.BadgeID != badgeID {
t.Fatalf("оформление ролей: %+v", effective)
}
if effective.NickColor == nil || *effective.NickColor != 15158332 {
t.Fatalf("цвет ника из роли = %v", effective.NickColor)
}
// Личный стиль добавляет эффект ника, элементы ролей остаются.
style := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
`{"frame_id":"`+frameID+`","badge_id":"`+badgeID+`","nick_effect":"glow"}`, member)
if style.Code != http.StatusOK {
t.Fatalf("личный стиль = %d (%s)", style.Code, style.Body.String())
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != frameID || effective.BadgeID != badgeID || effective.NickEffect != "glow" {
t.Fatalf("после личного стиля: %+v", effective)
}
// Потеря роли с рамкой: рамка исчезает, иконка и эффект остаются.
revoked := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+role.Role.ID, "", owner)
if revoked.Code != http.StatusOK {
t.Fatalf("снятие роли = %d (%s)", revoked.Code, revoked.Body.String())
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.FrameID != "" {
t.Fatalf("рамка осталась после снятия роли: %+v", effective)
}
if effective.BadgeID != badgeID || effective.NickEffect != "glow" {
t.Fatalf("иконка или эффект потерялись: %+v", effective)
}
// Потеря роли с иконкой откатывает и личный элемент: он больше не выдан.
if rec := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleWithBadge, "", owner); rec.Code != http.StatusOK {
t.Fatalf("снятие второй роли = %d", rec.Code)
}
effective = memberCosmetics(t, srv, guildID, memberID, member)
if effective.BadgeID != "" {
t.Fatalf("иконка осталась без роли: %+v", effective)
}
if effective.NickEffect != "glow" {
t.Fatalf("эффект ника потерялся: %q", effective.NickEffect)
}
}
func TestPersonalStyleAvailability(t *testing.T) {
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
frameID, _ := uploadCosmetic(t, srv, guildID, "frame", "Золотая", owner)
// Рамка не выдана ролями: выбрать её нельзя.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath(guildID),
`{"frame_id":"`+frameID+`"}`, member); rec.Code != http.StatusForbidden {
t.Fatalf("недоступная рамка = %d (%s)", rec.Code, rec.Body.String())
}
// Цвет вне палитры отклоняется.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
`{"nick_color":1}`, member); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("цвет вне палитры = %d (%s)", rec.Code, rec.Body.String())
}
// Неизвестный эффект отклоняется.
if rec := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
`{"nick_effect":"rainbow"}`, member); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("неизвестный эффект = %d (%s)", rec.Code, rec.Body.String())
}
// Выдаём роль с рамкой и иконкой.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Хранитель","cosmetic_frame_id":"`+frameID+`"}`, owner)
roleID := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, created).Role.ID
if rec := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guildID+"/members/"+memberID+"/roles/"+roleID, "", owner); rec.Code != http.StatusOK {
t.Fatalf("выдача роли = %d", rec.Code)
}
// Теперь рамка доступна и в пер-серверном, и в глобальном стиле.
for _, scope := range []string{guildID, "global"} {
rec := doJSON(t, srv, http.MethodPut, styleScopePath(scope),
`{"frame_id":"`+frameID+`","nick_effect":"gradient"}`, member)
if rec.Code != http.StatusOK {
t.Fatalf("стиль %s = %d (%s)", scope, rec.Code, rec.Body.String())
}
}
// Список стилей отдаёт доступные элементы и встроенный набор эффектов.
styles := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/styles", "", member)
list := decodeResponse[struct {
Styles []struct {
Scope string `json:"scope"`
FrameIDs []string `json:"frame_ids"`
Effects []string `json:"effects"`
} `json:"styles"`
}](t, styles)
if len(list.Styles) != 2 {
t.Fatalf("стилей %d, ожидалось 2", len(list.Styles))
}
if len(list.Styles[0].FrameIDs) != 1 || list.Styles[0].FrameIDs[0] != frameID {
t.Fatalf("доступные рамки: %+v", list.Styles[0].FrameIDs)
}
if len(list.Styles[0].Effects) != len(store.NickEffects()) {
t.Fatalf("эффектов %d", len(list.Styles[0].Effects))
}
// Сброс стиля: глобальный стиль больше не применяется.
if rec := doJSON(t, srv, http.MethodDelete, styleScopePath("global"), "", member); rec.Code != http.StatusOK {
t.Fatalf("сброс стиля = %d", rec.Code)
}
}
func TestSelfRoleAssignmentForCosmetics(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
frameID, status := uploadCosmetic(t, srv, guildID, "frame", "Своя", owner)
if status != http.StatusOK {
t.Fatalf("загрузка рамки: статус %d", status)
}
// Свою роль выдать можно: иначе владелец не примерит оформление.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Своя роль","cosmetic_frame_id":"`+frameID+`"}`, owner)
roleID := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, created).Role.ID
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
granted := doJSON(t, srv, http.MethodPut,
"/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner)
if granted.Code != http.StatusOK {
t.Fatalf("выдача роли себе = %d (%s)", granted.Code, granted.Body.String())
}
effective := memberCosmetics(t, srv, guildID, ownerID, owner)
if effective.FrameID != frameID {
t.Fatalf("рамка не применилась: %+v", effective)
}
// Свою роль можно и снять (владелец остаётся владельцем).
revoked := doJSON(t, srv, http.MethodDelete,
"/api/v1/guilds/"+guildID+"/members/"+ownerID+"/roles/"+roleID, "", owner)
if revoked.Code != http.StatusOK {
t.Fatalf("снятие своей роли = %d (%s)", revoked.Code, revoked.Body.String())
}
// Самомодерация по-прежнему запрещена: кик себя отклоняется.
kick := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/members/"+ownerID, "", owner)
if kick.Code != http.StatusForbidden {
t.Fatalf("кик себя = %d, ожидался 403", kick.Code)
}
}
func TestFriendsListCarriesCosmetics(t *testing.T) {
srv, owner, member, guildID, memberID := cosmeticsFixture(t)
// Стиль «для друзей» с эффектом и цветом из палитры.
style := doJSON(t, srv, http.MethodPut, styleScopePath("global"),
`{"nick_color":3066993,"nick_effect":"gradient"}`, member)
if style.Code != http.StatusOK {
t.Fatalf("стиль «для друзей» = %d (%s)", style.Code, style.Body.String())
}
// Дружба в обе стороны.
request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
`{"user_id":"`+memberID+`"}`, owner)
if request.Code != http.StatusOK && request.Code != http.StatusCreated {
t.Fatalf("заявка в друзья = %d (%s)", request.Code, request.Body.String())
}
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
accept := doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member)
if accept.Code != http.StatusOK {
t.Fatalf("принятие заявки = %d (%s)", accept.Code, accept.Body.String())
}
friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner)
list := decodeResponse[struct {
Friends []struct {
UserID string `json:"user_id"`
Cosmetics *effectiveCosmetics `json:"cosmetics"`
} `json:"friends"`
}](t, friends)
if len(list.Friends) != 1 {
t.Fatalf("друзей %d, ожидался 1 (%s)", len(list.Friends), friends.Body.String())
}
got := list.Friends[0].Cosmetics
if got == nil || got.NickEffect != "gradient" || got.NickColor == nil || *got.NickColor != 3066993 {
t.Fatalf("оформление друга = %+v", got)
}
_ = guildID
}
func TestChannelBackgroundUploadAndDelete(t *testing.T) {
srv, owner, member, guildID, _ := cosmeticsFixture(t)
channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", owner)
channelID := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"channels"`
}](t, channels).Channels[0].ID
// Загрузка фона владельцем: multipart, картинка.
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "bg.png")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/channels/"+channelID+"/background", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(owner)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
t.Fatalf("загрузка фона = %d (%s)", rec.Code, rec.Body.String())
}
uploaded := decodeResponse[struct {
Channel struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"channel"`
}](t, rec)
if uploaded.Channel.BackgroundFileID == "" {
t.Fatalf("фон не применился: %s", rec.Body.String())
}
// Участник без MANAGE_CHANNEL_BACKGROUND фон не меняет.
request = httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/channels/"+channelID+"/background", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(member)
denied := httptest.NewRecorder()
srv.Handler().ServeHTTP(denied, request)
if denied.Code != http.StatusForbidden {
t.Fatalf("фон участником = %d, ожидался 403 (%s)", denied.Code, denied.Body.String())
}
// Фон виден в списке комнат сервера.
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", member)
payload := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
BackgroundFileID string `json:"background_file_id"`
} `json:"channels"`
}](t, list)
found := false
for _, channel := range payload.Channels {
if channel.ID == channelID && channel.BackgroundFileID == uploaded.Channel.BackgroundFileID {
found = true
}
}
if !found {
t.Fatalf("фон не виден участнику: %s", list.Body.String())
}
// Снятие фона.
removed := doJSON(t, srv, http.MethodDelete, "/api/v1/channels/"+channelID+"/background", "", owner)
if removed.Code != http.StatusOK {
t.Fatalf("снятие фона = %d (%s)", removed.Code, removed.Body.String())
}
after := decodeResponse[struct {
Channel struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"channel"`
}](t, removed)
if after.Channel.BackgroundFileID != "" {
t.Fatalf("фон не снят: %s", removed.Body.String())
}
}
func TestMembersCarrySystemBadges(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
// Бейдж назначает система (AGENT.md 7.2): в тесте ставим его напрямую.
id, err := parseID("user_id", ownerID)
if err != nil {
t.Fatalf("parse user id: %v", err)
}
if err := srv.store.SetUserBadges(t.Context(), id, []string{"instance_admin"}); err != nil {
t.Fatalf("set badges: %v", err)
}
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner)
members := decodeResponse[struct {
Members []struct {
UserID string `json:"user_id"`
Badges []string `json:"badges"`
} `json:"members"`
}](t, list)
var found bool
for _, entry := range members.Members {
if entry.Badges == nil {
t.Fatalf("бейджи должны быть списком, а не null")
}
if entry.UserID != ownerID {
continue
}
found = true
if !contains(entry.Badges, "instance_admin") {
t.Fatalf("бейджи владельца = %v, ожидался instance_admin", entry.Badges)
}
}
if !found {
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
}
}
func TestBlockUserStopsDMsAndRequests(t *testing.T) {
srv, owner, member, _, memberID := cosmeticsFixture(t)
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
ownerID := decodeResponse[struct {
User struct {
ID string `json:"id"`
} `json:"user"`
}](t, me).User.ID
// Заводим дружбу, затем блокируем: связь снимается, остаётся блокировка.
request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
`{"user_id":"`+memberID+`"}`, owner)
if request.Code != http.StatusOK {
t.Fatalf("заявка = %d (%s)", request.Code, request.Body.String())
}
if accept := doJSON(t, srv, http.MethodPost,
"/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member); accept.Code != http.StatusOK {
t.Fatalf("принятие = %d", accept.Code)
}
blocked := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner)
if blocked.Code != http.StatusOK {
t.Fatalf("блокировка = %d (%s)", blocked.Code, blocked.Body.String())
}
relations := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner)
list := decodeResponse[struct {
Friends []struct {
UserID string `json:"user_id"`
} `json:"friends"`
Blocked []struct {
UserID string `json:"user_id"`
} `json:"blocked"`
}](t, relations)
if len(list.Friends) != 0 || len(list.Blocked) != 1 || list.Blocked[0].UserID != memberID {
t.Fatalf("после блокировки: друзей %d, заблокированных %d", len(list.Friends), len(list.Blocked))
}
// Заявка в друзья от заблокированного отклоняется.
again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships",
`{"user_id":"`+ownerID+`"}`, member)
if again.Code != http.StatusForbidden {
t.Fatalf("заявка от заблокированного = %d, ожидался 403 (%s)", again.Code, again.Body.String())
}
// Личная беседа не открывается и сообщения в неё не уходят.
open := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+memberID+`"}`, owner)
if open.Code != http.StatusForbidden {
t.Fatalf("беседа с заблокированным = %d, ожидался 403 (%s)", open.Code, open.Body.String())
}
// Снятие блокировки возвращает возможность писать.
if unblock := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/blocks/"+memberID, "", owner); unblock.Code != http.StatusOK {
t.Fatalf("снятие блокировки = %d", unblock.Code)
}
reopened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels",
`{"recipient_id":"`+memberID+`"}`, owner)
if reopened.Code != http.StatusOK {
t.Fatalf("беседа после снятия блокировки = %d (%s)", reopened.Code, reopened.Body.String())
}
channelID := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, reopened).Channel.ID
// Блокируем снова уже при существующей беседе: сообщение не отправляется.
if rec := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner); rec.Code != http.StatusOK {
t.Fatalf("повторная блокировка = %d", rec.Code)
}
post := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"привет"}`, owner)
if post.Code != http.StatusForbidden {
t.Fatalf("сообщение заблокированному = %d, ожидался 403 (%s)", post.Code, post.Body.String())
}
if code := errorCodeOf(t, post); code != "dm.blocked" {
t.Fatalf("код ошибки = %q", code)
}
_ = ownerID
}
func TestInviteBackgroundAndPreview(t *testing.T) {
srv, owner, member, guildID, _ := cosmeticsFixture(t)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites",
`{"max_uses":5,"max_age_seconds":600}`, owner)
code := decodeResponse[struct {
Invite struct {
Code string `json:"code"`
} `json:"invite"`
}](t, created).Invite.Code
if code == "" {
t.Fatalf("приглашение не создано: %s", created.Body.String())
}
// Предпросмотр: карточка сервера и пригласивший.
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
body := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
Guild struct {
Name string `json:"name"`
IsMember bool `json:"is_member"`
Inviter struct {
Username string `json:"username"`
} `json:"inviter"`
} `json:"guild"`
}](t, preview)
if body.Guild.Name == "" || body.Guild.Inviter.Username == "" {
t.Fatalf("предпросмотр без карточки: %s", preview.Body.String())
}
// Загрузка переопределения splash: multipart, картинка.
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "invite.png")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/invites/"+code+"/background", &buf)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(owner)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
t.Fatalf("загрузка картинки приглашения = %d (%s)", rec.Code, rec.Body.String())
}
uploaded := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, rec)
if uploaded.Invite.BackgroundFileID == "" {
t.Fatalf("переопределение не сохранилось: %s", rec.Body.String())
}
// Карточка приглашения отдаёт переопределение и без сессии не работает.
after := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
fileID := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, after).Invite.BackgroundFileID
if fileID != uploaded.Invite.BackgroundFileID {
t.Fatalf("переопределение не видно в предпросмотре: %s", after.Body.String())
}
// Посторонний не может менять оформление чужого приглашения.
outsider := registerAndLogin(t, srv, "invite_guest", "invite-guest@example.com")
denied := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", outsider)
if denied.Code != http.StatusForbidden && denied.Code != http.StatusNotFound {
t.Fatalf("снятие чужим = %d, ожидался отказ (%s)", denied.Code, denied.Body.String())
}
// Снятие переопределения создателем.
cleared := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", owner)
if cleared.Code != http.StatusOK {
t.Fatalf("снятие переопределения = %d (%s)", cleared.Code, cleared.Body.String())
}
final := decodeResponse[struct {
Invite struct {
BackgroundFileID string `json:"background_file_id"`
} `json:"invite"`
}](t, cleared)
if final.Invite.BackgroundFileID != "" {
t.Fatalf("переопределение не снято: %s", cleared.Body.String())
}
}
func TestPresentationFilesArePublic(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
// Оформление сервера: splash загружается как обычно.
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "splash.png")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := part.Write(pngBytes()); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/guilds/"+guildID+"/appearance/splash", &buf)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(owner)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusOK {
t.Fatalf("загрузка splash = %d (%s)", rec.Code, rec.Body.String())
}
splashID := decodeResponse[struct {
Guild struct {
SplashFileID string `json:"splash_file_id"`
} `json:"guild"`
}](t, rec).Guild.SplashFileID
// Без сессии картинка оформления отдаётся: её показывают до входа.
anonymous := doJSON(t, srv, http.MethodGet, "/files/"+splashID, "")
if anonymous.Code != http.StatusOK {
t.Fatalf("splash без сессии = %d, ожидался 200", anonymous.Code)
}
// Обычное вложение без сессии по-прежнему закрыто.
var fileBuf bytes.Buffer
fileWriter := multipart.NewWriter(&fileBuf)
filePart, err := fileWriter.CreateFormFile("file", "secret.txt")
if err != nil {
t.Fatalf("multipart: %v", err)
}
if _, err := filePart.Write([]byte("секрет")); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := fileWriter.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
uploadReq := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/channels/"+guildChannelID(t, srv, guildID, owner)+"/files", &fileBuf)
uploadReq.Header.Set("Content-Type", fileWriter.FormDataContentType())
uploadReq.AddCookie(owner)
uploadRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(uploadRec, uploadReq)
if uploadRec.Code != http.StatusOK {
t.Fatalf("загрузка вложения = %d (%s)", uploadRec.Code, uploadRec.Body.String())
}
attachmentID := decodeResponse[struct {
File struct {
FileID string `json:"file_id"`
} `json:"file"`
}](t, uploadRec).File.FileID
if rec := doJSON(t, srv, http.MethodGet, "/files/"+attachmentID, ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("вложение без сессии = %d, ожидался 401", rec.Code)
}
}
// guildChannelID возвращает первую текстовую комнату сервера.
func guildChannelID(t *testing.T, srv *Server, guildID string, cookie *http.Cookie) string {
t.Helper()
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/channels", "", cookie)
channels := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"channels"`
}](t, list)
for _, channel := range channels.Channels {
if channel.Type == "text" {
return channel.ID
}
}
t.Fatal("текстовая комната не найдена")
return ""
}
func TestStaticServesPWAAssets(t *testing.T) {
srv, _ := newTestServer(t)
// В тестовом каталоге лежит только index.html: проверяем типы по расширению.
cases := map[string]string{
"/manifest.webmanifest": "application/manifest+json",
"/sw.js": "text/javascript; charset=utf-8",
"/offline.html": "text/html; charset=utf-8",
}
for path, want := range cases {
rec := doJSON(t, srv, http.MethodGet, path, "")
if rec.Code != http.StatusOK {
t.Fatalf("%s = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != want {
t.Fatalf("%s: Content-Type = %q, ожидался %q", path, got, want)
}
}
}
func TestInstanceAdminActions(t *testing.T) {
srv, admin, member, guildID, memberID := cosmeticsFixture(t)
// Первый зарегистрированный пользователь — администратор инстанса.
promoteAdmin(t, srv, "ban-owner@example.com")
// Переименование чужого сервера администратором.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+guildID,
`{"name":"Новое имя","description":"Описание"}`, admin)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование сервера = %d (%s)", renamed.Code, renamed.Body.String())
}
detail := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID, "", admin)
guild := decodeResponse[struct {
Guild struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"guild"`
}](t, detail).Guild
if guild.Name != "Новое имя" || guild.Description != "Описание" {
t.Fatalf("сервер не обновлён: %+v", guild)
}
// Сначала проверим, что обычный участник админ-ручки не трогает: дальше его
// сессия будет отозвана сбросом пароля.
if rec := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", member); rec.Code != http.StatusForbidden {
t.Fatalf("удаление без прав = %d, ожидался 403 (%s)", rec.Code, rec.Body.String())
}
// Сброс пароля участнику: пароль выдаётся один раз, сессии отзываются.
before := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`)
if before.Code != http.StatusOK {
t.Fatalf("вход до сброса = %d (%s)", before.Code, before.Body.String())
}
reset := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+memberID+"/reset-password", "", admin)
if reset.Code != http.StatusOK {
t.Fatalf("сброс пароля = %d (%s)", reset.Code, reset.Body.String())
}
password := decodeResponse[struct {
Password string `json:"password"`
SessionsRevoked int64 `json:"sessions_revoked"`
}](t, reset)
if len(password.Password) < 12 {
t.Fatalf("временный пароль слишком короткий: %q", password.Password)
}
if password.SessionsRevoked < 1 {
t.Fatalf("сессии не отозваны: %d", password.SessionsRevoked)
}
// Старый пароль больше не работает, новый — работает.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"correct-horse-battery"}`); rec.Code == http.StatusOK {
t.Fatal("старый пароль продолжает работать")
}
relogin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"ban-member@example.com","password":"`+password.Password+`"}`)
if relogin.Code != http.StatusOK {
t.Fatalf("вход с новым паролем = %d (%s)", relogin.Code, relogin.Body.String())
}
// Выход со всех устройств.
logout := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+memberID+"/logout", "", admin)
if logout.Code != http.StatusOK {
t.Fatalf("выход со всех устройств = %d (%s)", logout.Code, logout.Body.String())
}
if rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member); rec.Code != http.StatusUnauthorized {
t.Fatalf("сессия участника жива: %d", rec.Code)
}
// Администратор удаляет пользователя: мягкое удаление (AGENT.md 6.4).
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/instance/users/"+memberID, "", admin)
if deleted.Code != http.StatusOK {
t.Fatalf("удаление пользователя = %d (%s)", deleted.Code, deleted.Body.String())
}
users := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users", "", admin)
list := decodeResponse[struct {
Users []struct {
UserID string `json:"id"`
} `json:"users"`
}](t, users)
for _, user := range list.Users {
if user.UserID == memberID {
t.Fatal("удалённый пользователь остался в списке")
}
}
}
func TestInstanceMediaLimitsEnforced(t *testing.T) {
srv, admin, _, guildID, _ := cosmeticsFixture(t)
promoteAdmin(t, srv, "ban-owner@example.com")
// Ограничиваем эмодзи 64 КБ и пробуем загрузить картинку больше лимита.
patched := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_emoji_size":65536}`, admin)
if patched.Code != http.StatusOK {
t.Fatalf("настройки = %d (%s)", patched.Code, patched.Body.String())
}
settings := doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "", admin)
payload := decodeResponse[struct {
Settings struct {
MaxEmojiSize int64 `json:"max_emoji_size"`
} `json:"settings"`
}](t, settings)
if payload.Settings.MaxEmojiSize != 65536 {
t.Fatalf("лимит эмодзи = %d", payload.Settings.MaxEmojiSize)
}
big := make([]byte, 70<<10)
copy(big, pngBytes())
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if err := writer.WriteField("name", "big_emoji"); err != nil {
t.Fatalf("multipart name: %v", err)
}
part, err := writer.CreateFormFile("file", "big.png")
if err != nil {
t.Fatalf("multipart file: %v", err)
}
if _, err := part.Write(big); err != nil {
t.Fatalf("multipart write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("multipart close: %v", err)
}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
"/api/v1/guilds/"+guildID+"/emojis", &buf)
request.Header.Set("Content-Type", writer.FormDataContentType())
request.AddCookie(admin)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, request)
if rec.Code != http.StatusRequestEntityTooLarge && rec.Code != http.StatusBadRequest {
t.Fatalf("загрузка сверх лимита = %d, ожидался отказ (%s)", rec.Code, rec.Body.String())
}
// Возвращаем лимит по умолчанию.
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_emoji_size":524288}`, admin); rec.Code != http.StatusOK {
t.Fatalf("возврат лимита = %d", rec.Code)
}
}
func TestOriginCheckRejectsForeignOrigin(t *testing.T) {
srv, owner, _, _, _ := cosmeticsFixture(t)
// Свой Origin проходит.
allowed := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/guilds",
strings.NewReader(`{"name":"Оригин"}`))
allowed.Header.Set("Content-Type", "application/json")
allowed.Header.Set("Origin", "https://gl.mhspx.su")
allowed.AddCookie(owner)
allowedRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(allowedRec, allowed)
if allowedRec.Code != http.StatusOK {
t.Fatalf("свой Origin = %d (%s)", allowedRec.Code, allowedRec.Body.String())
}
// Чужой Origin отклоняется до обработчика.
foreign := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/guilds",
strings.NewReader(`{"name":"Чужой"}`))
foreign.Header.Set("Content-Type", "application/json")
foreign.Header.Set("Origin", "https://evil.example")
foreign.AddCookie(owner)
foreignRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(foreignRec, foreign)
if foreignRec.Code != http.StatusForbidden {
t.Fatalf("чужой Origin = %d, ожидался 403 (%s)", foreignRec.Code, foreignRec.Body.String())
}
if code := errorCodeOf(t, foreignRec); code != "origin.forbidden" {
t.Fatalf("код ошибки = %q", code)
}
// GET с чужого Origin не блокируем: состояние он не меняет.
read := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/users/@me", nil)
read.Header.Set("Origin", "https://evil.example")
read.AddCookie(owner)
readRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(readRec, read)
if readRec.Code != http.StatusOK {
t.Fatalf("GET с чужого Origin = %d", readRec.Code)
}
}
func TestSecurityHeadersPolicy(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
policy := rec.Header().Get("Content-Security-Policy")
for _, directive := range []string{
"default-src 'self'",
"script-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"connect-src 'self'",
} {
if !strings.Contains(policy, directive) {
t.Fatalf("в CSP нет %q: %s", directive, policy)
}
}
if strings.Contains(policy, "unsafe-eval") {
t.Fatalf("CSP разрешает unsafe-eval: %s", policy)
}
if got := rec.Header().Get("Permissions-Policy"); !strings.Contains(got, "camera=(self)") {
t.Fatalf("Permissions-Policy = %q", got)
}
if rec.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
t.Fatalf("COOP = %q", rec.Header().Get("Cross-Origin-Opener-Policy"))
}
}
func TestUploadAndReactionLimits(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
channelID := guildChannelID(t, srv, guildID, owner)
// Реакции: 20 в минуту на пользователя (AGENT.md 8.6).
message := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"реакции"}`, owner)
messageID := decodeResponse[struct {
Message struct {
ID string `json:"id"`
} `json:"message"`
}](t, message).Message.ID
limited := false
for i := 0; i < 25; i++ {
emoji := url.PathEscape("👍")
rec := doJSON(t, srv, http.MethodPut,
"/api/v1/channels/"+channelID+"/messages/"+messageID+"/reactions/"+emoji, "", owner)
if rec.Code == http.StatusTooManyRequests {
limited = true
break
}
}
if !limited {
t.Fatal("лимит реакций не сработал")
}
}
func TestRolePermissionChangeRequiresStepUp(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Тихая"}`, owner)
roleID := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, created).Role.ID
// Смена прав без подтверждения отклоняется.
denied := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"permissions":"VIEW_GUILD|SEND_MESSAGES"}`, owner)
if denied.Code != http.StatusForbidden {
t.Fatalf("смена прав без step-up = %d (%s)", denied.Code, denied.Body.String())
}
if code := errorCodeOf(t, denied); code != "auth.step_up_required" {
t.Fatalf("код ошибки = %q", code)
}
// Переименование роли step-up не требует: права не меняются.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"name":"Громкая"}`, owner)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование роли = %d (%s)", renamed.Code, renamed.Body.String())
}
// После подтверждения паролем права меняются.
stepped := doJSON(t, srv, http.MethodPost, "/api/v1/auth/step-up",
`{"password":"correct-horse-battery"}`, owner)
if stepped.Code != http.StatusOK {
t.Fatalf("step-up = %d (%s)", stepped.Code, stepped.Body.String())
}
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"permissions":"VIEW_GUILD|SEND_MESSAGES"}`, owner)
if updated.Code != http.StatusOK {
t.Fatalf("смена прав после step-up = %d (%s)", updated.Code, updated.Body.String())
}
}