diff --git a/.env.example b/.env.example index ca71e1d..f33f3d1 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ SMTP_HOST=mail.crabs-games.art SMTP_PORT=465 SMTP_USER=evening_detective@crabs-games.art SMTP_PASSWORD=your_password +# Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s +SMTP_TIMEOUT=10s JWT_SECRET=your_secret diff --git a/.gitignore b/.gitignore index 60d0dc4..a87fcb4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ go.sum .env +# Локальные Go-кэши (используются, когда системный кэш недоступен) +.gocache/ +.gotmp/ + .VSCodeCounter/ docker-compose-prod.yml \ No newline at end of file diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 12a8aaa..320d09c 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index d2b1c16..bed5cb5 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -28,6 +28,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/jackc/pgx/v5/pgxpool" @@ -58,12 +59,19 @@ func main() { } usersRepo := users_repo.NewUserRepo(dbpool) - passwordGenerator := password_generator.NewGenerator(8) + passwordGenerator := password_generator.NewGenerator(12) + smtpTimeout := 10 * time.Second + if v := os.Getenv("SMTP_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + smtpTimeout = d + } + } emailSender := email_sender.NewSender( os.Getenv("SMTP_HOST"), os.Getenv("SMTP_PORT"), os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASSWORD"), + smtpTimeout, ) processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET")) refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool) diff --git a/internal/modules/email_sender/sender.go b/internal/modules/email_sender/sender.go index 62cf494..96c9dd3 100644 --- a/internal/modules/email_sender/sender.go +++ b/internal/modules/email_sender/sender.go @@ -3,8 +3,14 @@ package email_sender import ( "context" "crypto/tls" + "errors" "fmt" + "mime" + "net" + "net/mail" "net/smtp" + "strings" + "time" ) type sender struct { @@ -12,6 +18,9 @@ type sender struct { smtpPort string smtpUser string smtpPassword string + from string + timeout time.Duration + tlsConfig *tls.Config } func NewSender( @@ -19,76 +28,131 @@ func NewSender( smtpPort string, smtpUser string, smtpPassword string, + timeout time.Duration, ) IEmailSender { + // From заголовка письма по умолчанию совпадает с учётной записью SMTP. + from := smtpUser + if timeout <= 0 { + timeout = 10 * time.Second + } return &sender{ smtpHost: smtpHost, smtpPort: smtpPort, smtpUser: smtpUser, smtpPassword: smtpPassword, + from: from, + timeout: timeout, + // Проверка имени сервера включена всегда; поле переопределяется + // только в тестах (свой RootCAs для самоподписанного сертификата). + tlsConfig: &tls.Config{ServerName: smtpHost}, } } -func (s *sender) Send(_ context.Context, message Message) error { - emailMessage := []byte(fmt.Sprintf( - "To: %s\r\n"+ - "Subject: %s\r\n"+ - "MIME-Version: 1.0\r\n"+ - "Content-Type: text/plain; charset=utf-8\r\n"+ - "\r\n"+ - "%s\r\n", - message.To, - message.Subject, - message.Body, - )) - - // Настраиваем TLS - tlsConfig := &tls.Config{ - ServerName: s.smtpHost, - } - - // Подключаемся к серверу - conn, err := tls.Dial("tcp", s.smtpHost+":"+s.smtpPort, tlsConfig) - if err != nil { +// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и +// таймаута: соединение устанавливается через DialContext, а все фазы SMTP- +// диалога ограничены общим deadline, производным от ctx. +func (s *sender) Send(ctx context.Context, message Message) error { + // Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают + // формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.). + to := sanitizeHeader(message.To) + subject := sanitizeHeader(message.Subject) + if err := validateMessage(to, subject); err != nil { return err } - defer conn.Close() + body := buildMessage(s.from, to, subject, message.Body) + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + + addr := net.JoinHostPort(s.smtpHost, s.smtpPort) + rawConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("email: smtp dial %s: %w", addr, err) + } + defer rawConn.Close() + + conn := tls.Client(rawConn, s.tlsConfig) + if deadline, ok := ctx.Deadline(); ok { + // Единый бюджет времени на весь SMTP-диалог: зависший сервер не + // должен держать запрос дольше таймаута. + if err = conn.SetDeadline(deadline); err != nil { + return fmt.Errorf("email: set deadline: %w", err) + } + } - // Создаем SMTP клиент client, err := smtp.NewClient(conn, s.smtpHost) if err != nil { - return err + return fmt.Errorf("email: smtp greeting: %w", err) } - defer client.Quit() + // Close гарантированно закрывает соединение при раннем выходе; в конце + // штатного пути вместо него вызывается Quit. + defer client.Close() //nolint:errcheck - // Аутентификация auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost) if err = client.Auth(auth); err != nil { - return err + return fmt.Errorf("email: smtp auth: %w", err) + } + if err = client.Mail(s.from); err != nil { + return fmt.Errorf("email: smtp mail from: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("email: smtp rcpt to: %w", err) } - // Указываем отправителя - if err = client.Mail(s.smtpUser); err != nil { - return err - } - - // Указываем получателя - if err = client.Rcpt(message.To); err != nil { - return err - } - - // Отправляем данные письма w, err := client.Data() if err != nil { - return err + return fmt.Errorf("email: smtp data: %w", err) } - _, err = w.Write(emailMessage) - if err != nil { - return err + if _, err = w.Write(body); err != nil { + return fmt.Errorf("email: smtp write: %w", err) } - err = w.Close() - if err != nil { - return err + if err = w.Close(); err != nil { + return fmt.Errorf("email: smtp data close: %w", err) + } + if err = client.Quit(); err != nil { + return fmt.Errorf("email: smtp quit: %w", err) + } + return nil +} + +// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст. +// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица +// иначе может быть испорчена промежуточными серверами). +func buildMessage(from, to, subject, body string) []byte { + var b strings.Builder + b.Grow(len(from) + len(to) + len(subject) + len(body) + 128) + b.WriteString("From: ") + b.WriteString(from) + b.WriteString("\r\n") + b.WriteString("To: ") + b.WriteString(to) + b.WriteString("\r\n") + b.WriteString("Subject: ") + b.WriteString(mime.QEncoding.Encode("utf-8", subject)) + b.WriteString("\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("\r\n") + b.WriteString(body) + b.WriteString("\r\n") + return []byte(b.String()) +} + +// sanitizeHeader удаляет символы, ломающие структуру заголовков письма +// (CRLF-инъекция заголовков, NUL). +func sanitizeHeader(s string) string { + r := strings.NewReplacer("\r", "", "\n", "", "\x00", "") + return r.Replace(s) +} + +// validateMessage проверяет адрес получателя и наличие темы до обращения к +// SMTP-серверу. +func validateMessage(to, subject string) error { + if _, err := mail.ParseAddress(to); err != nil { + return fmt.Errorf("email: некорректный адрес получателя: %w", err) + } + if subject == "" { + return errors.New("email: пустая тема письма") } - return nil } diff --git a/internal/modules/email_sender/sender_test.go b/internal/modules/email_sender/sender_test.go new file mode 100644 index 0000000..d58a167 --- /dev/null +++ b/internal/modules/email_sender/sender_test.go @@ -0,0 +1,289 @@ +package email_sender + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "strings" + "testing" + "time" +) + +// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на +// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми +// телами писем. При hang=true сервер отправляет приветствие и молчит — +// для проверки таймаутов. Сертификат генерируется один на тест и +// передаётся и серверу, и клиенту (testSender). +func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) { + t.Helper() + + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{cert}, + }) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + messages = make(chan []byte, 8) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + if hang { + go func() { + // Приветствие отправляем, дальше молчим. + _, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n")) + }() + continue + } + go handleSMTPConn(conn, messages) + } + }() + + return ln.Addr().String(), messages +} + +// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp: +// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT. +func handleSMTPConn(conn net.Conn, messages chan<- []byte) { + defer conn.Close() + + r := bufio.NewReader(conn) + write := func(s string) { _, _ = conn.Write([]byte(s)) } + + write("220 test.example.com ESMTP\r\n") + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + cmd := strings.ToUpper(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(cmd, "EHLO"): + write("250-test.example.com\r\n250-AUTH PLAIN\r\n250 OK\r\n") + case strings.HasPrefix(cmd, "AUTH"): + write("235 2.7.0 Authentication successful\r\n") + case strings.HasPrefix(cmd, "MAIL"): + write("250 OK\r\n") + case strings.HasPrefix(cmd, "RCPT"): + write("250 OK\r\n") + case strings.HasPrefix(cmd, "DATA"): + write("354 End data with .\r\n") + var body []byte + for { + b, err := r.ReadBytes('\n') + if err != nil { + return + } + body = append(body, b...) + if string(b) == ".\r\n" { + break + } + } + select { + case messages <- body: + default: + } + write("250 OK\r\n") + case strings.HasPrefix(cmd, "QUIT"): + write("221 Bye\r\n") + return + } + } +} + +// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и +// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы +// тест мог добавить его в корни доверия клиента. +func testTLSCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse certificate: %v", err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf} +} + +// testSender собирает sender, доверяющий самоподписанному сертификату +// тестового SMTP-сервера (в проде NewSender использует системные корни). +func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender { + t.Helper() + host, port := splitHostPort(t, addr) + + pool := x509.NewCertPool() + pool.AddCert(cert.Leaf) + + return &sender{ + smtpHost: host, + smtpPort: port, + smtpUser: "sender@example.com", + smtpPassword: "secret", + from: "sender@example.com", + timeout: timeout, + tlsConfig: &tls.Config{ServerName: host, RootCAs: pool}, + } +} + +func splitHostPort(t *testing.T, addr string) (host, port string) { + t.Helper() + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host port: %v", err) + } + return host, port +} + +func TestSendSuccess(t *testing.T) { + cert := testTLSCert(t) + addr, messages := startSMTPTestServer(t, false, cert) + s := testSender(t, addr, 5*time.Second, cert) + + err := s.Send(context.Background(), Message{ + To: "user@example.com", + Subject: "Привет, детектив!", + Body: "Текст письма", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case data := <-messages: + raw := string(data) + for _, want := range []string{ + "From: sender@example.com", + "To: user@example.com", + "Subject: =?utf-8?q?", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "\r\n\r\n", + "Текст письма", + } { + if !strings.Contains(raw, want) { + t.Errorf("письмо не содержит %q:\n%s", want, raw) + } + } + case <-time.After(2 * time.Second): + t.Fatal("письмо не получено сервером") + } +} + +func TestSendRejectsCRLFInjection(t *testing.T) { + cert := testTLSCert(t) + addr, messages := startSMTPTestServer(t, false, cert) + s := testSender(t, addr, 5*time.Second, cert) + + // CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо + // должно быть отклонено до обращения к SMTP-серверу. + err := s.Send(context.Background(), Message{ + To: "user@example.com\r\nBcc: victim@example.com", + Subject: "Test", + Body: "body", + }) + if err == nil { + t.Fatal("ожидалась ошибка для адреса с CRLF") + } + select { + case <-messages: + t.Fatal("письмо не должно было уйти на сервер") + default: + } +} + +func TestSendContextCancelled(t *testing.T) { + cert := testTLSCert(t) + addr, _ := startSMTPTestServer(t, false, cert) + s := testSender(t, addr, 10*time.Second, cert) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + err := s.Send(ctx, Message{To: "user@example.com", Subject: "Test", Body: "body"}) + if err == nil { + t.Fatal("ожидалась ошибка при отменённом контексте") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("отмена контекста должна прерывать отправку быстро, заняло %v", elapsed) + } +} + +func TestSendTimeout(t *testing.T) { + cert := testTLSCert(t) + addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия + s := testSender(t, addr, 500*time.Millisecond, cert) + + start := time.Now() + err := s.Send(context.Background(), Message{To: "user@example.com", Subject: "Test", Body: "body"}) + if err == nil { + t.Fatal("ожидалась ошибка таймаута") + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("таймаут должен сработать быстро, заняло %v", elapsed) + } +} + +func TestBuildMessage(t *testing.T) { + raw := string(buildMessage("sender@example.com", "user@example.com", "Привет", "текст")) + for _, want := range []string{ + "From: sender@example.com", + "To: user@example.com", + "Subject: =?utf-8?q?", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "\r\n\r\n", + "текст", + } { + if !strings.Contains(raw, want) { + t.Errorf("письмо не содержит %q:\n%s", want, raw) + } + } +} + +func TestSanitizeHeader(t *testing.T) { + got := sanitizeHeader("a@b.c\r\nBcc: x@y.z\x00") + want := "a@b.cBcc: x@y.z" + if got != want { + t.Errorf("sanitizeHeader = %q, want %q", got, want) + } +} + +func TestValidateMessage(t *testing.T) { + if err := validateMessage("user@example.com", "тема"); err != nil { + t.Errorf("валидное письмо отклонено: %v", err) + } + if err := validateMessage("not-an-email", "тема"); err == nil { + t.Error("невалидный адрес принят") + } + if err := validateMessage("user@example.com", ""); err == nil { + t.Error("пустая тема принята") + } +} diff --git a/internal/services/users_service/service.go b/internal/services/users_service/service.go index cfb2a32..0c87610 100644 --- a/internal/services/users_service/service.go +++ b/internal/services/users_service/service.go @@ -12,7 +12,10 @@ import ( "evening_detective_server/internal/repos/user_agreements_repo" "evening_detective_server/internal/repos/users_repo" "fmt" + "log" + "net/mail" "slices" + "strings" "time" "golang.org/x/crypto/bcrypt" @@ -29,8 +32,28 @@ const ( privacyVersion = "1.0" ) +// usersRepository — граница доступа к данным пользователей, реализуемая +// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД. +type usersRepository interface { + AddUserWithAgreements( + ctx context.Context, + username string, + email string, + passwordHash string, + roles []string, + agreements []user_agreements_repo.Agreement, + ) (int, error) + UpdateUserPassword(ctx context.Context, email string, passwordHash string) error + GetUserByEmail(ctx context.Context, email string) (*repos.User, error) + GetUserByID(ctx context.Context, id int) (*repos.User, error) + GetUsers(ctx context.Context) ([]*repos.User, error) + AddUserRole(ctx context.Context, userId int, role string) error + DeleteUserRole(ctx context.Context, userId int, role string) error + DeleteUser(ctx context.Context, userId int) error +} + type UsersService struct { - usersRepo *users_repo.UsersRepo + usersRepo usersRepository passwordGenerator password_generator.IPasswordGenerator emailSender email_sender.IEmailSender processorJWT processor_jwt.IProcessorJWT @@ -38,7 +61,7 @@ type UsersService struct { } func NewUsersService( - usersRepo *users_repo.UsersRepo, + usersRepo usersRepository, passwordGenerator password_generator.IPasswordGenerator, emailSender email_sender.IEmailSender, processorJWT processor_jwt.IProcessorJWT, @@ -69,6 +92,11 @@ func (s *UsersService) AddUser( return ErrTermsNotAccepted } + email = normalizeEmail(email) + if err := validateEmail(email); err != nil { + return err + } + password, err := s.passwordGenerator.Generate() if err != nil { return err @@ -79,7 +107,7 @@ func (s *UsersService) AddUser( } // Создание пользователя и фиксация акцептов — в одной транзакции: // без записей о согласиях пользователь не существует. - _, err = s.usersRepo.AddUserWithAgreements( + id, err := s.usersRepo.AddUserWithAgreements( ctx, username, email, @@ -104,13 +132,25 @@ func (s *UsersService) AddUser( return err } + // Консистентность ответа и состояния БД: если письмо с паролем не + // ушло, пользователь не должен оставаться в системе «полусозданным» + // (клиент получит ошибку и повторит регистрацию). Компенсация — + // удаление созданного пользователя. Выполняется вне отменённого + // контекста: письмо могло упасть именно из-за истечения таймаута ctx, + // и тогда тот же ctx не дал бы выполнить удаление. err = s.emailSender.Send(ctx, email_sender.Message{ To: email, Subject: "Приветствую тебя, детектив!", Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password), }) + if err != nil { + if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil { + log.Printf("email: отправка не удалась (%v) и компенсация не выполнена: %v", err, delErr) + } + return fmt.Errorf("email send: %w", err) + } - return err + return nil } // DeleteAccount удаляет учётную запись и все персональные данные пользователя @@ -135,6 +175,21 @@ func (s *UsersService) RefreshPassword( ctx context.Context, email string, ) error { + email = normalizeEmail(email) + if err := validateEmail(email); err != nil { + return err + } + + // Анти-enumeration: ответ одинаков для существующего и несуществующего + // адреса, письмо отправляется только реальному пользователю. + user, err := s.usersRepo.GetUserByEmail(ctx, email) + if err != nil { + if errors.Is(err, users_repo.ErrUserNotFound) { + return nil + } + return err + } + password, err := s.passwordGenerator.Generate() if err != nil { return err @@ -144,22 +199,41 @@ func (s *UsersService) RefreshPassword( return err } - err = s.usersRepo.UpdateUserPassword( - ctx, - email, - string(hashedPassword), - ) - if err != nil { - return err - } - + // Письмо отправляется ДО смены пароля: при сбое отправки пароль + // остаётся прежним и аккаунт не блокируется (иначе старый пароль уже + // не работает, а новый пользователь так и не узнал). err = s.emailSender.Send(ctx, email_sender.Message{ - To: email, - Subject: "Приветствую тебя, детектив!", + To: user.Email, + Subject: "Сброс пароля", Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password), }) + if err != nil { + return fmt.Errorf("email send: %w", err) + } - return err + return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword)) +} + +// normalizeEmail приводит адрес к каноническому виду (без пробелов, нижний +// регистр): "User@Example.COM " и "user@example.com" — один аккаунт. +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// validateEmail проверяет, что строка является одиночным email-адресом. +func validateEmail(email string) error { + if email == "" { + return errors.New("Email не указан") + } + addr, err := mail.ParseAddress(email) + if err != nil { + return errors.New("Некорректный email") + } + // Отклоняем форму "Имя " и всё, что не является чистым адресом. + if !strings.EqualFold(addr.Address, email) { + return errors.New("Некорректный email") + } + return nil } func (s *UsersService) Login( @@ -167,9 +241,11 @@ func (s *UsersService) Login( email string, password string, ) (string, string, error) { + // Email нормализуется так же, как при регистрации: иначе пользователь, + // зарегистрировавшийся как user@example.com, не войдёт с User@Example.COM. user, err := s.usersRepo.GetUserByEmail( ctx, - email, + normalizeEmail(email), ) if err != nil { return "", "", err diff --git a/internal/services/users_service/service_test.go b/internal/services/users_service/service_test.go new file mode 100644 index 0000000..10c367a --- /dev/null +++ b/internal/services/users_service/service_test.go @@ -0,0 +1,295 @@ +package users_service + +import ( + "context" + "errors" + "evening_detective_server/internal/modules/email_sender" + "evening_detective_server/internal/modules/password_generator" + "evening_detective_server/internal/modules/processor_jwt" + "evening_detective_server/internal/repos" + "evening_detective_server/internal/repos/refresh_tokens_repo" + "evening_detective_server/internal/repos/user_agreements_repo" + "evening_detective_server/internal/repos/users_repo" + "strings" + "sync" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type fakeSender struct { + mu sync.Mutex + messages []email_sender.Message + err error +} + +func (f *fakeSender) Send(_ context.Context, m email_sender.Message) error { + f.mu.Lock() + defer f.mu.Unlock() + f.messages = append(f.messages, m) + return f.err +} + +func (f *fakeSender) sent() []email_sender.Message { + f.mu.Lock() + defer f.mu.Unlock() + return append([]email_sender.Message(nil), f.messages...) +} + +// fakeRepo — минимальная in-memory реализация usersRepository для тестов +// сервиса без БД. +type fakeRepo struct { + mu sync.Mutex + users map[string]*repos.User + byID map[int]*repos.User + nextID int +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{ + users: map[string]*repos.User{}, + byID: map[int]*repos.User{}, + nextID: 1, + } +} + +func (r *fakeRepo) AddUserWithAgreements( + _ context.Context, + username, email, passwordHash string, + roles []string, + _ []user_agreements_repo.Agreement, +) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.users[email]; ok { + return 0, errors.New("Username или email уже используется") + } + id := r.nextID + r.nextID++ + u := &repos.User{ID: id, Username: username, Email: email, PasswordHash: passwordHash, Roles: roles} + r.users[email] = u + r.byID[id] = u + return id, nil +} + +func (r *fakeRepo) UpdateUserPassword(_ context.Context, email, passwordHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.users[email] + if !ok { + return users_repo.ErrUserNotFound + } + u.PasswordHash = passwordHash + return nil +} + +func (r *fakeRepo) GetUserByEmail(_ context.Context, email string) (*repos.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.users[email] + if !ok { + return nil, users_repo.ErrUserNotFound + } + return u, nil +} + +func (r *fakeRepo) GetUserByID(_ context.Context, id int) (*repos.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[id] + if !ok { + return nil, users_repo.ErrUserNotFound + } + return u, nil +} + +func (r *fakeRepo) GetUsers(_ context.Context) ([]*repos.User, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*repos.User, 0, len(r.users)) + for _, u := range r.users { + out = append(out, u) + } + return out, nil +} + +func (r *fakeRepo) AddUserRole(_ context.Context, _ int, _ string) error { return nil } + +func (r *fakeRepo) DeleteUserRole(_ context.Context, _ int, _ string) error { return nil } + +func (r *fakeRepo) DeleteUser(_ context.Context, userId int) error { + r.mu.Lock() + defer r.mu.Unlock() + u, ok := r.byID[userId] + if !ok { + return users_repo.ErrUserNotFound + } + delete(r.byID, userId) + delete(r.users, u.Email) + return nil +} + +func (r *fakeRepo) userCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.users) +} + +func (r *fakeRepo) seed(email, passwordHash string) { + r.mu.Lock() + defer r.mu.Unlock() + u := &repos.User{ID: r.nextID, Email: email, PasswordHash: passwordHash} + r.nextID++ + r.users[email] = u + r.byID[u.ID] = u +} + +func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService { + // Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих + // тестах не используется. + pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none") + if err != nil { + panic(err) + } + return NewUsersService( + repo, + password_generator.NewGenerator(12), + sender, + processor_jwt.NewProcessor("test-secret"), + refresh_tokens_repo.NewRefreshTokensRepo(pool), + ) +} + +func TestAddUserSuccess(t *testing.T) { + repo := newFakeRepo() + sender := &fakeSender{} + svc := newTestService(repo, sender) + + err := svc.AddUser(context.Background(), "detective", " User@Example.COM ", true, true, "1.2.3.4", "ua") + if err != nil { + t.Fatalf("AddUser: %v", err) + } + + // Email нормализован: пробелы убраны, регистр приведён. + if _, ok := repo.users["user@example.com"]; !ok { + t.Errorf("пользователь не создан с нормализованным email: %+v", repo.users) + } + + msgs := sender.sent() + if len(msgs) != 1 { + t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs)) + } + if msgs[0].To != "user@example.com" { + t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To) + } + if !strings.Contains(msgs[0].Body, "пароль") { + t.Error("в письме не упоминается пароль") + } +} + +func TestAddUserSendFailureCompensates(t *testing.T) { + repo := newFakeRepo() + sender := &fakeSender{err: errors.New("smtp down")} + svc := newTestService(repo, sender) + + err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "") + if err == nil { + t.Fatal("ожидалась ошибка при сбое отправки письма") + } + + // Компенсация: клиенту ошибка, пользователя в БД нет. + if repo.userCount() != 0 { + t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users) + } +} + +func TestAddUserInvalidEmail(t *testing.T) { + repo := newFakeRepo() + svc := newTestService(repo, &fakeSender{}) + + err := svc.AddUser(context.Background(), "detective", "not-an-email", true, true, "", "") + if err == nil { + t.Fatal("ожидалась ошибка валидации email") + } + if repo.userCount() != 0 { + t.Error("пользователь не должен создаваться при невалидном email") + } +} + +func TestAddUserRequiresTerms(t *testing.T) { + repo := newFakeRepo() + svc := newTestService(repo, &fakeSender{}) + + err := svc.AddUser(context.Background(), "detective", "user@example.com", false, true, "", "") + if !errors.Is(err, ErrTermsNotAccepted) { + t.Fatalf("ожидался ErrTermsNotAccepted, получено %v", err) + } + if repo.userCount() != 0 { + t.Error("пользователь не должен создаваться без акцепта") + } +} + +func TestRefreshPasswordSuccess(t *testing.T) { + repo := newFakeRepo() + repo.seed("user@example.com", "old-hash") + sender := &fakeSender{} + svc := newTestService(repo, sender) + + // Ввод с пробелами и другим регистром — нормализуется. + err := svc.RefreshPassword(context.Background(), " User@Example.COM ") + if err != nil { + t.Fatalf("RefreshPassword: %v", err) + } + + if len(sender.sent()) != 1 { + t.Fatalf("ожидалось 1 письмо, отправлено %d", len(sender.sent())) + } + u, _ := repo.GetUserByEmail(context.Background(), "user@example.com") + if u.PasswordHash == "old-hash" { + t.Error("пароль не изменён") + } +} + +func TestRefreshPasswordSendFailureKeepsPassword(t *testing.T) { + repo := newFakeRepo() + repo.seed("user@example.com", "old-hash") + sender := &fakeSender{err: errors.New("smtp down")} + svc := newTestService(repo, sender) + + err := svc.RefreshPassword(context.Background(), "user@example.com") + if err == nil { + t.Fatal("ожидалась ошибка при сбое отправки") + } + + u, _ := repo.GetUserByEmail(context.Background(), "user@example.com") + if u.PasswordHash != "old-hash" { + t.Error("пароль изменён, хотя письмо не ушло — аккаунт заблокирован") + } +} + +func TestRefreshPasswordUnknownEmail(t *testing.T) { + repo := newFakeRepo() + sender := &fakeSender{} + svc := newTestService(repo, sender) + + // Анти-enumeration: для несуществующего адреса — успех и без письма. + if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil { + t.Fatalf("для несуществующего email ожидался успех, получено %v", err) + } + if len(sender.sent()) != 0 { + t.Error("письмо не должно уходить несуществующему пользователю") + } +} + +func TestRefreshPasswordInvalidEmail(t *testing.T) { + repo := newFakeRepo() + sender := &fakeSender{} + svc := newTestService(repo, sender) + + if err := svc.RefreshPassword(context.Background(), "not-an-email"); err == nil { + t.Fatal("ожидалась ошибка валидации email") + } + if len(sender.sent()) != 0 { + t.Error("письмо не должно уходить при невалидном email") + } +}