319 lines
10 KiB
Go
319 lines
10 KiB
Go
|
|
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")
|
|||
|
|
}
|
|||
|
|
}
|