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),