From a6cec5919ac7ac5eafc329f318c923e2b0f59c98 Mon Sep 17 00:00:00 2001 From: grendervill Date: Tue, 22 Sep 2026 21:52:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20READY=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B0=D1=91=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=BF=D1=83=D1=81=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снапшот READY инициализировал my_permissions пустым списком и нигде его не заполнял, поэтому у всех — включая владельца сервера — клиент выключал композер («В эту комнату нельзя писать»), прятал шестерёнку, приглашения и кнопку саундборда: состав прав в снапшоте не совпадал с REST (GET /users/@me/guilds отдавал полный список). Права считает тот же движок и тот же кэш, что REST (permissions.Calculator, переданный в NewSnapshot), а инстанс-админ получает максимальный набор даже на чужом сервере, где он не участник (AGENT.md 7.19, 11.6). Тесты: TestReadyCarriesMyPermissions (непустые права роли по умолчанию, VIEW_CHANNEL/SEND_MESSAGES и согласие с can_send комнаты) и TestReadyGivesInstanceAdminAllPermissions. --- internal/gateway/gateway_test.go | 66 ++++++++++++++++++++++++++++++++ internal/gateway/ready.go | 14 ++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 1a2727c..4913687 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http/httptest" "path/filepath" + "slices" "strings" "testing" "time" @@ -214,6 +215,71 @@ func TestHelloAndReady(t *testing.T) { } } +// TestReadyCarriesMyPermissions проверяет, что снапшот несёт права участника: +// пустой `my_permissions` выключал композер и управление сервером у всех, +// включая владельца (AGENT.md 11.6). +func TestReadyCarriesMyPermissions(t *testing.T) { + f := newFixture(t) + token := registerUser(t, f, "perm_user", "perm@example.com") + joinMainGuildForTest(t, f, token) + + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + + var snapshot gateway.Ready + if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil { + t.Fatalf("decode READY: %v", err) + } + perms := snapshot.Guilds[0].MyPerms + if len(perms) == 0 { + t.Fatal("READY must carry my_permissions of the default role") + } + // Роль @user даёт эти права каждому участнику (AGENT.md 6.3). + for _, want := range []string{"VIEW_CHANNEL", "SEND_MESSAGES"} { + if !slices.Contains(perms, want) { + t.Fatalf("my_permissions = %v, want %s", perms, want) + } + } + if !snapshot.Guilds[0].Channels[0].CanSend { + t.Fatal("my_permissions must agree with channel can_send") + } +} + +// TestReadyGivesInstanceAdminAllPermissions: у инстанс-админа права +// максимальные даже на чужом сервере, где он не участник (AGENT.md 7.19). +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 { + t.Fatalf("ResolveSession: %v", err) + } + if err := f.store.SetInstanceAdmin(ctx, user.ID, true); err != nil { + t.Fatalf("SetInstanceAdmin: %v", err) + } + + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) + + var snapshot gateway.Ready + if err := json.Unmarshal(readEnvelope(t, conn).D, &snapshot); err != nil { + t.Fatalf("decode READY: %v", err) + } + if len(snapshot.Guilds) != 1 { + t.Fatalf("instance admin must see the guild, got %d", len(snapshot.Guilds)) + } + perms := snapshot.Guilds[0].MyPerms + for _, want := range []string{"ADMINISTRATOR", "SEND_MESSAGES", "MANAGE_ROLES", "BAN_MEMBERS"} { + if !slices.Contains(perms, want) { + t.Fatalf("my_permissions инстанс-админа = %v, want %s", perms, want) + } + } +} + func TestReadyHidesPrivateChannels(t *testing.T) { f := newFixture(t) ctx := context.Background() diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index 57503c4..54bdfe7 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -140,7 +140,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor Roles: []ReadyRole{}, MemberIDs: []string{}, MyRoles: []string{}, - MyPerms: []string{}, + MyPerms: readyPermissions(resolved), Emojis: []ReadyEmoji{}, VoiceStates: []ReadyVoiceState{}, Sounds: []ReadySound{}, @@ -257,6 +257,18 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor return readyGuild, nil } +// readyPermissions отдаёт права пользователя на сервере списком имён — тем же +// движком и в том же виде, что REST (`permissions.Names`). Без них клиент +// выключал композер и прятал управление сервером (AGENT.md 11.6). +func readyPermissions(resolved permissions.Resolved) []string { + // У инстанс-админа права максимальные на любом сервере, включая чужой, где + // он не участник (AGENT.md 7.19): в маске прав роли их нет. + if resolved.IsInstanceAdmin { + return permissions.Names(permissions.AllPermissions) + } + return permissions.Names(resolved.Guild) +} + func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel { payload := ReadyChannel{ ID: formatID(channel.ID),