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
+2 -2
View File
@@ -251,8 +251,8 @@ func TestReadyGivesInstanceAdminAllPermissions(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
token := registerUser(t, f, "admin_user", "admin@example.com")
// Инстанс-админ — участник главного сервера: список серверов берётся из
// членства, а права на них максимальные.
// В инстансе всего один сервер (главный): снапшот админа несёт все серверы
// инстанса, а права на них максимальные.
joinMainGuildForTest(t, f, token)
user, _, err := f.auth.ResolveSession(ctx, token)
if err != nil {
+22 -2
View File
@@ -104,9 +104,12 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
ready.DMChannels = append(ready.DMChannels, channel)
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
// Инстанс-админ видит в снапшоте все серверы инстанса, включая чужие и
// приватные, где он не участник: без этого он не мог войти в чужой сервер
// из интерфейса, хотя права у него есть везде (AGENT.md 7.19, 11.5).
guilds, err := s.userGuilds(ctx, user)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
return nil, err
}
for _, guild := range guilds {
resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin)
@@ -126,6 +129,23 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error)
return ready, nil
}
// userGuilds выбирает серверы для снапшота: обычному пользователю — только
// свои, администратору инстанса — все серверы инстанса (AGENT.md 7.19, 11.5).
func (s *Snapshot) userGuilds(ctx context.Context, user *store.User) ([]store.Guild, error) {
if user.IsInstanceAdmin {
guilds, err := s.store.ListAllGuilds(ctx)
if err != nil {
return nil, fmt.Errorf("list instance guilds: %w", err)
}
return guilds, nil
}
guilds, err := s.store.ListGuildsForUser(ctx, user.ID)
if err != nil {
return nil, fmt.Errorf("list user guilds: %w", err)
}
return guilds, nil
}
func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) {
readyGuild := ReadyGuild{
ID: formatID(guild.ID),
+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)
}
}