af03cfb063
- 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, не окно)
1086 lines
44 KiB
Go
1086 lines
44 KiB
Go
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())
|
||
}
|
||
}
|