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:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user