2026-09-21 01:28:48 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"mime/multipart"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 01:40:17 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 01:48:56 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-09-21 18:08:21 +03:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 18:10:39 +03:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 19:21:00 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-09-21 19:25:06 +03:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 19:30:25 +03:00
|
|
|
|
|
|
|
|
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 ""
|
|
|
|
|
}
|
2026-09-21 19:45:25 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-21 22:37:47 +03:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|