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) } // Открытый сервер (главный открыт всегда) тоже закрыт для забаненного: // иначе бан обходится нажатием «Войти» вместо приглашения (AGENT.md 7.20). byJoin := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/join", "", member) if byJoin.Code != http.StatusForbidden { t.Fatalf("вход забаненного в публичный сервер = %d, ожидался 403", byJoin.Code) } if code := errorCodeOf(t, byJoin); 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()) } } // TestInstanceAdminCannotModerateSelf: администратор инстанса обходит иерархию // ролей, но не запрет на модерацию себя — иначе он исключает себя из сервера. func TestInstanceAdminCannotModerateSelf(t *testing.T) { srv, owner, _, guildID, _ := banFixture(t) promoteAdmin(t, srv, "ban-owner@example.com") 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 ban := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guildID+"/bans/"+ownerID, `{"reason":"сам себя"}`, owner) if ban.Code != http.StatusForbidden { t.Fatalf("самобан администратора инстанса = %d, ожидался 403", ban.Code) } 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 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) } }