256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
|
|
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")
|
||
|
|
}
|
||
|
|
}
|