Files
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

361 lines
12 KiB
Go
Raw Permalink 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) IsMember(_ context.Context, guildID, userID uint64) (bool, error) {
if byUser, ok := f.memberRoles[guildID]; ok {
if _, found := byUser[userID]; found {
return true, nil
}
}
return false, 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 TestNonMemberHasNoPermissions(t *testing.T) {
source := newFakeSource()
source.roles[guildID] = []RoleData{
{ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0},
}
source.owners[guildID] = ownerID
source.overrides[channelID] = []OverrideData{
{TargetType: "user", TargetID: memberID, Allow: ViewChannel | SendMessages},
}
calculator := NewCalculator(source)
// Пользователь вне сервера: роль @user и персональный оверрайд не действуют.
resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false)
if err != nil {
t.Fatalf("Channel: %v", err)
}
if resolved.IsMember {
t.Fatal("user without membership must not be a member")
}
if resolved.Has(ViewGuild) || resolved.CanViewChannel() || resolved.Can(SendMessages) {
t.Fatalf("non-member received permissions: guild=%s channel=%s", resolved.Guild, resolved.Channel)
}
// Инстанс-админ обходит отсутствие участия (AGENT.md 7.19).
admin, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true)
if err != nil {
t.Fatalf("Channel as instance admin: %v", err)
}
if !admin.CanViewChannel() || !admin.Has(ManageGuild) {
t.Fatal("instance admin must bypass membership checks")
}
}
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")
}
}