Files
glchat/internal/permissions/permissions_test.go
T
grendervill a5205cc054 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 из константных шаблонов)
2026-09-19 21:24:17 +03:00

319 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}