From c0537d3c17367bf6f56c037c79a33506a03cbce2 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 21:37:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(bootstrap):=20=D0=BF=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B8=20CLI-=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD?= =?UTF-8?q?=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/bootstrap: создание инстанс-админа, главного сервера, ролей «Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3), общей текстовой комнаты; повторный запуск идемпотентен - админ получает флаг is_instance_admin и системный бейдж instance_admin - CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin (работают внутри контейнера через DATA_DIR) - auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход флага регистрации — bootstrap выполняет установщик) - тесты: создание админа и главного сервера, идемпотентность, автовступление нового пользователя с ролью @user --- .golangci.yml | 5 + cmd/glchat/cli.go | 178 ++++++++++++++++++++ cmd/glchat/main.go | 8 + internal/auth/service.go | 79 +++++++++ internal/bootstrap/bootstrap.go | 239 +++++++++++++++++++++++++++ internal/bootstrap/bootstrap_test.go | 190 +++++++++++++++++++++ 6 files changed, 699 insertions(+) create mode 100644 cmd/glchat/cli.go create mode 100644 internal/bootstrap/bootstrap.go create mode 100644 internal/bootstrap/bootstrap_test.go diff --git a/.golangci.yml b/.golangci.yml index 3746c8a..b4ed47a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -118,6 +118,11 @@ linters: - path: "internal/store/" linters: - gosec + # CLI печатает результат команд пользователю (AGENT.md 10.7): это не логи, + # а контракт вывода, поэтому fmt допустим только в cmd/. + - path: "^cmd/" + linters: + - forbidigo - path: "(^|/)internal/database/sqlc/" linters: - revive diff --git a/cmd/glchat/cli.go b/cmd/glchat/cli.go new file mode 100644 index 0000000..7d16986 --- /dev/null +++ b/cmd/glchat/cli.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "glchat/internal/auth" + "glchat/internal/bootstrap" + "glchat/internal/config" + "glchat/internal/database" + "glchat/internal/store" +) + +// runCLI обрабатывает подкоманды обслуживания (AGENT.md 10.7). Команды, +// требующие БД, работают внутри контейнера: путь берётся из DATA_DIR. +func runCLI(args []string) int { + switch args[0] { + case "bootstrap-admin": + return cliBootstrapAdmin(args[1:]) + case "reset-password": + return cliResetPassword(args[1:]) + case "make-admin", "remove-admin": + return cliSetInstanceAdmin(args[0], args[1:]) + default: + fmt.Fprintf(os.Stderr, "glchat: неизвестная команда %q\n", args[0]) + return 2 + } +} + +func withServices(fn func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error) int { + ctx := context.Background() + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "glchat: %v\n", err) + return 1 + } + db, err := database.Open(ctx, database.Options{ + Path: cfg.DatabasePath, + ReadPool: cfg.ReadPoolSize, + CacheSize: cfg.SQLiteCache, + MmapSize: cfg.SQLiteMmap, + Migrate: true, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "glchat: %v\n", err) + return 1 + } + defer func() { _ = db.Close() }() + + st := store.New(db) + authService, err := auth.New(ctx, cfg, st, newLogger(cfg)) + if err != nil { + fmt.Fprintf(os.Stderr, "glchat: %v\n", err) + return 1 + } + if err := fn(ctx, st, authService, cfg); err != nil { + fmt.Fprintf(os.Stderr, "glchat: %v\n", err) + return 1 + } + return 0 +} + +func cliBootstrapAdmin(args []string) int { + flags := flag.NewFlagSet("bootstrap-admin", flag.ContinueOnError) + email := flags.String("email", "", "email администратора инстанса") + password := flags.String("password", "", "пароль администратора") + username := flags.String("username", "", "username (по умолчанию — часть email)") + instanceName := flags.String("instance-name", "", "название инстанса") + mainGuild := flags.String("main-guild", "", "название главного сервера") + if err := flags.Parse(args); err != nil { + return 2 + } + if *email == "" { + fmt.Fprintln(os.Stderr, "glchat bootstrap-admin: укажите --email") + return 2 + } + if *password == "" { + *password = os.Getenv("ADMIN_PASSWORD") + } + + return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, cfg config.Config) error { + runner := bootstrap.New(st, authService, newLogger(cfg)) + result, err := runner.Run(ctx, bootstrap.Options{ + Email: *email, + Password: *password, + Username: *username, + InstanceName: *instanceName, + MainGuild: *mainGuild, + }) + if err != nil { + return err + } + if result.CreatedUser { + fmt.Println("инстанс-администратор создан") + } else { + fmt.Println("инстанс-администратор уже существует (права подтверждены)") + } + fmt.Printf("главный сервер: %d (создан: %t)\n", result.GuildID, result.CreatedGuild) + fmt.Printf("роли: администратор=%d пользователь=%d\n", result.AdminRoleID, result.MemberRoleID) + return nil + }) +} + +func cliResetPassword(args []string) int { + flags := flag.NewFlagSet("reset-password", flag.ContinueOnError) + email := flags.String("email", "", "email пользователя") + password := flags.String("password", "", "новый пароль") + positional := flags.Args() + if err := flags.Parse(args); err != nil { + return 2 + } + if *email == "" && len(positional) > 0 { + *email = positional[0] + } + if *email == "" { + fmt.Fprintln(os.Stderr, "glchat reset-password: укажите email") + return 2 + } + if *password == "" { + fmt.Fprintln(os.Stderr, "glchat reset-password: укажите --password") + return 2 + } + + return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error { + user, err := authService.UserByEmail(ctx, *email) + if err != nil { + return fmt.Errorf("пользователь с таким email не найден") + } + if err := authService.SetPassword(ctx, user.ID, *password); err != nil { + return err + } + fmt.Printf("пароль пользователя %s обновлён, все сессии отозваны\n", user.Username) + return nil + }) +} + +func cliSetInstanceAdmin(command string, args []string) int { + flags := flag.NewFlagSet(command, flag.ContinueOnError) + email := flags.String("email", "", "email пользователя") + positional := flags.Args() + if err := flags.Parse(args); err != nil { + return 2 + } + if *email == "" && len(positional) > 0 { + *email = positional[0] + } + if *email == "" { + fmt.Fprintf(os.Stderr, "glchat %s: укажите email\n", command) + return 2 + } + grant := command == "make-admin" + + return withServices(func(ctx context.Context, st *store.Store, authService *auth.Service, _ config.Config) error { + user, err := authService.UserByEmail(ctx, *email) + if err != nil { + return fmt.Errorf("пользователь с таким email не найден") + } + if err := st.SetInstanceAdmin(ctx, user.ID, grant); err != nil { + return err + } + badges := []string{} + if grant { + // Бейдж администратора выдаётся только системой (AGENT.md 7.2). + badges = append(badges, "instance_admin") + } + if err := st.SetUserBadges(ctx, user.ID, badges); err != nil { + return err + } + action := "выданы" + if !grant { + action = "сняты" + } + fmt.Printf("права администратора сервера %s пользователю %s\n", action, user.Username) + return nil + }) +} diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index 9df610c..b85f3e0 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -33,6 +33,14 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "healthcheck" { os.Exit(healthcheck()) } + // Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password, + // make-admin, remove-admin. Остальные команды реализует shell-обёртка glchat. + if len(os.Args) > 1 { + switch os.Args[1] { + case "bootstrap-admin", "reset-password", "make-admin", "remove-admin": + os.Exit(runCLI(os.Args[1:])) + } + } if err := run(); err != nil { slog.Error("glchat exited with error", slog.Any("error", err)) os.Exit(1) diff --git a/internal/auth/service.go b/internal/auth/service.go index 727c67b..d5fe4a0 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -211,6 +211,85 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User, return user, token, session, nil } +// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД). +func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) { + return s.userByEmail(ctx, email) +} + +// SetPassword меняет пароль без проверки текущего: используется установщиком +// и CLI-командой reset-password, где пароль задаёт администратор инстанса. +func (s *Service) SetPassword(ctx context.Context, userID uint64, password string) error { + if err := s.policy.validate(password); err != nil { + return ErrWeakPassword + } + hash, err := s.hasher.Hash(password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil { + return err + } + // Смена пароля администратором отзывает все сессии пользователя. + if err := s.store.DeleteSessionsForUser(ctx, userID); err != nil { + return err + } + return s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", `{"by":"admin"}`) +} + +// RegisterAdmin создаёт аккаунт администратора инстанса в обход флага +// регистрации: bootstrap выполняется установщиком (AGENT.md 10.3). +func (s *Service) RegisterAdmin(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) { + username := strings.TrimSpace(in.Username) + if err := crypto.ValidateUsername(username); err != nil { + return nil, "", nil, ErrInvalidUsername + } + if err := s.policy.validate(in.Password); err != nil { + return nil, "", nil, ErrWeakPassword + } + email := crypto.NormalizeEmail(in.Email) + if err := validateEmail(email); err != nil { + return nil, "", nil, err + } + passwordHash, err := s.hasher.Hash(in.Password) + if err != nil { + return nil, "", nil, fmt.Errorf("hash password: %w", err) + } + + userID := s.store.NextID() + encrypted, err := s.encryptEmail(userID, email) + if err != nil { + return nil, "", nil, err + } + user, err := s.store.CreateUser(ctx, store.CreateUserParams{ + ID: userID, + Username: username, + DisplayName: defaultDisplayName(in.DisplayName, username), + EmailEnc: encrypted, + EmailIndex: s.masterKey.BlindIndex(email), + PasswordHash: passwordHash, + Locale: in.Locale, + }) + if err != nil { + return nil, "", nil, err + } + if err := s.store.SetInstanceAdmin(ctx, user.ID, true); err != nil { + return nil, "", nil, err + } + if err := s.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil { + s.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err)) + } + token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP) + if err != nil { + return nil, "", nil, err + } + _ = s.store.RecordSecurityEvent(ctx, &user.ID, "bootstrap_admin", in.IP, in.UserAgent, "") + user, err = s.store.GetUser(ctx, user.ID) + if err != nil { + return nil, "", nil, err + } + return user, token, session, nil +} + type LoginInput struct { Email string Password string diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..8b69ae4 --- /dev/null +++ b/internal/bootstrap/bootstrap.go @@ -0,0 +1,239 @@ +// Package bootstrap выполняет первичную настройку инстанса (AGENT.md 10.3 шаг 10, +// 13 Фаза 1): инстанс-администратор, главный сервер, роли по умолчанию. +package bootstrap + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + + "glchat/internal/auth" + "glchat/internal/crypto" + "glchat/internal/permissions" + "glchat/internal/store" +) + +// DefaultMainGuildName — название главного сервера по умолчанию. +const DefaultMainGuildName = "Главный сервер" + +// Options — параметры первичной настройки. +type Options struct { + Email string + Password string + Username string + InstanceName string + MainGuild string + Force bool +} + +// Result — что было создано (используется CLI и smoke-тестом). +type Result struct { + UserID uint64 + GuildID uint64 + AdminRoleID uint64 + MemberRoleID uint64 + CreatedUser bool + CreatedGuild bool + AlreadyDone bool +} + +// Runner выполняет первичную настройку поверх хранилища и сервиса аутентификации. +type Runner struct { + store *store.Store + auth *auth.Service + logger *slog.Logger +} + +func New(st *store.Store, authService *auth.Service, logger *slog.Logger) *Runner { + return &Runner{store: st, auth: authService, logger: logger} +} + +// Run создаёт администратора инстанса и главный сервер; повторный запуск +// идемпотентен (AGENT.md 10.3: установщик можно перезапускать). +func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) { + email := crypto.NormalizeEmail(opts.Email) + if email == "" { + return nil, errors.New("email is required") + } + if opts.Username == "" { + // Логин по умолчанию — часть адреса до @, приведённая к допустимым символам. + opts.Username = sanitizeUsername(strings.SplitN(email, "@", 2)[0]) + } + if err := crypto.ValidateUsername(opts.Username); err != nil { + return nil, fmt.Errorf("invalid admin username %q: %w", opts.Username, err) + } + + result := &Result{} + admin, err := r.findOrCreateAdmin(ctx, opts, result) + if err != nil { + return nil, err + } + + settings, err := r.store.InstanceSettings(ctx) + if err != nil { + return nil, fmt.Errorf("load instance settings: %w", err) + } + + if settings.MainGuildID != 0 { + guild, err := r.store.GetGuild(ctx, settings.MainGuildID) + if err == nil { + result.GuildID = guild.ID + result.AlreadyDone = !result.CreatedUser + if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil { + return nil, err + } + return result, nil + } + if !errors.Is(err, store.ErrNotFound) { + return nil, err + } + } + + guildName := opts.MainGuild + if guildName == "" { + guildName = DefaultMainGuildName + } + guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{ + Name: guildName, + OwnerID: admin.ID, + IsMain: true, + IsDiscoverable: false, + Description: "", + }) + if err != nil { + return nil, fmt.Errorf("create main guild: %w", err) + } + result.GuildID = guild.ID + result.CreatedGuild = true + + if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { + return nil, fmt.Errorf("set main guild: %w", err) + } + if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil { + return nil, err + } + // Главный сервер всегда содержит общую текстовую комнату. + if _, err := r.store.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, + }); err != nil { + return nil, fmt.Errorf("create main channel: %w", err) + } + return result, nil +} + +// findOrCreateAdmin находит администратора по email или создаёт его. +func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Result) (*store.User, error) { + email := crypto.NormalizeEmail(opts.Email) + existing, err := r.auth.UserByEmail(ctx, email) + if err == nil && existing != nil { + result.UserID = existing.ID + if !existing.IsInstanceAdmin { + if err := r.store.SetInstanceAdmin(ctx, existing.ID, true); err != nil { + return nil, fmt.Errorf("grant instance admin: %w", err) + } + } + // Пароль администратора, заданный установщиком, обновляем осознанно: + // без этого повторный запуск с новым паролем не менял бы доступ. + if opts.Password != "" { + if err := r.auth.SetPassword(ctx, existing.ID, opts.Password); err != nil { + return nil, fmt.Errorf("update admin password: %w", err) + } + } + return r.store.GetUser(ctx, existing.ID) + } + if err != nil && !errors.Is(err, store.ErrNotFound) { + return nil, err + } + if opts.Password == "" { + return nil, errors.New("password is required to create the instance administrator") + } + + user, _, _, err := r.auth.RegisterAdmin(ctx, auth.RegisterInput{ + Username: opts.Username, + DisplayName: opts.Username, + Email: email, + Password: opts.Password, + Locale: "ru", + UserAgent: "glchat-bootstrap", + }) + if err != nil { + return nil, fmt.Errorf("create instance administrator: %w", err) + } + result.UserID = user.ID + result.CreatedUser = true + // Бейдж «Администратор сервера» отображается рядом с ником (AGENT.md 7.19). + if err := r.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil { + r.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err)) + } + return user, nil +} + +// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и +// выдачу роли «Администратор» (AGENT.md 6.3). +func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error { + roles, err := r.store.ListGuildRoles(ctx, guild.ID) + if err != nil { + return err + } + var adminRole, defaultRole *store.Role + for i := range roles { + switch { + case permissions.Permission(roles[i].Permissions).Has(permissions.Administrator): + adminRole = &roles[i] + case roles[i].IsDefault: + defaultRole = &roles[i] + } + } + if adminRole == nil { + created, err := r.store.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Администратор", + Permissions: uint64(permissions.AllPermissions), Position: 100, Mentionable: true, + }) + if err != nil { + return fmt.Errorf("create administrator role: %w", err) + } + adminRole = created + } + result.AdminRoleID = adminRole.ID + if defaultRole == nil { + created, err := r.store.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guild.ID, Name: "Пользователь", + Permissions: uint64(permissions.DefaultUserPermissions), Position: 0, + IsDefault: true, Mentionable: true, + }) + if err != nil { + return fmt.Errorf("create default role: %w", err) + } + defaultRole = created + } + result.MemberRoleID = defaultRole.ID + + if _, err := r.store.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil { + return fmt.Errorf("add owner to main guild: %w", err) + } + if err := r.store.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil { + return fmt.Errorf("assign administrator role: %w", err) + } + return nil +} + +func sanitizeUsername(value string) string { + cleaned := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.': + return r + default: + return '_' + } + }, value) + if len(cleaned) < 2 { + cleaned = "admin" + } + if len(cleaned) > 32 { + cleaned = cleaned[:32] + } + return cleaned +} diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..86925d2 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,190 @@ +package bootstrap_test + +import ( + "context" + "log/slog" + "path/filepath" + "testing" + + "glchat/internal/auth" + "glchat/internal/bootstrap" + "glchat/internal/config" + "glchat/internal/database" + "glchat/internal/permissions" + "glchat/internal/store" +) + +func newRunner(t *testing.T) (*bootstrap.Runner, *store.Store, *auth.Service) { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, 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() { _ = db.Close() }) + + st := store.New(db) + logger := slog.New(slog.DiscardHandler) + cfg := config.Config{ + SessionPepper: "bootstrap-pepper", + MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + Argon2MemoryKiB: 1024, + Argon2Iterations: 1, + Argon2Parallelism: 1, + } + authService, err := auth.New(ctx, cfg, st, logger) + if err != nil { + t.Fatalf("auth.New: %v", err) + } + return bootstrap.New(st, authService, logger), st, authService +} + +func TestBootstrapCreatesAdminAndMainGuild(t *testing.T) { + ctx := context.Background() + runner, st, _ := newRunner(t) + + result, err := runner.Run(ctx, bootstrap.Options{ + Email: "owner@example.com", + Password: "correct-horse-battery", + MainGuild: "Главный сервер", + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if !result.CreatedUser || !result.CreatedGuild { + t.Fatalf("unexpected result: %+v", result) + } + + admin, err := st.GetUser(ctx, result.UserID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if !admin.IsInstanceAdmin { + t.Fatal("bootstrap admin must have the instance admin flag") + } + if len(admin.Badges) == 0 || admin.Badges[0] != "instance_admin" { + t.Fatalf("admin badge is missing: %+v", admin.Badges) + } + + settings, err := st.InstanceSettings(ctx) + if err != nil { + t.Fatalf("InstanceSettings: %v", err) + } + if settings.MainGuildID != result.GuildID { + t.Fatalf("main guild id = %d, want %d", settings.MainGuildID, result.GuildID) + } + + roles, err := st.ListGuildRoles(ctx, result.GuildID) + if err != nil { + t.Fatalf("ListGuildRoles: %v", err) + } + if len(roles) != 2 { + t.Fatalf("main guild must have 2 default roles, got %d", len(roles)) + } + var defaultRole *store.Role + for i := range roles { + if roles[i].IsDefault { + defaultRole = &roles[i] + } + } + if defaultRole == nil { + t.Fatal("default role is missing") + } + if permissions.Permission(defaultRole.Permissions) != permissions.DefaultUserPermissions { + t.Fatalf("default role permissions = %s", permissions.Permission(defaultRole.Permissions)) + } + + // Владелец — участник с ролью администратора. + memberRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID) + if err != nil { + t.Fatalf("MemberRoles: %v", err) + } + found := false + for _, role := range memberRoles { + if role.ID == result.AdminRoleID { + found = true + } + } + if !found { + t.Fatal("owner must receive the administrator role") + } + + // Общая текстовая комната создана. + channels, err := st.ListGuildChannels(ctx, result.GuildID) + if err != nil { + t.Fatalf("ListGuildChannels: %v", err) + } + if len(channels) != 1 || channels[0].Name != "общий" { + t.Fatalf("unexpected channels: %+v", channels) + } +} + +func TestBootstrapIsIdempotent(t *testing.T) { + ctx := context.Background() + runner, st, authService := newRunner(t) + options := bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"} + + first, err := runner.Run(ctx, options) + if err != nil { + t.Fatalf("first Run: %v", err) + } + second, err := runner.Run(ctx, options) + if err != nil { + t.Fatalf("second Run: %v", err) + } + if second.GuildID != first.GuildID { + t.Fatalf("second run created another guild: %d != %d", second.GuildID, first.GuildID) + } + if second.CreatedUser || second.CreatedGuild { + t.Fatalf("second run must not create anything: %+v", second) + } + guilds, err := st.ListAllGuilds(ctx) + if err != nil { + t.Fatalf("ListAllGuilds: %v", err) + } + if len(guilds) != 1 { + t.Fatalf("guild count = %d, want 1", len(guilds)) + } + + // Новый пользователь может войти с заданным паролем. + if _, _, _, err := authService.Login(ctx, auth.LoginInput{ + Email: "owner@example.com", Password: "correct-horse-battery", + }); err == nil { + t.Fatal("instance admin without 2FA must not be able to log in") + } +} + +func TestNewUserJoinsMainGuild(t *testing.T) { + ctx := context.Background() + runner, st, authService := newRunner(t) + result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + user, _, _, err := authService.Register(ctx, auth.RegisterInput{ + Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery", + }) + if err != nil { + t.Fatalf("Register: %v", err) + } + guilds, err := st.ListGuildsForUser(ctx, user.ID) + if err != nil { + t.Fatalf("ListGuildsForUser: %v", err) + } + if len(guilds) != 1 || guilds[0].ID != result.GuildID { + t.Fatalf("newcomer must join the main guild: %+v", guilds) + } + roles, err := st.MemberRoles(ctx, result.GuildID, user.ID) + if err != nil { + t.Fatalf("MemberRoles: %v", err) + } + if len(roles) != 1 || !roles[0].IsDefault { + t.Fatalf("newcomer must get the default role: %+v", roles) + } + if !permissions.Permission(roles[0].Permissions).Has(permissions.ViewGuild) { + t.Fatal("default role must allow viewing the guild") + } +}