4fb3509128
Фаза 4 (AGENT.md 7.17, 7.18): модерация участников.
- миграция 00011: таблица `guild_bans` (бан переживает исключение участника);
- `PUT /guilds/{id}/bans/{user_id}` — бан с причиной и права `BAN_MEMBERS`:
исключает участника, отключает его от голосовой комнаты, пишет системное
сообщение, аудит и события `MEMBER_REMOVE`/`GUILD_DELETE`;
- `DELETE /guilds/{id}/bans/{user_id}` — снятие бана, `GET .../bans` — список
с автором и причиной (только модераторам);
- вход по приглашению для забаненного отклоняется с `guild.banned`;
- тайм-аут уже был в схеме, но не работал: кэш прав комнат не сбрасывался при
изменениях сервера, поэтому участник с тайм-аутом продолжал писать. Ключ
кэша комнат теперь включает сервер, `InvalidateGuild` чистит и его;
- тайм-аут уходит в `MEMBER_UPDATE` (и снимается событием), истёкшие
тайм-ауты не показываются в профиле участника;
- журнал аудита получил фильтры `action`, `actor_id`, `target_id`, `before_id`;
- тесты: полный цикл бана (включая запрет входа и разбан), права и иерархия,
блокировка сообщений тайм-аутом, запрет тайм-аута на себя, фильтры аудита.
325 lines
13 KiB
Go
325 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Тесты модерации Фазы 4 (AGENT.md 7.17, 7.18): баны, тайм-ауты и фильтры аудита.
|
|
|
|
type banListBody struct {
|
|
Bans []struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
ActorID string `json:"actor_id"`
|
|
ActorName string `json:"actor_name"`
|
|
Reason string `json:"reason"`
|
|
CreatedAt string `json:"created_at"`
|
|
} `json:"bans"`
|
|
}
|
|
|
|
// banFixture поднимает сервер: владелец с сервером и участник, которого банят.
|
|
func banFixture(t *testing.T) (srv *Server, owner *http.Cookie, member *http.Cookie, guildID, memberID string) {
|
|
t.Helper()
|
|
srv, _ = newTestServer(t)
|
|
owner = registerAndLogin(t, srv, "ban_owner", "ban-owner@example.com")
|
|
member = registerAndLogin(t, srv, "ban_member", "ban-member@example.com")
|
|
|
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, owner)
|
|
guild := decodeResponse[struct {
|
|
Guild struct {
|
|
ID string `json:"id"`
|
|
} `json:"guild"`
|
|
}](t, created)
|
|
guildID = guild.Guild.ID
|
|
|
|
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", member)
|
|
profile := decodeResponse[struct {
|
|
User struct {
|
|
ID string `json:"id"`
|
|
} `json:"user"`
|
|
}](t, me)
|
|
memberID = profile.User.ID
|
|
|
|
// Участник вступает по приглашению владельца.
|
|
invite := 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, invite).Invite.Code
|
|
accept := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member)
|
|
if accept.Code != http.StatusOK {
|
|
t.Fatalf("вступление по приглашению = %d (%s)", accept.Code, accept.Body.String())
|
|
}
|
|
return srv, owner, member, guildID, memberID
|
|
}
|
|
|
|
func TestBanMemberFlow(t *testing.T) {
|
|
srv, owner, member, guildID, memberID := banFixture(t)
|
|
|
|
// Бан: участник исключается, запись попадает в список.
|
|
ban := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID,
|
|
`{"reason":"спам"}`, owner)
|
|
if ban.Code != http.StatusOK {
|
|
t.Fatalf("бан = %d (%s)", ban.Code, ban.Body.String())
|
|
}
|
|
bans := decodeResponse[banListBody](t, ban).Bans
|
|
if len(bans) != 1 || bans[0].UserID != memberID || bans[0].Reason != "спам" {
|
|
t.Fatalf("список банов = %+v", bans)
|
|
}
|
|
if bans[0].ActorName == "" || bans[0].CreatedAt == "" {
|
|
t.Fatalf("в бане нет автора или времени: %+v", bans[0])
|
|
}
|
|
|
|
// Исключённый участник больше не видит сервер.
|
|
guilds := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/guilds", "", member)
|
|
list := decodeResponse[struct {
|
|
Guilds []struct {
|
|
ID string `json:"id"`
|
|
} `json:"guilds"`
|
|
}](t, guilds).Guilds
|
|
if len(list) != 0 {
|
|
t.Fatalf("забаненный видит серверы: %+v", list)
|
|
}
|
|
|
|
// Повторный вход по приглашению запрещён даже с новым кодом.
|
|
invite := 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, invite).Invite.Code
|
|
again := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member)
|
|
if again.Code != http.StatusForbidden {
|
|
t.Fatalf("вход забаненного = %d, ожидался 403", again.Code)
|
|
}
|
|
if code := errorCodeOf(t, again); code != "guild.banned" {
|
|
t.Fatalf("код ошибки = %q", code)
|
|
}
|
|
|
|
// Снятие бана возвращает возможность войти.
|
|
unban := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guildID+"/bans/"+memberID, "", owner)
|
|
if unban.Code != http.StatusOK {
|
|
t.Fatalf("снятие бана = %d (%s)", unban.Code, unban.Body.String())
|
|
}
|
|
if bans := decodeResponse[banListBody](t, unban).Bans; len(bans) != 0 {
|
|
t.Fatalf("после снятия бана список = %+v", bans)
|
|
}
|
|
rejoin := doJSON(t, srv, http.MethodPost, "/api/v1/invites/"+code, "", member)
|
|
if rejoin.Code != http.StatusOK {
|
|
t.Fatalf("повторный вход после снятия бана = %d (%s)", rejoin.Code, rejoin.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBanRequiresPermissionAndHierarchy(t *testing.T) {
|
|
srv, owner, member, guildID, memberID := banFixture(t)
|
|
|
|
// Участник без BAN_MEMBERS не может банить и не видит список.
|
|
list := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/bans", "", member)
|
|
if list.Code != http.StatusForbidden {
|
|
t.Fatalf("список банов без прав = %d", list.Code)
|
|
}
|
|
denied := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID,
|
|
`{"reason":"сам себя"}`, member)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("бан без прав = %d", denied.Code)
|
|
}
|
|
|
|
// Владельца забанить нельзя даже администратору сервера.
|
|
self := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
|
|
ownerID := decodeResponse[struct {
|
|
User struct {
|
|
ID string `json:"id"`
|
|
} `json:"user"`
|
|
}](t, self).User.ID
|
|
ownerBan := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+ownerID,
|
|
`{}`, owner)
|
|
if ownerBan.Code != http.StatusForbidden {
|
|
t.Fatalf("бан владельца = %d, ожидался 403", ownerBan.Code)
|
|
}
|
|
}
|
|
|
|
func TestMemberTimeoutBlocksSending(t *testing.T) {
|
|
srv, owner, member, guildID, memberID := banFixture(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
|
|
|
|
// До тайм-аута сообщение отправляется.
|
|
sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"до тайм-аута"}`, member)
|
|
if sent.Code != http.StatusOK {
|
|
t.Fatalf("сообщение до тайм-аута = %d (%s)", sent.Code, sent.Body.String())
|
|
}
|
|
|
|
// Тайм-аут на час: отправка запрещена, состояние видно в участниках.
|
|
until := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
|
|
timeout := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+memberID,
|
|
`{"timeout_until":"`+until+`"}`, owner)
|
|
if timeout.Code != http.StatusOK {
|
|
t.Fatalf("тайм-аут = %d (%s)", timeout.Code, timeout.Body.String())
|
|
}
|
|
blocked := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"во время тайм-аута"}`, member)
|
|
if blocked.Code != http.StatusForbidden {
|
|
t.Fatalf("сообщение в тайм-ауте = %d, ожидался 403", blocked.Code)
|
|
}
|
|
if code := errorCodeOf(t, blocked); code != "perm.denied" && code != "member.timed_out" {
|
|
t.Fatalf("код ошибки тайм-аута = %q", code)
|
|
}
|
|
|
|
members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/members", "", owner)
|
|
found := false
|
|
for _, item := range decodeResponse[struct {
|
|
Members []struct {
|
|
UserID string `json:"user_id"`
|
|
TimeoutUntil string `json:"timeout_until"`
|
|
} `json:"members"`
|
|
}](t, members).Members {
|
|
if item.UserID == memberID {
|
|
found = true
|
|
if item.TimeoutUntil == "" {
|
|
t.Fatal("тайм-аут не виден в списке участников")
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("участник не найден в списке")
|
|
}
|
|
|
|
// Снятие тайм-аута возвращает отправку.
|
|
cleared := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+memberID,
|
|
`{"timeout_until":"2000-01-01T00:00:00Z"}`, owner)
|
|
if cleared.Code != http.StatusOK {
|
|
t.Fatalf("снятие тайм-аута = %d (%s)", cleared.Code, cleared.Body.String())
|
|
}
|
|
restored := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
|
|
`{"content":"после тайм-аута"}`, member)
|
|
if restored.Code != http.StatusOK {
|
|
t.Fatalf("сообщение после тайм-аута = %d (%s)", restored.Code, restored.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTimeoutCannotBeSetOnSelf(t *testing.T) {
|
|
srv, owner, _, guildID, _ := banFixture(t) // участник не нужен: проверяем себя
|
|
self := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner)
|
|
ownerID := decodeResponse[struct {
|
|
User struct {
|
|
ID string `json:"id"`
|
|
} `json:"user"`
|
|
}](t, self).User.ID
|
|
|
|
until := time.Now().UTC().Add(time.Hour).Format(time.RFC3339)
|
|
rec := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/members/"+ownerID,
|
|
`{"timeout_until":"`+until+`"}`, owner)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("тайм-аут на себя = %d, ожидался 403", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuditLogFilters(t *testing.T) {
|
|
srv, owner, _, guildID, memberID := banFixture(t)
|
|
|
|
// Два разных действия: бан участника и создание комнаты.
|
|
doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+memberID, `{"reason":"спам"}`, owner)
|
|
doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/channels",
|
|
`{"name":"комната","type":"text"}`, owner)
|
|
|
|
all := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log?limit=50", "", owner)
|
|
if code := all.Code; code != http.StatusOK {
|
|
t.Fatalf("журнал = %d (%s)", code, all.Body.String())
|
|
}
|
|
entries := decodeResponse[struct {
|
|
Entries []struct {
|
|
ID string `json:"id"`
|
|
Action string `json:"action"`
|
|
Target string `json:"target,omitempty"`
|
|
TargetID string `json:"target_id,omitempty"`
|
|
} `json:"entries"`
|
|
}](t, all).Entries
|
|
if len(entries) == 0 {
|
|
t.Fatal("журнал пуст")
|
|
}
|
|
|
|
// Фильтр по действию.
|
|
bansOnly := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log?action=member.ban", "", owner)
|
|
banEntries := decodeResponse[struct {
|
|
Entries []struct {
|
|
Action string `json:"action"`
|
|
} `json:"entries"`
|
|
}](t, bansOnly).Entries
|
|
if len(banEntries) != 1 || banEntries[0].Action != "member.ban" {
|
|
t.Fatalf("фильтр по действию = %+v", banEntries)
|
|
}
|
|
|
|
// Фильтр по цели: с участником связаны и бан, и принятие приглашения,
|
|
// поэтому проверяем, что все записи про него и есть бан среди них.
|
|
byTarget := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/guilds/"+guildID+"/audit-log?target_id="+memberID, "", owner)
|
|
targetEntries := decodeResponse[struct {
|
|
Entries []struct {
|
|
Action string `json:"action"`
|
|
TargetID string `json:"target_id"`
|
|
} `json:"entries"`
|
|
}](t, byTarget).Entries
|
|
if len(targetEntries) == 0 {
|
|
t.Fatal("фильтр по цели ничего не вернул")
|
|
}
|
|
sawBan := false
|
|
for _, entry := range targetEntries {
|
|
if entry.TargetID != memberID {
|
|
t.Fatalf("в выборке чужая цель: %+v", entry)
|
|
}
|
|
sawBan = sawBan || entry.Action == "member.ban"
|
|
}
|
|
if !sawBan {
|
|
t.Fatalf("в выборке нет бана: %+v", targetEntries)
|
|
}
|
|
|
|
// Комбинация фильтров: действие и цель одновременно.
|
|
combined := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/guilds/"+guildID+"/audit-log?action=member.ban&target_id="+memberID, "", owner)
|
|
combinedEntries := decodeResponse[struct {
|
|
Entries []struct {
|
|
Action string `json:"action"`
|
|
} `json:"entries"`
|
|
}](t, combined).Entries
|
|
if len(combinedEntries) != 1 || combinedEntries[0].Action != "member.ban" {
|
|
t.Fatalf("комбинированный фильтр = %+v", combinedEntries)
|
|
}
|
|
|
|
// Постранично: before_id отдаёт только более старые записи.
|
|
newest := entries[0].ID
|
|
older := doJSON(t, srv, http.MethodGet,
|
|
"/api/v1/guilds/"+guildID+"/audit-log?before_id="+newest, "", owner)
|
|
olderEntries := decodeResponse[struct {
|
|
Entries []struct {
|
|
ID string `json:"id"`
|
|
} `json:"entries"`
|
|
}](t, older).Entries
|
|
for _, entry := range olderEntries {
|
|
if entry.ID >= newest {
|
|
t.Fatalf("before_id вернул свежую запись: %s ≥ %s", entry.ID, newest)
|
|
}
|
|
}
|
|
if len(olderEntries) != len(entries)-1 {
|
|
t.Fatalf("страница = %d записей, ожидалось %d", len(olderEntries), len(entries)-1)
|
|
}
|
|
|
|
// Чужой сервер и отсутствие прав: журнал недоступен.
|
|
stranger := registerAndLogin(t, srv, "audit_stranger", "audit-stranger@example.com")
|
|
denied := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guildID+"/audit-log", "", stranger)
|
|
if denied.Code != http.StatusNotFound && denied.Code != http.StatusForbidden {
|
|
t.Fatalf("журнал чужого сервера = %d", denied.Code)
|
|
}
|
|
}
|