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)
|
f := newFixture(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
token := registerUser(t, f, "admin_user", "admin@example.com")
|
token := registerUser(t, f, "admin_user", "admin@example.com")
|
||||||
// Инстанс-админ — участник главного сервера: список серверов берётся из
|
// В инстансе всего один сервер (главный): снапшот админа несёт все серверы
|
||||||
// членства, а права на них максимальные.
|
// инстанса, а права на них максимальные.
|
||||||
joinMainGuildForTest(t, f, token)
|
joinMainGuildForTest(t, f, token)
|
||||||
user, _, err := f.auth.ResolveSession(ctx, token)
|
user, _, err := f.auth.ResolveSession(ctx, token)
|
||||||
if err != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list user guilds: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, guild := range guilds {
|
for _, guild := range guilds {
|
||||||
resolved, err := s.calculator.Guild(ctx, guild.ID, user.ID, user.IsInstanceAdmin)
|
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
|
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) {
|
func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *store.User, resolved permissions.Resolved) (ReadyGuild, error) {
|
||||||
readyGuild := ReadyGuild{
|
readyGuild := ReadyGuild{
|
||||||
ID: formatID(guild.ID),
|
ID: formatID(guild.ID),
|
||||||
|
|||||||
@@ -392,7 +392,15 @@ func (s *Server) registerUserRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -336,3 +337,87 @@ func TestInstanceAdminManagesForeignGuild(t *testing.T) {
|
|||||||
t.Fatalf("admin delete guild = %d, body = %s", rec.Code, rec.Body.String())
|
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