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) IsMember(_ context.Context, guildID, userID uint64) (bool, error) { if byUser, ok := f.memberRoles[guildID]; ok { if _, found := byUser[userID]; found { return true, nil } } return false, 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 TestNonMemberHasNoPermissions(t *testing.T) { source := newFakeSource() source.roles[guildID] = []RoleData{ {ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0}, } source.owners[guildID] = ownerID source.overrides[channelID] = []OverrideData{ {TargetType: "user", TargetID: memberID, Allow: ViewChannel | SendMessages}, } calculator := NewCalculator(source) // Пользователь вне сервера: роль @user и персональный оверрайд не действуют. resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) if err != nil { t.Fatalf("Channel: %v", err) } if resolved.IsMember { t.Fatal("user without membership must not be a member") } if resolved.Has(ViewGuild) || resolved.CanViewChannel() || resolved.Can(SendMessages) { t.Fatalf("non-member received permissions: guild=%s channel=%s", resolved.Guild, resolved.Channel) } // Инстанс-админ обходит отсутствие участия (AGENT.md 7.19). admin, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true) if err != nil { t.Fatalf("Channel as instance admin: %v", err) } if !admin.CanViewChannel() || !admin.Has(ManageGuild) { t.Fatal("instance admin must bypass membership checks") } } 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") } }