diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 2b49091..534e5d2 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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 { diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 45c4bee..888877a 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -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), diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 797e1fc..7f091f4 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -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) } diff --git a/internal/server/realtime_test.go b/internal/server/realtime_test.go index 1420e5d..c349beb 100644 --- a/internal/server/realtime_test.go +++ b/internal/server/realtime_test.go @@ -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) + } +}