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") } }