generated from VLADIMIR/template
updates email
This commit is contained in:
@@ -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")
|
||||
}
|
||||
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user