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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user