feat(store,permissions): схема Фазы 1, доступ к данным и движок прав
- миграция 00002: users, sessions, totp_secrets, webauthn_credentials, security_events, guilds, guild_members, roles, member_roles, channels, channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1) - internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий (ротация, step-up, logout-all), TOTP и события безопасности, серверы, участники, роли, комнаты и оверрайды, настройки инстанса и аудит - internal/permissions: 37 прав битмаской, вычисление по правилам §6.2 (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR), иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш с инвалидацией - internal/source: адаптер permissions.Source поверх store - тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite (приватная комната, модератор, владелец, инстанс-админ, тайм-аут) - golangci: обоснованное исключение gosec для пакета store (конверсии Snowflake и сборка SQL из константных шаблонов)
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Cache — простой LRU-кэш вычисленных прав с инвалидацией при записи
|
||||
// (AGENT.md 9.1). Ключи: сервер+пользователь и комната+пользователь.
|
||||
type Cache struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
items map[guildKey]*list.Element
|
||||
channels map[channelKey]*list.Element
|
||||
order *list.List
|
||||
}
|
||||
|
||||
type guildKey struct {
|
||||
guildID uint64
|
||||
userID uint64
|
||||
}
|
||||
|
||||
type channelKey struct {
|
||||
channelID uint64
|
||||
userID uint64
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
guild guildKey
|
||||
channel channelKey
|
||||
value Resolved
|
||||
isChan bool
|
||||
}
|
||||
|
||||
func NewCache(capacity int) *Cache {
|
||||
if capacity <= 0 {
|
||||
capacity = 1024
|
||||
}
|
||||
return &Cache{
|
||||
capacity: capacity,
|
||||
items: make(map[guildKey]*list.Element, capacity),
|
||||
channels: make(map[channelKey]*list.Element, capacity),
|
||||
order: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) GetGuild(guildID, userID uint64) (Resolved, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
element, ok := c.items[guildKey{guildID: guildID, userID: userID}]
|
||||
if !ok {
|
||||
return Resolved{}, false
|
||||
}
|
||||
c.order.MoveToFront(element)
|
||||
return element.Value.(*cacheEntry).value, true
|
||||
}
|
||||
|
||||
func (c *Cache) PutGuild(guildID, userID uint64, value Resolved) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
key := guildKey{guildID: guildID, userID: userID}
|
||||
if element, ok := c.items[key]; ok {
|
||||
element.Value.(*cacheEntry).value = value
|
||||
c.order.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
entry := &cacheEntry{guild: key, value: value}
|
||||
c.items[key] = c.order.PushFront(entry)
|
||||
c.evictLocked()
|
||||
}
|
||||
|
||||
func (c *Cache) GetChannel(channelID, userID uint64) (Resolved, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
element, ok := c.channels[channelKey{channelID: channelID, userID: userID}]
|
||||
if !ok {
|
||||
return Resolved{}, false
|
||||
}
|
||||
c.order.MoveToFront(element)
|
||||
return element.Value.(*cacheEntry).value, true
|
||||
}
|
||||
|
||||
func (c *Cache) PutChannel(channelID, userID uint64, value Resolved) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
key := channelKey{channelID: channelID, userID: userID}
|
||||
if element, ok := c.channels[key]; ok {
|
||||
element.Value.(*cacheEntry).value = value
|
||||
c.order.MoveToFront(element)
|
||||
return
|
||||
}
|
||||
entry := &cacheEntry{channel: key, value: value, isChan: true}
|
||||
c.channels[key] = c.order.PushFront(entry)
|
||||
c.evictLocked()
|
||||
}
|
||||
|
||||
// InvalidateGuild сбрасывает права всех пользователей сервера.
|
||||
func (c *Cache) InvalidateGuild(guildID uint64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for key, element := range c.items {
|
||||
if key.guildID == guildID {
|
||||
c.order.Remove(element)
|
||||
delete(c.items, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateChannel сбрасывает кэш комнаты.
|
||||
func (c *Cache) InvalidateChannel(channelID uint64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for key, element := range c.channels {
|
||||
if key.channelID == channelID {
|
||||
c.order.Remove(element)
|
||||
delete(c.channels, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items = make(map[guildKey]*list.Element, c.capacity)
|
||||
c.channels = make(map[channelKey]*list.Element, c.capacity)
|
||||
c.order.Init()
|
||||
}
|
||||
|
||||
func (c *Cache) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.order.Len()
|
||||
}
|
||||
|
||||
func (c *Cache) evictLocked() {
|
||||
for c.order.Len() > c.capacity {
|
||||
oldest := c.order.Back()
|
||||
if oldest == nil {
|
||||
return
|
||||
}
|
||||
entry := oldest.Value.(*cacheEntry)
|
||||
if entry.isChan {
|
||||
delete(c.channels, entry.channel)
|
||||
} else {
|
||||
delete(c.items, entry.guild)
|
||||
}
|
||||
c.order.Remove(oldest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Channel вычисляет права пользователя в конкретной комнате: базовые права
|
||||
// сервера, затем оверрайды ролей, затем оверрайд пользователя (AGENT.md 6.2).
|
||||
func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uint64, instanceAdmin bool) (Resolved, error) {
|
||||
if cached, ok := c.cache.GetChannel(channelID, userID); ok {
|
||||
cached.IsInstanceAdmin = instanceAdmin
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
resolved, err := c.Guild(ctx, guildID, userID, instanceAdmin)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
resolved.Channel = resolved.Guild
|
||||
if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) {
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
overrides, err := c.source.ChannelOverrides(ctx, channelID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load channel overrides: %w", err)
|
||||
}
|
||||
roles, err := c.source.GuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild roles: %w", err)
|
||||
}
|
||||
memberRoleIDs, err := c.source.MemberRoleIDs(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load member roles: %w", err)
|
||||
}
|
||||
|
||||
assigned := make(map[uint64]bool, len(memberRoleIDs)+1)
|
||||
for _, roleID := range memberRoleIDs {
|
||||
assigned[roleID] = true
|
||||
}
|
||||
defaultRoleID := uint64(0)
|
||||
for _, role := range roles {
|
||||
if role.IsDefault {
|
||||
defaultRoleID = role.ID
|
||||
assigned[role.ID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Порядок как в AGENT.md 6.2: базовые права роли @user → оверрайды ролей →
|
||||
// персональный оверрайд пользователя.
|
||||
denied, allowed := Permission(0), Permission(0)
|
||||
for _, override := range overrides {
|
||||
if override.TargetType != "role" {
|
||||
continue
|
||||
}
|
||||
if override.TargetID == defaultRoleID || assigned[override.TargetID] {
|
||||
denied |= override.Deny
|
||||
allowed |= override.Allow
|
||||
}
|
||||
}
|
||||
computed := (resolved.Guild &^ denied) | allowed
|
||||
|
||||
// Персональный deny обязан перебить ролевой allow, поэтому применяется
|
||||
// к уже вычисленному результату.
|
||||
var userDeny, userAllow Permission
|
||||
for _, override := range overrides {
|
||||
if override.TargetType == "user" && override.TargetID == userID {
|
||||
userDeny |= override.Deny
|
||||
userAllow |= override.Allow
|
||||
}
|
||||
}
|
||||
resolved.Channel = (computed &^ userDeny) | userAllow
|
||||
c.cache.PutChannel(channelID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package permissions_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/source"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// newTestStore поднимает реальную SQLite со всеми миграциями: так проверяются
|
||||
// и SQL-запросы, и связка store → permissions (AGENT.md §11.1).
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Errorf("close database: %v", err)
|
||||
}
|
||||
})
|
||||
return store.New(db)
|
||||
}
|
||||
|
||||
func createUser(t *testing.T, st *store.Store, username string) *store.User {
|
||||
t.Helper()
|
||||
user, err := st.CreateUser(context.Background(), store.CreateUserParams{
|
||||
Username: username,
|
||||
DisplayName: username,
|
||||
EmailEnc: "v1:encrypted-" + username,
|
||||
EmailIndex: "index-" + username,
|
||||
PasswordHash: "argon2id$test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create user %s: %v", username, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// buildGuild создаёт сервер с ролями по умолчанию: «Администратор» и «Пользователь».
|
||||
func buildGuild(t *testing.T, st *store.Store, owner *store.User) (*store.Guild, *store.Role, *store.Role) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Test Guild", OwnerID: owner.ID, IsMain: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create guild: %v", err)
|
||||
}
|
||||
adminRole, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create admin role: %v", err)
|
||||
}
|
||||
userRole, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions),
|
||||
Position: 0, IsDefault: true, Mentionable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create default role: %v", err)
|
||||
}
|
||||
if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil {
|
||||
t.Fatalf("add owner as member: %v", err)
|
||||
}
|
||||
return guild, adminRole, userRole
|
||||
}
|
||||
|
||||
func TestEngineAgainstRealDatabase(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
calculator := permissions.NewCalculator(source.New(st))
|
||||
|
||||
owner := createUser(t, st, "owner")
|
||||
member := createUser(t, st, "member")
|
||||
moderator := createUser(t, st, "moderator")
|
||||
guild, adminRole, userRole := buildGuild(t, st, owner)
|
||||
|
||||
for _, user := range []*store.User{member, moderator} {
|
||||
if _, err := st.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil {
|
||||
t.Fatalf("add member: %v", err)
|
||||
}
|
||||
}
|
||||
moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Модератор",
|
||||
Permissions: uint64(permissions.ViewGuild | permissions.ViewChannel | permissions.SendMessages |
|
||||
permissions.ManageMessages | permissions.KickMembers),
|
||||
Position: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create moderator role: %v", err)
|
||||
}
|
||||
if err := st.AssignRole(ctx, guild.ID, moderator.ID, moderatorRole.ID); err != nil {
|
||||
t.Fatalf("assign moderator role: %v", err)
|
||||
}
|
||||
|
||||
channel, err := st.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
private, err := st.CreateChannel(ctx, store.CreateChannelParams{
|
||||
GuildID: &guild.ID, Type: store.ChannelText, Name: "приватный",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create private channel: %v", err)
|
||||
}
|
||||
// Приватный канал: @user не видит, модератор — видит.
|
||||
if err := st.SetChannelOverride(ctx, store.ChannelOverride{
|
||||
ChannelID: private.ID, TargetType: "role", TargetID: userRole.ID,
|
||||
Deny: uint64(permissions.ViewChannel | permissions.ReadMessageHistory),
|
||||
}); err != nil {
|
||||
t.Fatalf("set override: %v", err)
|
||||
}
|
||||
if err := st.SetChannelOverride(ctx, store.ChannelOverride{
|
||||
ChannelID: private.ID, TargetType: "role", TargetID: moderatorRole.ID,
|
||||
Allow: uint64(permissions.ViewChannel | permissions.ReadMessageHistory),
|
||||
}); err != nil {
|
||||
t.Fatalf("set moderator override: %v", err)
|
||||
}
|
||||
calculator.InvalidateAll()
|
||||
|
||||
t.Run("member sees public channel only", func(t *testing.T) {
|
||||
public, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !public.Can(permissions.SendMessages) {
|
||||
t.Fatal("member must send messages in the public channel")
|
||||
}
|
||||
hidden, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("private channel permissions: %v", err)
|
||||
}
|
||||
if hidden.CanViewChannel() {
|
||||
t.Fatal("member must not see the private channel")
|
||||
}
|
||||
if err := permissions.RequireChannel(hidden, permissions.ViewChannel); !errors.Is(err, permissions.ErrDenied) {
|
||||
t.Fatalf("RequireChannel = %v, want ErrDenied", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("moderator sees private channel and can moderate", func(t *testing.T) {
|
||||
visible, err := calculator.Channel(ctx, guild.ID, private.ID, moderator.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !visible.CanViewChannel() || !visible.Can(permissions.ReadMessageHistory) {
|
||||
t.Fatal("moderator must see the private channel")
|
||||
}
|
||||
if !visible.Has(permissions.KickMembers) {
|
||||
t.Fatal("moderator must keep guild-level KICK_MEMBERS")
|
||||
}
|
||||
if visible.Can(permissions.BanMembers) {
|
||||
t.Fatal("moderator must not receive BAN_MEMBERS")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("owner has administrator everywhere", func(t *testing.T) {
|
||||
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, owner.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !resolved.IsOwner || !resolved.Can(permissions.Administrator) {
|
||||
t.Fatal("owner must have ADMINISTRATOR")
|
||||
}
|
||||
if !resolved.CanViewChannel() {
|
||||
t.Fatal("owner must see any channel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("instance admin bypasses privacy", func(t *testing.T) {
|
||||
stranger := createUser(t, st, "instance-admin")
|
||||
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, stranger.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !resolved.CanViewChannel() || !resolved.Can(permissions.ManageRoles) {
|
||||
t.Fatal("instance admin must bypass privacy and permissions")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("timeout blocks writing", func(t *testing.T) {
|
||||
timeout := store.TimeoutFromNow(1)
|
||||
if err := st.UpdateGuildMember(ctx, guild.ID, member.ID, nil, &timeout); err != nil {
|
||||
t.Fatalf("set timeout: %v", err)
|
||||
}
|
||||
calculator.InvalidateAll()
|
||||
|
||||
resolved, err := calculator.Channel(ctx, guild.ID, channel.ID, member.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !resolved.TimedOut {
|
||||
t.Fatal("timeout must be reflected in resolved permissions")
|
||||
}
|
||||
if resolved.Can(permissions.SendMessages) {
|
||||
t.Fatal("timed out member must not write")
|
||||
}
|
||||
if !resolved.Can(permissions.ViewChannel) {
|
||||
t.Fatal("timed out member must still read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("administrator role ignores overrides", func(t *testing.T) {
|
||||
if err := st.AssignRole(ctx, guild.ID, member.ID, adminRole.ID); err != nil {
|
||||
t.Fatalf("assign admin role: %v", err)
|
||||
}
|
||||
calculator.InvalidateAll()
|
||||
resolved, err := calculator.Channel(ctx, guild.ID, private.ID, member.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("channel permissions: %v", err)
|
||||
}
|
||||
if !resolved.CanViewChannel() {
|
||||
t.Fatal("administrator role must ignore deny overrides")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoleHierarchyHelpersWithRealRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
owner := createUser(t, st, "owner2")
|
||||
guild, adminRole, userRole := buildGuild(t, st, owner)
|
||||
|
||||
moderatorRole, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Модератор", Position: 50,
|
||||
Permissions: uint64(permissions.KickMembers),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create role: %v", err)
|
||||
}
|
||||
|
||||
toData := func(role *store.Role) permissions.RoleData {
|
||||
return permissions.RoleData{ID: role.ID, Permissions: permissions.Permission(role.Permissions), Position: role.Position}
|
||||
}
|
||||
if !permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(userRole), false) {
|
||||
t.Fatal("moderator must outrank the default role")
|
||||
}
|
||||
if permissions.OutranksRole([]permissions.RoleData{toData(moderatorRole)}, toData(adminRole), false) {
|
||||
t.Fatal("moderator must not outrank the administrator role")
|
||||
}
|
||||
if !permissions.OutranksRole(nil, toData(adminRole), true) {
|
||||
t.Fatal("owner must outrank every role")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Package permissions — ядро вычисления прав glchat (AGENT.md 6.2, 7.4).
|
||||
// Любая проверка прав на сервере обязана идти через этот пакет.
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Permission — битовая маска прав (uint64, AGENT.md 6.2).
|
||||
type Permission uint64
|
||||
|
||||
const (
|
||||
ViewGuild Permission = 1 << iota
|
||||
ViewChannel
|
||||
ReadMessageHistory
|
||||
SendMessages
|
||||
AttachFiles
|
||||
EmbedLinks
|
||||
AddReactions
|
||||
UseExternalEmojis
|
||||
MentionEveryone
|
||||
ManageMessages
|
||||
ConnectVoice
|
||||
Speak
|
||||
Video
|
||||
ScreenShare
|
||||
UseSoundboard
|
||||
MuteMembers
|
||||
DeafenMembers
|
||||
MoveMembers
|
||||
ManageChannels
|
||||
ManageChannelStatus
|
||||
ManageChannelDescription
|
||||
ManageChannelBackground
|
||||
ManageChannelPermissions
|
||||
ManageGuild
|
||||
ManageRoles
|
||||
ManageEmojis
|
||||
ManageSounds
|
||||
UseWebhooks
|
||||
ManageWebhooks
|
||||
CreateInvites
|
||||
KickMembers
|
||||
BanMembers
|
||||
TimeoutMembers
|
||||
ViewAuditLog
|
||||
ManageNicknames
|
||||
ChangeNickname
|
||||
Administrator
|
||||
)
|
||||
|
||||
// AllPermissions — маска всех прав.
|
||||
const AllPermissions Permission = (1 << 37) - 1
|
||||
|
||||
var permissionNames = map[Permission]string{
|
||||
ViewGuild: "VIEW_GUILD",
|
||||
ViewChannel: "VIEW_CHANNEL",
|
||||
ReadMessageHistory: "READ_MESSAGE_HISTORY",
|
||||
SendMessages: "SEND_MESSAGES",
|
||||
AttachFiles: "ATTACH_FILES",
|
||||
EmbedLinks: "EMBED_LINKS",
|
||||
AddReactions: "ADD_REACTIONS",
|
||||
UseExternalEmojis: "USE_EXTERNAL_EMOJIS",
|
||||
MentionEveryone: "MENTION_EVERYONE",
|
||||
ManageMessages: "MANAGE_MESSAGES",
|
||||
ConnectVoice: "CONNECT_VOICE",
|
||||
Speak: "SPEAK",
|
||||
Video: "VIDEO",
|
||||
ScreenShare: "SCREEN_SHARE",
|
||||
UseSoundboard: "USE_SOUNDBOARD",
|
||||
MuteMembers: "MUTE_MEMBERS",
|
||||
DeafenMembers: "DEAFEN_MEMBERS",
|
||||
MoveMembers: "MOVE_MEMBERS",
|
||||
ManageChannels: "MANAGE_CHANNELS",
|
||||
ManageChannelStatus: "MANAGE_CHANNEL_STATUS",
|
||||
ManageChannelDescription: "MANAGE_CHANNEL_DESCRIPTION",
|
||||
ManageChannelBackground: "MANAGE_CHANNEL_BACKGROUND",
|
||||
ManageChannelPermissions: "MANAGE_CHANNEL_PERMISSIONS",
|
||||
ManageGuild: "MANAGE_GUILD",
|
||||
ManageRoles: "MANAGE_ROLES",
|
||||
ManageEmojis: "MANAGE_EMOJIS",
|
||||
ManageSounds: "MANAGE_SOUNDS",
|
||||
UseWebhooks: "USE_WEBHOOKS",
|
||||
ManageWebhooks: "MANAGE_WEBHOOKS",
|
||||
CreateInvites: "CREATE_INVITES",
|
||||
KickMembers: "KICK_MEMBERS",
|
||||
BanMembers: "BAN_MEMBERS",
|
||||
TimeoutMembers: "TIMEOUT_MEMBERS",
|
||||
ViewAuditLog: "VIEW_AUDIT_LOG",
|
||||
ManageNicknames: "MANAGE_NICKNAMES",
|
||||
ChangeNickname: "CHANGE_NICKNAME",
|
||||
Administrator: "ADMINISTRATOR",
|
||||
}
|
||||
|
||||
func (p Permission) Has(required Permission) bool {
|
||||
return p&required == required
|
||||
}
|
||||
|
||||
// Any сообщает, что установлен хотя бы один бит из маски.
|
||||
func (p Permission) Any(mask Permission) bool {
|
||||
return p&mask != 0
|
||||
}
|
||||
|
||||
func (p Permission) String() string {
|
||||
if p == 0 {
|
||||
return "NONE"
|
||||
}
|
||||
names := make([]string, 0, 4)
|
||||
for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 {
|
||||
if p&bit != 0 {
|
||||
names = append(names, permissionNames[bit])
|
||||
}
|
||||
}
|
||||
return strings.Join(names, "|")
|
||||
}
|
||||
|
||||
// AllPermissionsList возвращает все права по порядку битов (для API и UI).
|
||||
func AllPermissionsList() []Permission {
|
||||
list := make([]Permission, 0, 37)
|
||||
for bit := ViewGuild; bit <= Administrator; bit <<= 1 {
|
||||
list = append(list, bit)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// Name возвращает строковое имя права.
|
||||
func Name(permission Permission) string {
|
||||
if name, ok := permissionNames[permission]; ok {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("UNKNOWN_%d", uint64(permission))
|
||||
}
|
||||
|
||||
// ByName ищет право по имени (нужно API и импорту шаблонов).
|
||||
func ByName(name string) (Permission, bool) {
|
||||
for permission, candidate := range permissionNames {
|
||||
if candidate == strings.ToUpper(strings.TrimSpace(name)) {
|
||||
return permission, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// DefaultUserPermissions — права роли «Пользователь» при создании сервера
|
||||
// (AGENT.md 6.3).
|
||||
const DefaultUserPermissions = ViewGuild | ViewChannel | ReadMessageHistory |
|
||||
SendMessages | AttachFiles | AddReactions | ConnectVoice | Speak
|
||||
|
||||
// Resolved — результат вычисления прав для контекста сервера и комнаты.
|
||||
type Resolved struct {
|
||||
// Guild — права на уровне сервера (без учёта оверрайдов комнаты).
|
||||
Guild Permission
|
||||
// Channel — права с учётом оверрайдов (только для конкретной комнаты).
|
||||
Channel Permission
|
||||
// IsOwner — пользователь владеет сервером: неявный ADMINISTRATOR (AGENT.md 6.2).
|
||||
IsOwner bool
|
||||
// IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19).
|
||||
IsInstanceAdmin bool
|
||||
// TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10).
|
||||
TimedOut bool
|
||||
}
|
||||
|
||||
// Has — есть ли право на сервере. ADMINISTRATOR даёт все права (AGENT.md 6.2).
|
||||
func (r Resolved) Has(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Guild.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Guild.Has(permission)
|
||||
}
|
||||
|
||||
// Can — есть ли право в комнате (с учётом тайм-аута и оверрайдов).
|
||||
func (r Resolved) Can(permission Permission) bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
if !r.Channel.Has(permission) {
|
||||
return false
|
||||
}
|
||||
if r.TimedOut && permission.Any(SendMessages|AddReactions|AttachFiles|Speak) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CanViewChannel — может ли пользователь видеть комнату. Требуется именно
|
||||
// VIEW_CHANNEL: CONNECT_VOICE сам по себе комнату в списке не показывает
|
||||
// (текстовая комната без VIEW_CHANNEL скрыта, AGENT.md 7.5).
|
||||
func (r Resolved) CanViewChannel() bool {
|
||||
if r.IsInstanceAdmin {
|
||||
return true
|
||||
}
|
||||
if r.Channel.Has(Administrator) {
|
||||
return true
|
||||
}
|
||||
return r.Channel.Has(ViewChannel)
|
||||
}
|
||||
|
||||
// Source — данные для расчёта прав: роли сервера и роли участника.
|
||||
type Source interface {
|
||||
GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error)
|
||||
MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error)
|
||||
ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error)
|
||||
GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error)
|
||||
MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error)
|
||||
}
|
||||
|
||||
// RoleData и OverrideData описывают входные данные расчёта без зависимости
|
||||
// пакета прав от пакета хранения.
|
||||
type RoleData struct {
|
||||
ID uint64
|
||||
Permissions Permission
|
||||
IsDefault bool
|
||||
Position int
|
||||
}
|
||||
|
||||
type OverrideData struct {
|
||||
TargetType string // role | user
|
||||
TargetID uint64
|
||||
Allow Permission
|
||||
Deny Permission
|
||||
}
|
||||
|
||||
// Calculator вычисляет права и кэширует результат (AGENT.md 9.1).
|
||||
type Calculator struct {
|
||||
source Source
|
||||
cache *Cache
|
||||
}
|
||||
|
||||
func NewCalculator(source Source) *Calculator {
|
||||
return &Calculator{source: source, cache: NewCache(4096)}
|
||||
}
|
||||
|
||||
// InvalidateGuild сбрасывает кэш сервера при изменении ролей или оверрайдов.
|
||||
func (c *Calculator) InvalidateGuild(guildID uint64) { c.cache.InvalidateGuild(guildID) }
|
||||
|
||||
// InvalidateChannel сбрасывает кэш комнаты.
|
||||
func (c *Calculator) InvalidateChannel(channelID uint64) { c.cache.InvalidateChannel(channelID) }
|
||||
|
||||
// InvalidateAll полностью очищает кэш (после импорта или массовых изменений).
|
||||
func (c *Calculator) InvalidateAll() { c.cache.Clear() }
|
||||
|
||||
// Guild вычисляет права пользователя на сервере.
|
||||
func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instanceAdmin bool) (Resolved, error) {
|
||||
if cached, ok := c.cache.GetGuild(guildID, userID); ok {
|
||||
cached.IsInstanceAdmin = instanceAdmin
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
roles, err := c.source.GuildRoles(ctx, guildID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild roles: %w", err)
|
||||
}
|
||||
memberRoleIDs, err := c.source.MemberRoleIDs(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load member roles: %w", err)
|
||||
}
|
||||
ownerID, err := c.source.GuildOwnerID(ctx, guildID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load guild owner: %w", err)
|
||||
}
|
||||
|
||||
resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin}
|
||||
assigned := make(map[uint64]bool, len(memberRoleIDs))
|
||||
for _, roleID := range memberRoleIDs {
|
||||
assigned[roleID] = true
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
if role.IsDefault || assigned[role.ID] {
|
||||
resolved.Guild |= role.Permissions
|
||||
}
|
||||
}
|
||||
if resolved.IsOwner {
|
||||
// Владелец сервера имеет ADMINISTRATOR неявно и не может его лишиться.
|
||||
resolved.Guild |= Administrator
|
||||
}
|
||||
timedOut, err := c.source.MemberTimeout(ctx, guildID, userID)
|
||||
if err != nil {
|
||||
return Resolved{}, fmt.Errorf("load member timeout: %w", err)
|
||||
}
|
||||
resolved.TimedOut = timedOut
|
||||
|
||||
c.cache.PutGuild(guildID, userID, resolved)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// RequireGuild проверяет право на сервере и возвращает ошибку доступа.
|
||||
func RequireGuild(resolved Resolved, permission Permission) error {
|
||||
if !resolved.Has(permission) {
|
||||
return fmt.Errorf("%w: %s", ErrDenied, Name(permission))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireChannel проверяет право в комнате и возвращает ошибку доступа.
|
||||
func RequireChannel(resolved Resolved, permission Permission) error {
|
||||
if !resolved.Can(permission) {
|
||||
return fmt.Errorf("%w: %s", ErrDenied, Name(permission))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrDenied — единая ошибка отсутствия прав (на HTTP-слое → perm.denied).
|
||||
var ErrDenied = fmt.Errorf("permission denied")
|
||||
|
||||
// HigherThan сообщает, что роль a выше роли b (AGENT.md 7.4).
|
||||
func HigherThan(a, b RoleData) bool { return a.Position > b.Position }
|
||||
|
||||
// OutranksRole проверяет, что актор может управлять ролью: его высшая роль
|
||||
// должна быть выше целевой, владелец — выше всех (AGENT.md 7.4).
|
||||
func OutranksRole(actorRoles []RoleData, targetRole RoleData, isOwner bool) bool {
|
||||
if isOwner {
|
||||
return true
|
||||
}
|
||||
for _, role := range actorRoles {
|
||||
if role.Position > targetRole.Position {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OutranksMember проверяет иерархию участников: нельзя управлять тем, у кого
|
||||
// высшая роль не ниже роли актора (владелец — выше всех).
|
||||
func OutranksMember(actorRoles, targetRoles []RoleData, actorIsOwner, targetIsOwner bool) bool {
|
||||
if actorIsOwner {
|
||||
return !targetIsOwner || actorIsOwner
|
||||
}
|
||||
if targetIsOwner {
|
||||
return false
|
||||
}
|
||||
actorTop := topPosition(actorRoles)
|
||||
targetTop := topPosition(targetRoles)
|
||||
return actorTop > targetTop
|
||||
}
|
||||
|
||||
func topPosition(roles []RoleData) int {
|
||||
top := 0
|
||||
for _, role := range roles {
|
||||
if role.Position > top {
|
||||
top = role.Position
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSource — управляемый источник данных для тестов прав.
|
||||
type fakeSource struct {
|
||||
roles map[uint64][]RoleData
|
||||
memberRoles map[uint64]map[uint64][]uint64
|
||||
overrides map[uint64][]OverrideData
|
||||
owners map[uint64]uint64
|
||||
timeouts map[uint64]map[uint64]bool
|
||||
}
|
||||
|
||||
func newFakeSource() *fakeSource {
|
||||
return &fakeSource{
|
||||
roles: map[uint64][]RoleData{},
|
||||
memberRoles: map[uint64]map[uint64][]uint64{},
|
||||
overrides: map[uint64][]OverrideData{},
|
||||
owners: map[uint64]uint64{},
|
||||
timeouts: map[uint64]map[uint64]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSource) GuildRoles(_ context.Context, guildID uint64) ([]RoleData, error) {
|
||||
return f.roles[guildID], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([]uint64, error) {
|
||||
if byUser, ok := f.memberRoles[guildID]; ok {
|
||||
return byUser[userID], nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) {
|
||||
return f.overrides[channelID], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) GuildOwnerID(_ context.Context, guildID uint64) (uint64, error) {
|
||||
return f.owners[guildID], nil
|
||||
}
|
||||
|
||||
func (f *fakeSource) MemberTimeout(_ context.Context, guildID, userID uint64) (bool, error) {
|
||||
if byUser, ok := f.timeouts[guildID]; ok {
|
||||
return byUser[userID], nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
const (
|
||||
guildID = uint64(100)
|
||||
ownerID = uint64(1)
|
||||
memberID = uint64(2)
|
||||
adminID = uint64(3)
|
||||
defaultID = uint64(10)
|
||||
adminRole = uint64(11)
|
||||
moderatorRole = uint64(12)
|
||||
channelID = uint64(500)
|
||||
)
|
||||
|
||||
func fixture() (*fakeSource, *Calculator) {
|
||||
source := newFakeSource()
|
||||
source.roles[guildID] = []RoleData{
|
||||
{ID: adminRole, Permissions: Administrator, Position: 100},
|
||||
{ID: moderatorRole, Permissions: ManageMessages | KickMembers | ViewGuild | ViewChannel | SendMessages, Position: 50},
|
||||
{ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0},
|
||||
}
|
||||
source.memberRoles[guildID] = map[uint64][]uint64{
|
||||
memberID: {},
|
||||
adminID: {adminRole},
|
||||
ownerID: {},
|
||||
}
|
||||
source.owners[guildID] = ownerID
|
||||
return source, NewCalculator(source)
|
||||
}
|
||||
|
||||
func TestDefaultRoleProvidesBasePermissions(t *testing.T) {
|
||||
_, calculator := fixture()
|
||||
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if !resolved.Has(SendMessages) || !resolved.Has(ViewChannel) {
|
||||
t.Fatalf("default role permissions not applied: %s", resolved.Guild)
|
||||
}
|
||||
if resolved.Has(KickMembers) {
|
||||
t.Fatal("member must not receive moderator permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignedRoleAddsPermissions(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
|
||||
calculator.InvalidateAll()
|
||||
|
||||
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if !resolved.Has(KickMembers) || !resolved.Has(ManageMessages) {
|
||||
t.Fatalf("assigned role permissions missing: %s", resolved.Guild)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorGrantsEverything(t *testing.T) {
|
||||
_, calculator := fixture()
|
||||
resolved, err := calculator.Guild(context.Background(), guildID, adminID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if !resolved.Has(Administrator) {
|
||||
t.Fatal("administrator bit must be set")
|
||||
}
|
||||
if !resolved.Has(BanMembers) || !resolved.Has(ManageGuild) {
|
||||
t.Fatal("ADMINISTRATOR must imply every permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerAlwaysHasAdministrator(t *testing.T) {
|
||||
source := newFakeSource()
|
||||
// Владелец без ролей и без ADMINISTRATOR у роли @user.
|
||||
source.roles[guildID] = []RoleData{{ID: defaultID, Permissions: ViewGuild, IsDefault: true}}
|
||||
source.owners[guildID] = ownerID
|
||||
calculator := NewCalculator(source)
|
||||
|
||||
resolved, err := calculator.Guild(context.Background(), guildID, ownerID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if !resolved.IsOwner || !resolved.Has(Administrator) {
|
||||
t.Fatal("owner must implicitly receive ADMINISTRATOR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAdminBypassesEverything(t *testing.T) {
|
||||
_, calculator := fixture()
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
// Инстанс-админ обходит и приватность комнаты, и продуктовые лимиты (AGENT.md 7.19).
|
||||
if !resolved.Can(BanMembers) || !resolved.CanViewChannel() {
|
||||
t.Fatal("instance admin must bypass permission checks")
|
||||
}
|
||||
if err := RequireChannel(resolved, Administrator); err != nil {
|
||||
t.Fatalf("instance admin must satisfy Administrator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDenyOverrideRemovesPermission(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "role", TargetID: defaultID, Deny: SendMessages},
|
||||
}
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.Can(SendMessages) {
|
||||
t.Fatal("deny override must remove the permission")
|
||||
}
|
||||
if !resolved.Can(ViewChannel) {
|
||||
t.Fatal("other permissions must survive the override")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAllowOverrideGrantsPermission(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "user", TargetID: memberID, Deny: ViewChannel},
|
||||
{TargetType: "role", TargetID: defaultID, Allow: ConnectVoice},
|
||||
}
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.Can(ViewChannel) {
|
||||
t.Fatal("personal deny must hide the channel")
|
||||
}
|
||||
if !resolved.Can(ConnectVoice) {
|
||||
t.Fatal("role allow must add the permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserOverrideWinsOverRoleOverride(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "role", TargetID: moderatorRole, Allow: SendMessages},
|
||||
{TargetType: "user", TargetID: memberID, Deny: SendMessages},
|
||||
}
|
||||
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
|
||||
calculator.InvalidateAll()
|
||||
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.Can(SendMessages) {
|
||||
t.Fatal("user-level deny must be applied after role-level allow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorIgnoresChannelOverrides(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.overrides[channelID] = []OverrideData{
|
||||
{TargetType: "role", TargetID: adminRole, Deny: ViewChannel | SendMessages},
|
||||
}
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, adminID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if !resolved.CanViewChannel() || !resolved.Can(SendMessages) {
|
||||
t.Fatal("ADMINISTRATOR must ignore channel overrides")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutBlocksWritingButNotReading(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
source.timeouts[guildID] = map[uint64]bool{memberID: true}
|
||||
calculator.InvalidateAll()
|
||||
|
||||
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Channel: %v", err)
|
||||
}
|
||||
if resolved.Can(SendMessages) || resolved.Can(AddReactions) {
|
||||
t.Fatal("timed out member must not be able to write")
|
||||
}
|
||||
if !resolved.Can(ViewChannel) || !resolved.Can(ReadMessageHistory) {
|
||||
t.Fatal("timed out member must still read the channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireHelpersReturnDenied(t *testing.T) {
|
||||
_, calculator := fixture()
|
||||
resolved, err := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if err := RequireGuild(resolved, SendMessages); err != nil {
|
||||
t.Fatalf("RequireGuild(SendMessages): %v", err)
|
||||
}
|
||||
if err := RequireGuild(resolved, BanMembers); !errors.Is(err, ErrDenied) {
|
||||
t.Fatalf("RequireGuild(BanMembers) = %v, want ErrDenied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutranksRoleAndMember(t *testing.T) {
|
||||
top := RoleData{ID: adminRole, Position: 100}
|
||||
middle := RoleData{ID: moderatorRole, Position: 50}
|
||||
bottom := RoleData{ID: defaultID, Position: 0}
|
||||
|
||||
if !OutranksRole([]RoleData{middle}, bottom, false) {
|
||||
t.Fatal("moderator must outrank the default role")
|
||||
}
|
||||
if OutranksRole([]RoleData{middle}, top, false) {
|
||||
t.Fatal("moderator must not outrank the administrator role")
|
||||
}
|
||||
if !OutranksRole(nil, top, true) {
|
||||
t.Fatal("owner must outrank any role")
|
||||
}
|
||||
if !OutranksMember([]RoleData{top}, []RoleData{middle}, false, false) {
|
||||
t.Fatal("higher role must outrank the lower member")
|
||||
}
|
||||
if OutranksMember([]RoleData{middle}, []RoleData{top}, false, false) {
|
||||
t.Fatal("lower role must not manage the higher member")
|
||||
}
|
||||
if OutranksMember([]RoleData{top}, nil, false, true) {
|
||||
t.Fatal("nobody except the owner may manage the owner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionNamesRoundTrip(t *testing.T) {
|
||||
for _, permission := range AllPermissionsList() {
|
||||
name := Name(permission)
|
||||
parsed, ok := ByName(name)
|
||||
if !ok || parsed != permission {
|
||||
t.Fatalf("round trip failed for %s (%d)", name, permission)
|
||||
}
|
||||
}
|
||||
if _, ok := ByName("NOT_A_PERMISSION"); ok {
|
||||
t.Fatal("unknown permission name must not parse")
|
||||
}
|
||||
if len(AllPermissionsList()) != 37 {
|
||||
t.Fatalf("expected 37 permissions, got %d", len(AllPermissionsList()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheInvalidation(t *testing.T) {
|
||||
source, calculator := fixture()
|
||||
first, err := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild: %v", err)
|
||||
}
|
||||
if first.Has(KickMembers) {
|
||||
t.Fatal("member must not have KICK_MEMBERS initially")
|
||||
}
|
||||
|
||||
// Меняем роли в источнике без инвалидации: кэш должен отдать старое значение.
|
||||
source.memberRoles[guildID][memberID] = []uint64{moderatorRole}
|
||||
cached, _ := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if cached.Has(KickMembers) {
|
||||
t.Fatal("cache must serve the stale value until invalidation")
|
||||
}
|
||||
|
||||
calculator.InvalidateGuild(guildID)
|
||||
updated, err := calculator.Guild(context.Background(), guildID, memberID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Guild after invalidation: %v", err)
|
||||
}
|
||||
if !updated.Has(KickMembers) {
|
||||
t.Fatal("after invalidation the new role must be visible")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user