feat(ops): генератор нагрузки и подготовка аккаунтов для теста

- cmd/loadgen: держит заданное число WS-клиентов Gateway, шлёт сообщения с
  нужной частотой, измеряет задержку доставки (p50/p95/p99/max), ответы API,
  разрывы соединений и пишет отчёт в JSON;
- glchat create-user (CLI + обёртка): создаёт аккаунты в обход выключенной
  регистрации, пакетно (--count/--prefix), при --sessions сразу выдаёт сессии
  и складывает учётные данные в файл 600 — иначе сотня клиентов не сможет
  войти из-за лимита попыток по IP;
- auth: CreateUserByOperator и IssueSessionForOperator с тестом;
- web/e2e/voice-ten.spec.ts: десять участников в одной голосовой комнате,
  проверка плиток, входящего аудио и слоя 1080p60 у клиента; сессии берутся
  из файла (без формы входа);
- .gitignore: каталоги вывода Playwright test-results*.
This commit is contained in:
2026-09-22 20:00:33 +03:00
parent 785af0dc7b
commit 66dd07eb7b
8 changed files with 1026 additions and 2 deletions
+55
View File
@@ -213,6 +213,61 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User,
return user, token, session, nil
}
// CreateUserByOperator создаёт аккаунт по команде обслуживания (CLI
// create-user): регистрация на инстансе может быть выключена, а сессия
// создателю не нужна — он действует от имени администратора. Используется в
// том числе для подготовки нагрузки (AGENT.md 11.4).
func (s *Service) CreateUserByOperator(ctx context.Context, in RegisterInput) (*store.User, error) {
username := strings.TrimSpace(in.Username)
if err := crypto.ValidateUsername(username); err != nil {
return nil, ErrInvalidUsername
}
if err := s.policy.validate(in.Password); err != nil {
return nil, ErrWeakPassword
}
email := crypto.NormalizeEmail(in.Email)
if err := validateEmail(email); err != nil {
return nil, err
}
passwordHash, err := s.hasher.Hash(in.Password)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
userID := s.store.NextID()
emailEncrypted, err := s.encryptEmail(userID, email)
if err != nil {
return nil, err
}
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
ID: userID,
Username: username,
DisplayName: defaultDisplayName(in.DisplayName, username),
EmailEnc: emailEncrypted,
EmailIndex: s.masterKey.BlindIndex(email),
PasswordHash: passwordHash,
Locale: in.Locale,
})
if err != nil {
if errors.Is(err, store.ErrConflict) {
if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil {
return nil, ErrUsernameTaken
}
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "created_by_operator", "", "", "")
return user, nil
}
// IssueSessionForOperator выдаёт сессию по команде обслуживания: нужна для
// нагрузочного теста (AGENT.md 11.4), где сотни клиентов не могут пройти вход
// через /auth/login — лимит попыток по IP рассчитан на защиту от перебора.
func (s *Service) IssueSessionForOperator(ctx context.Context, userID uint64, userAgent string) (string, error) {
token, _, err := s.createSession(ctx, userID, userAgent, "")
return token, err
}
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
return s.userByEmail(ctx, email)
+45
View File
@@ -459,3 +459,48 @@ func totpCode(t *testing.T, secret string) string {
}
return code
}
// TestCreateUserByOperator проверяет команду обслуживания: аккаунт создаётся
// даже при выключенной регистрации, дубликаты и слабые пароли отклоняются
// (AGENT.md 10.7, 11.4 — подготовка нагрузки).
func TestCreateUserByOperator(t *testing.T) {
ctx := context.Background()
service, st := newService(t)
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
t.Fatalf("SetInstanceSetting: %v", err)
}
// Публичная регистрация закрыта.
if _, _, _, err := service.Register(ctx, auth.RegisterInput{
Username: "operator_blocked", Email: "blocked@example.com", Password: "correct-horse-battery",
}); !errors.Is(err, auth.ErrRegistrationOff) {
t.Fatalf("Register with registration off = %v, want ErrRegistrationOff", err)
}
user, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest1", Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
})
if err != nil {
t.Fatalf("CreateUserByOperator: %v", err)
}
if user.Username != "loadtest1" || user.IsInstanceAdmin {
t.Fatalf("created user = %+v", user)
}
// Вход новым аккаунтом работает без выдачи сессии создателю.
if _, _, _, err := service.Login(ctx, auth.LoginInput{
Email: "loadtest1@loadtest.local", Password: "correct-horse-battery",
}); err != nil {
t.Fatalf("Login as created user: %v", err)
}
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest1", Email: "other@loadtest.local", Password: "correct-horse-battery",
}); !errors.Is(err, auth.ErrUsernameTaken) {
t.Fatalf("duplicate username = %v, want ErrUsernameTaken", err)
}
if _, err := service.CreateUserByOperator(ctx, auth.RegisterInput{
Username: "loadtest2", Email: "loadtest2@loadtest.local", Password: "short",
}); !errors.Is(err, auth.ErrWeakPassword) {
t.Fatalf("weak password = %v, want ErrWeakPassword", err)
}
}