Files
glchat/internal/server/instance_panel_test.go
grendervill 3dc200c196 feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):

- GET /instance/stats: рост пользователей и сообщений по дням, активность,
  размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
  события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
  одного устройства и сброс второго фактора со step-up, аудитом и записью
  в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
  пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
  lower() не знает кириллицу, поэтому регистр приводит приложение (как для
  текста сообщений), старые записи дополняются backfill'ом при старте.
2026-09-26 16:44:49 +03:00

584 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Тесты расширенной админ-панели инстанса (AGENT.md 3.2, 7.18, 11.5):
// обычный пользователь не видит разделы, администратор получает данные,
// чувствительные действия требуют step-up и попадают в аудит.
// instanceGet вызывает ручку панели администратором и проверяет статус.
func instanceGet(t *testing.T, srv *Server, cookie *http.Cookie, path string, want int) *httptest.ResponseRecorder {
t.Helper()
rec := doJSON(t, srv, http.MethodGet, path, "", cookie)
if rec.Code != want {
t.Fatalf("GET %s = %d, want %d (body: %s)", path, rec.Code, want, rec.Body.String())
}
return rec
}
// TestInstancePanelRequiresAdmin: разделы расширенной панели закрыты для
// обычного пользователя (AGENT.md 7.18, 11.5).
func TestInstancePanelRequiresAdmin(t *testing.T) {
srv, _ := newTestServer(t)
userCookie := registerAndLogin(t, srv, "panel_user", "panel-user@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "panel-user@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetPath := formatSnowflake(target.ID)
for _, path := range []string{
"/api/v1/instance/stats",
"/api/v1/instance/audit",
"/api/v1/instance/audit/actions",
"/api/v1/instance/audit/export",
"/api/v1/instance/users/" + targetPath,
"/api/v1/instance/guilds?limit=10",
} {
rec := doJSON(t, srv, http.MethodGet, path, "", userCookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("GET %s обычным пользователем = %d, want 403", path, rec.Code)
}
}
}
// TestInstanceStatsVisibility: статистика инстанса считает пользователей,
// сообщения и серверы, а обычному пользователю недоступна (AGENT.md 7.18).
func TestInstanceStatsVisibility(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "stats_admin", "stats-admin@example.com")
promoteAdmin(t, srv, "stats-admin@example.com")
registerAndLogin(t, srv, "stats_plain", "stats-plain@example.com")
rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/stats", http.StatusOK)
stats := decodeResponse[struct {
Stats struct {
Users struct {
Total int `json:"total"`
Admins int `json:"admins"`
New7Days int `json:"new_7_days"`
Daily []struct {
Date string `json:"date"`
Count int `json:"count"`
} `json:"daily"`
} `json:"users"`
Messages struct {
Total int `json:"total"`
Today int `json:"today"`
} `json:"messages"`
Guilds int `json:"guilds"`
DatabaseBytes int64 `json:"database_bytes"`
StorageBytes int64 `json:"storage_bytes"`
} `json:"stats"`
}](t, rec)
if stats.Stats.Users.Total < 2 || stats.Stats.Users.New7Days < 2 {
t.Fatalf("пользователи в статистике: %+v", stats.Stats.Users)
}
if stats.Stats.Users.Admins != 1 {
t.Fatalf("администраторов = %d, want 1", stats.Stats.Users.Admins)
}
if len(stats.Stats.Users.Daily) == 0 {
t.Fatal("рост пользователей по дням пуст")
}
if stats.Stats.DatabaseBytes <= 0 || stats.Stats.StorageBytes < stats.Stats.DatabaseBytes {
t.Fatalf("размеры данных: %+v", stats.Stats)
}
}
// TestInstanceAuditFiltersAndExport: фильтры журнала, пагинация и выгрузка CSV
// (AGENT.md 7.10, 7.18).
func TestInstanceAuditFiltersAndExport(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "audit_admin", "audit-admin@example.com")
promoteAdmin(t, srv, "audit-admin@example.com")
registerAndLogin(t, srv, "audit_target", "audit-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "audit-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
// Действия администратора создают записи журнала.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+targetID+"/logout", "", adminCookie); rec.Code != http.StatusOK {
t.Fatalf("logout = %d (%s)", rec.Code, rec.Body.String())
}
if rec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings",
`{"max_message_length":4001}`, adminCookie); rec.Code != http.StatusOK {
t.Fatalf("settings = %d (%s)", rec.Code, rec.Body.String())
}
listAudit := func(query string) ([]string, int) {
t.Helper()
path := "/api/v1/instance/audit"
if query != "" {
path += "?" + query
}
rec := instanceGet(t, srv, adminCookie, path, http.StatusOK)
payload := decodeResponse[struct {
Entries []struct {
Action string `json:"action"`
TargetID string `json:"target_id"`
} `json:"entries"`
Total int `json:"total"`
}](t, rec)
actions := make([]string, 0, len(payload.Entries))
for _, entry := range payload.Entries {
actions = append(actions, entry.Action)
}
return actions, payload.Total
}
all, total := listAudit("")
if total < 2 || len(all) < 2 {
t.Fatalf("журнал пуст: %v (total %d)", all, total)
}
byAction, total := listAudit("action=instance.user_logout")
if total != 1 || len(byAction) != 1 || byAction[0] != "instance.user_logout" {
t.Fatalf("фильтр по действию: %v (total %d)", byAction, total)
}
byTarget, total := listAudit("target_id=" + targetID)
if total != 1 || len(byTarget) != 1 {
t.Fatalf("фильтр по цели: %v (total %d)", byTarget, total)
}
actorID := ""
{
admin, err := srv.auth.UserByEmail(t.Context(), "audit-admin@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
actorID = formatSnowflake(admin.ID)
}
byActor, total := listAudit("actor_id=" + actorID)
if total < 2 || len(byActor) < 2 {
t.Fatalf("фильтр по актору: %v (total %d)", byActor, total)
}
today := time.Now().UTC().Format("2006-01-02")
byDate, total := listAudit("since=" + today + "&until=" + today)
if total < 2 {
t.Fatalf("фильтр по дате: %v (total %d)", byDate, total)
}
if _, total := listAudit("since=2030-01-01"); total != 0 {
t.Fatalf("фильтр «в будущем» вернул %d записей", total)
}
page1, _ := listAudit("limit=1&offset=0")
page2, _ := listAudit("limit=1&offset=1")
if len(page1) != 1 || len(page2) != 1 || page1[0] == page2[0] {
t.Fatalf("пагинация журнала: %v / %v", page1, page2)
}
// Неверная дата — понятная ошибка валидации, а не пустой журнал.
bad := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit?since=вчера", "", adminCookie)
if bad.Code != http.StatusUnprocessableEntity {
t.Fatalf("неверная дата = %d, want 422", bad.Code)
}
// Список действий для фильтра.
actionsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit/actions", http.StatusOK)
actions := decodeResponse[struct {
Actions []string `json:"actions"`
}](t, actionsRec)
foundLogout := false
for _, action := range actions.Actions {
if action == "instance.user_logout" {
foundLogout = true
}
}
if !foundLogout {
t.Fatalf("список действий не содержит instance.user_logout: %v", actions.Actions)
}
// Экспорт CSV: тот же фильтр, заголовок и строки.
export := doJSON(t, srv, http.MethodGet,
"/api/v1/instance/audit/export?action=instance.user_logout", "", adminCookie)
if export.Code != http.StatusOK {
t.Fatalf("экспорт = %d (%s)", export.Code, export.Body.String())
}
if contentType := export.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") {
t.Fatalf("Content-Type экспорта = %q", contentType)
}
body := export.Body.String()
if !strings.Contains(body, "id,created_at,action") || !strings.Contains(body, "instance.user_logout") {
t.Fatalf("тело экспорта: %s", body)
}
if strings.Contains(body, "instance.settings_update") {
t.Fatal("экспорт не учёл фильтр по действию")
}
// Экспорт — тоже событие журнала.
_, total = listAudit("action=instance.audit_export")
if total != 1 {
t.Fatalf("экспорт не записан в аудит: total %d", total)
}
}
// TestInstanceUserCardShowsDetails: карточка пользователя отдаёт устройства,
// серверы с ролями, события безопасности и аудит по нему (AGENT.md 7.18).
func TestInstanceUserCardShowsDetails(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "card_admin", "card-admin@example.com")
promoteAdmin(t, srv, "card-admin@example.com")
targetCookie := registerAndLogin(t, srv, "card_target", "card-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "card-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
// У цели есть сервер (создаётся вместе с ролями по умолчанию) — карточка
// должна показать участие и роли.
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Карточка"}`, targetCookie)
if created.Code != http.StatusOK {
t.Fatalf("создать сервер = %d (%s)", created.Code, created.Body.String())
}
rec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
TOTPEnabled bool `json:"totp_enabled"`
Passkeys int `json:"passkeys"`
Sessions []struct {
ID string `json:"id"`
UserAgent string `json:"user_agent"`
} `json:"sessions"`
Guilds []struct {
GuildName string `json:"guild_name"`
Roles []struct {
Name string `json:"name"`
} `json:"roles"`
} `json:"guilds"`
SecurityEvents []struct {
Type string `json:"type"`
} `json:"security_events"`
Audit []any `json:"audit"`
AuditTotal int `json:"audit_total"`
}](t, rec)
if card.User.ID != targetID || card.User.Username != "card_target" {
t.Fatalf("профиль в карточке: %+v", card.User)
}
if len(card.Sessions) != 1 || card.Sessions[0].ID == "" {
t.Fatalf("устройства в карточке: %+v", card.Sessions)
}
if len(card.SecurityEvents) == 0 {
t.Fatal("события безопасности не попали в карточку")
}
if card.TOTPEnabled || card.Passkeys != 0 {
t.Fatalf("второй фактор/ключи: %v/%d", card.TOTPEnabled, card.Passkeys)
}
if len(card.Guilds) != 1 || card.Guilds[0].GuildName != "Карточка" {
t.Fatalf("серверы в карточке: %+v", card.Guilds)
}
if len(card.Guilds[0].Roles) == 0 {
t.Fatalf("роли в карточке пусты: %+v", card.Guilds[0])
}
// Несуществующий пользователь — 404, а не пустая карточка.
missing := doJSON(t, srv, http.MethodGet, "/api/v1/instance/users/999999999", "", adminCookie)
if missing.Code != http.StatusNotFound {
t.Fatalf("карточка несуществующего = %d, want 404", missing.Code)
}
}
// TestInstanceRevokeSession: отзыв одного устройства требует step-up, попадает
// в аудит и в события безопасности, второе устройство продолжает работать
// (AGENT.md 7.1, 7.18, 11.6).
func TestInstanceRevokeSession(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "revoke_admin", "revoke-admin@example.com")
promoteAdmin(t, srv, "revoke-admin@example.com")
firstCookie := registerAndLogin(t, srv, "revoke_target", "revoke-target@example.com")
// Второе устройство: обычный вход тем же паролем.
second := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"revoke-target@example.com","password":"correct-horse-battery"}`)
if second.Code != http.StatusOK {
t.Fatalf("второй вход = %d (%s)", second.Code, second.Body.String())
}
secondCookie := second.Result().Cookies()[0]
target, err := srv.auth.UserByEmail(t.Context(), "revoke-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
Sessions []struct {
ID string `json:"id"`
} `json:"sessions"`
}](t, cardRec)
if len(card.Sessions) != 2 {
t.Fatalf("устройств у цели = %d, want 2", len(card.Sessions))
}
// Определяем, какая из сессий принадлежит второму устройству: отзываем
// первую и проверяем, что хотя бы одно устройство продолжает работать.
revokePath := "/api/v1/instance/users/" + targetID + "/sessions/" + card.Sessions[0].ID
noStepUp := doJSON(t, srv, http.MethodDelete, revokePath, `{}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("отзыв без step-up = %d, want 403", noStepUp.Code)
}
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
t.Fatalf("код отказа без step-up = %q", code)
}
ok := doJSON(t, srv, http.MethodDelete, revokePath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if ok.Code != http.StatusOK {
t.Fatalf("отзыв устройства = %d (%s)", ok.Code, ok.Body.String())
}
// Отозванное устройство теряет доступ, второе продолжает работать.
alive := 0
for _, cookie := range []*http.Cookie{firstCookie, secondCookie} {
rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie)
if rec.Code == http.StatusOK {
alive++
}
}
if alive != 1 {
t.Fatalf("после отзыва работает устройств: %d, want 1", alive)
}
// Повторный отзыв той же сессии — 404.
again := doJSON(t, srv, http.MethodDelete, revokePath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if again.Code != http.StatusNotFound {
t.Fatalf("повторный отзыв = %d, want 404", again.Code)
}
// Аудит и событие безопасности записаны.
auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_session_revoke", http.StatusOK)
audit := decodeResponse[struct {
Total int `json:"total"`
}](t, auditRec)
if audit.Total != 1 {
t.Fatalf("записей аудита об отзыве = %d, want 1", audit.Total)
}
cardRec = instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card = decodeResponse[struct {
Sessions []struct {
ID string `json:"id"`
} `json:"sessions"`
}](t, cardRec)
if len(card.Sessions) != 1 {
t.Fatalf("устройств после отзыва = %d, want 1", len(card.Sessions))
}
eventsRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
events := decodeResponse[struct {
SecurityEvents []struct {
Type string `json:"type"`
Metadata struct {
By string `json:"by"`
} `json:"metadata"`
} `json:"security_events"`
}](t, eventsRec)
found := false
for _, event := range events.SecurityEvents {
if event.Type == "session_revoked" && event.Metadata.By == "instance_admin" {
found = true
}
}
if !found {
t.Fatalf("событие session_revoked не записано: %+v", events.SecurityEvents)
}
}
// TestInstanceReset2FA: сброс второго фактора требует step-up, защищает
// администраторов и записывается в аудит и события безопасности (AGENT.md 7.18).
func TestInstanceReset2FA(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "tfa_admin", "tfa-admin@example.com")
promoteAdmin(t, srv, "tfa-admin@example.com")
registerAndLogin(t, srv, "tfa_target", "tfa-target@example.com")
target, err := srv.auth.UserByEmail(t.Context(), "tfa-target@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
targetID := formatSnowflake(target.ID)
resetPath := "/api/v1/instance/users/" + targetID + "/reset-2fa"
// Без step-up сброс отклоняется: проверка свежести аутентификации идёт до
// проверок цели (AGENT.md 7.1).
noStepUp := doJSON(t, srv, http.MethodPost, resetPath, `{}`, adminCookie)
if noStepUp.Code != http.StatusForbidden {
t.Fatalf("сброс без step-up = %d, want 403", noStepUp.Code)
}
if code := errorCodeOf(t, noStepUp); code != "auth.step_up_required" {
t.Fatalf("код отказа без step-up = %q", code)
}
// С подтверждением, но без включённого второго фактора сбрасывать нечего.
empty := doJSON(t, srv, http.MethodPost, resetPath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if empty.Code != http.StatusUnprocessableEntity {
t.Fatalf("сброс без 2FA = %d, want 422", empty.Code)
}
// Включаем второй фактор напрямую в хранилище (как это делает раздел
// безопасности) и сбрасываем его администратором.
if err := srv.store.UpsertTOTPSecret(t.Context(), target.ID, "encrypted-secret"); err != nil {
t.Fatalf("записать секрет: %v", err)
}
if err := srv.store.EnableTOTP(t.Context(), target.ID, nil); err != nil {
t.Fatalf("включить 2FA: %v", err)
}
ok := doJSON(t, srv, http.MethodPost, resetPath,
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if ok.Code != http.StatusOK {
t.Fatalf("сброс 2FA = %d (%s)", ok.Code, ok.Body.String())
}
secret, err := srv.store.GetTOTPSecret(t.Context(), target.ID)
if err == nil && secret.Enabled {
t.Fatal("второй фактор остался включённым после сброса")
}
// Аудит и событие безопасности.
auditRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/audit?action=instance.user_2fa_reset", http.StatusOK)
audit := decodeResponse[struct {
Total int `json:"total"`
}](t, auditRec)
if audit.Total != 1 {
t.Fatalf("записей аудита о сбросе 2FA = %d, want 1", audit.Total)
}
cardRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/users/"+targetID, http.StatusOK)
card := decodeResponse[struct {
TOTPEnabled bool `json:"totp_enabled"`
SecurityEvents []struct {
Type string `json:"type"`
} `json:"security_events"`
}](t, cardRec)
if card.TOTPEnabled {
t.Fatal("карточка всё ещё показывает включённый второй фактор")
}
foundEvent := false
for _, event := range card.SecurityEvents {
if event.Type == "2fa_change" {
foundEvent = true
}
}
if !foundEvent {
t.Fatalf("событие 2fa_change не записано: %+v", card.SecurityEvents)
}
// Себя сбрасывать нельзя: свой второй фактор меняют в настройках.
self := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin@example.com")+"/reset-2fa",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if self.Code != http.StatusUnprocessableEntity {
t.Fatalf("сброс себе = %d, want 422", self.Code)
}
// Другого администратора — тоже: иначе сброс чужого ключа обходил бы
// обязательный второй фактор инстанс-админа (AGENT.md 7.19).
registerAndLogin(t, srv, "tfa_admin2", "tfa-admin2@example.com")
promoteAdmin(t, srv, "tfa-admin2@example.com")
protected := doJSON(t, srv, http.MethodPost,
"/api/v1/instance/users/"+adminIDOf(t, srv, "tfa-admin2@example.com")+"/reset-2fa",
`{"step_up_password":"correct-horse-battery"}`, adminCookie)
if protected.Code != http.StatusForbidden {
t.Fatalf("сброс 2FA другого администратора = %d, want 403", protected.Code)
}
}
// adminIDOf возвращает snowflake-идентификатор пользователя по email.
func adminIDOf(t *testing.T, srv *Server, email string) string {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("UserByEmail(%s): %v", email, err)
}
return formatSnowflake(user.ID)
}
// TestInstanceGuildListFilters: список серверов панели ищет по названию без
// учёта регистра, фильтрует по владельцу и листается (AGENT.md 7.18).
func TestInstanceGuildListFilters(t *testing.T) {
srv, _ := newTestServer(t)
adminCookie := registerAndLogin(t, srv, "list_admin", "list-admin@example.com")
promoteAdmin(t, srv, "list-admin@example.com")
ownerCookie := registerAndLogin(t, srv, "list_owner", "list-owner@example.com")
ownerID := adminIDOf(t, srv, "list-owner@example.com")
for _, name := range []string{"Альфа", "Бета"} {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"`+name+`"}`, ownerCookie)
if rec.Code != http.StatusOK {
t.Fatalf("создать сервер %s = %d (%s)", name, rec.Code, rec.Body.String())
}
}
type guildList struct {
Guilds []struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name"`
MemberCount int `json:"member_count"`
} `json:"guilds"`
Total int `json:"total"`
}
allRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=200", http.StatusOK)
list := decodeResponse[guildList](t, allRec)
if list.Total < 2 || len(list.Guilds) < 2 {
t.Fatalf("список серверов: %+v (total %d)", list.Guilds, list.Total)
}
found := false
for _, guild := range list.Guilds {
if guild.Name == "Альфа" {
found = true
if guild.OwnerName != "list_owner" || guild.MemberCount != 1 {
t.Fatalf("строка сервера: %+v", guild)
}
}
}
if !found {
t.Fatal("сервер «Альфа» не найден")
}
// Поиск без учёта регистра: кириллица в SQLite lower() не приводится,
// поэтому сравнивается нормализованная копия названия.
searchRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK)
search := decodeResponse[guildList](t, searchRec)
if search.Total != 1 || len(search.Guilds) != 1 || search.Guilds[0].Name != "Альфа" {
t.Fatalf("поиск по названию: %+v (total %d)", search.Guilds, search.Total)
}
ownerRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?owner_id="+ownerID, http.StatusOK)
byOwner := decodeResponse[guildList](t, ownerRec)
if byOwner.Total != 2 {
t.Fatalf("фильтр по владельцу: total %d, want 2", byOwner.Total)
}
pageRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?limit=1&offset=1", http.StatusOK)
page := decodeResponse[guildList](t, pageRec)
if len(page.Guilds) != 1 || page.Total != byOwner.Total {
t.Fatalf("пагинация серверов: %+v (total %d)", page.Guilds, page.Total)
}
// Переименование обновляет нормализованное название: старый поиск пуст.
if _, adminErr := srv.auth.UserByEmail(t.Context(), "list-admin@example.com"); adminErr != nil {
t.Fatalf("UserByEmail: %v", adminErr)
}
renameRec := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/guilds/"+search.Guilds[0].ID,
`{"name":"Гамма"}`, adminCookie)
if renameRec.Code != http.StatusOK {
t.Fatalf("переименование = %d (%s)", renameRec.Code, renameRec.Body.String())
}
oldRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=альф", http.StatusOK)
old := decodeResponse[guildList](t, oldRec)
if old.Total != 0 {
t.Fatalf("после переименования старый поиск вернул %d записей", old.Total)
}
newRec := instanceGet(t, srv, adminCookie, "/api/v1/instance/guilds?q=гамма", http.StatusOK)
renamed := decodeResponse[guildList](t, newRec)
if renamed.Total != 1 {
t.Fatalf("поиск по новому названию: total %d, want 1", renamed.Total)
}
}