fix(server): инстанс-админ видит все серверы инстанса в READY

Чек-лист §11.5 требует, чтобы READY и список серверов инстанс-админа
содержали все серверы инстанса, включая чужие и приватные. Фактически
список строился из членства (`ListGuildsForUser`), поэтому в интерфейсе
админа чужого сервера не было ни в рейке, ни в подстраховочном
`GET /users/@me/guilds` — войти в него без инвайта было нельзя, хотя права
на чужом сервере у него максимальные (AGENT.md 7.18, 7.19).

- `Snapshot.userGuilds`: админу — `ListAllGuilds`, остальным — членство;
- `GET /users/@me/guilds` отдаёт админу тот же список, иначе REST-ответ
  выкидывал бы чужой сервер из рейки при перечитывании сводки;
- Go-тест `TestReadyListsAllGuildsForInstanceAdmin`: READY и REST содержат
  чужой приватный сервер с полными правами, у обычного пользователя его нет.
This commit is contained in:
2026-09-26 11:06:24 +03:00
parent 61cad0fdfc
commit 5f21c9d069
4 changed files with 118 additions and 5 deletions
+9 -1
View File
@@ -392,7 +392,15 @@ func (s *Server) registerUserRoutes(api huma.API) {
if err != nil {
return nil, err
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
// Инстанс-админ видит все серверы инстанса — тем же списком, что и
// READY, иначе подстраховочный REST-запрос выкинул бы чужой сервер из
// рейки (AGENT.md 7.19, 11.5).
var guilds []store.Guild
if user.IsInstanceAdmin {
guilds, err = s.store.ListAllGuilds(ctx)
} else {
guilds, err = s.store.ListGuildsForUser(ctx, user.ID)
}
if err != nil {
return nil, humaError(err)
}
+85
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -336,3 +337,87 @@ func TestInstanceAdminManagesForeignGuild(t *testing.T) {
t.Fatalf("admin delete guild = %d, body = %s", rec.Code, rec.Body.String())
}
}
// TestReadyListsAllGuildsForInstanceAdmin проверяет §11.5: READY и
// `/users/@me/guilds` инстанс-админа несут все серверы инстанса, включая чужой
// приватный, где он не участник. Раньше список брался из членства, и войти в
// чужой сервер из интерфейса было нельзя, хотя права на него максимальные.
func TestReadyListsAllGuildsForInstanceAdmin(t *testing.T) {
srv, _ := newTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
ownerCookie := registerAndLogin(t, srv, "ready_owner", "ready-owner@example.com")
adminCookie := registerAndLogin(t, srv, "ready_admin", "ready-admin@example.com")
otherCookie := registerAndLogin(t, srv, "ready_other", "ready-other@example.com")
promoteAdmin(t, srv, "ready-admin@example.com")
// Первый сервер становится главным, поэтому чужой создаём вторым.
doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Главный"}`, ownerCookie)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Чужой приватный"}`, ownerCookie)
guild := decodeResponse[struct {
Guild struct {
ID string `json:"id"`
} `json:"guild"`
}](t, created)
type guildList struct {
Guilds []struct {
ID string `json:"id"`
MyPerms []string `json:"my_permissions"`
} `json:"guilds"`
}
contains := func(list guildList, id string) (bool, []string) {
for _, item := range list.Guilds {
if item.ID == id {
return true, item.MyPerms
}
}
return false, nil
}
// READY администратора видит чужой сервер и даёт на нём полные права.
client := dialGateway(t, httpServer, adminCookie)
var snapshot guildList
if err := json.Unmarshal(client.ready.D, &snapshot); err != nil {
t.Fatalf("decode READY: %v", err)
}
found, perms := contains(snapshot, guild.Guild.ID)
if !found {
t.Fatalf("READY инстанс-админа не содержит чужой сервер: %+v", snapshot.Guilds)
}
for _, want := range []string{"ADMINISTRATOR", "SEND_MESSAGES", "BAN_MEMBERS"} {
if !slices.Contains(perms, want) {
t.Fatalf("my_permissions чужого сервера = %v, want %s", perms, want)
}
}
// REST-подстраховка интерфейса отдаёт тот же список: иначе чужой сервер
// пропал бы из рейки, когда снапшот заменяется ответом `/users/@me/guilds`.
myGuilds := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/guilds", "", adminCookie)
var restList guildList
if err := json.Unmarshal(myGuilds.Body.Bytes(), &restList); err != nil {
t.Fatalf("decode my guilds: %v", err)
}
if found, _ := contains(restList, guild.Guild.ID); !found {
t.Fatalf("/users/@me/guilds инстанс-админа не содержит чужой сервер: %+v", restList.Guilds)
}
// Обычный пользователь чужой сервер не видит ни там, ни там.
otherClient := dialGateway(t, httpServer, otherCookie)
var otherSnapshot guildList
if err := json.Unmarshal(otherClient.ready.D, &otherSnapshot); err != nil {
t.Fatalf("decode READY обычного пользователя: %v", err)
}
if found, _ := contains(otherSnapshot, guild.Guild.ID); found {
t.Fatalf("чужой сервер не должен попадать в READY обычного пользователя: %+v", otherSnapshot.Guilds)
}
otherGuilds := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/guilds", "", otherCookie)
var otherRest guildList
if err := json.Unmarshal(otherGuilds.Body.Bytes(), &otherRest); err != nil {
t.Fatalf("decode my guilds обычного пользователя: %v", err)
}
if found, _ := contains(otherRest, guild.Guild.ID); found {
t.Fatalf("чужой сервер не должен попадать в /users/@me/guilds: %+v", otherRest.Guilds)
}
}