feat(bootstrap): первичная настройка инстанса и CLI-команды
- 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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user