Files
glchat/internal/server/cosmetics_test.go
T

1086 lines
44 KiB
Go
Raw Normal View History

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())
}
}