fix(gateway): READY отдаёт права участника вместо пустого списка

Снапшот 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.
This commit is contained in:
2026-09-22 21:52:09 +03:00
parent cffcec72b8
commit a6cec5919a
2 changed files with 79 additions and 1 deletions
+66
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestReadyHidesPrivateChannels(t *testing.T) {
f := newFixture(t) f := newFixture(t)
ctx := context.Background() ctx := context.Background()
+13 -1
View File
@@ -140,7 +140,7 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
Roles: []ReadyRole{}, Roles: []ReadyRole{},
MemberIDs: []string{}, MemberIDs: []string{},
MyRoles: []string{}, MyRoles: []string{},
MyPerms: []string{}, MyPerms: readyPermissions(resolved),
Emojis: []ReadyEmoji{}, Emojis: []ReadyEmoji{},
VoiceStates: []ReadyVoiceState{}, VoiceStates: []ReadyVoiceState{},
Sounds: []ReadySound{}, Sounds: []ReadySound{},
@@ -257,6 +257,18 @@ func (s *Snapshot) buildGuild(ctx context.Context, guild store.Guild, user *stor
return readyGuild, nil 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 { func channelPayload(channel store.Channel, canSend, canConnect bool) ReadyChannel {
payload := ReadyChannel{ payload := ReadyChannel{
ID: formatID(channel.ID), ID: formatID(channel.ID),