generated from VLADIMIR/template
fix
This commit is contained in:
@@ -48,8 +48,6 @@ func TestChangeUserPassword(t *testing.T) {
|
||||
r.byID[u.ID] = u
|
||||
r.users[u.Email] = u
|
||||
}
|
||||
// Пустые JWT, репозиторий токенов и отправитель писем подтверждают,
|
||||
// что операция их не использует.
|
||||
s := &UsersService{usersRepo: r}
|
||||
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
|
||||
if !errors.Is(err, tc.want) {
|
||||
@@ -91,7 +89,6 @@ func TestLoginAfterPasswordChangePreservesEmailCase(t *testing.T) {
|
||||
if err := svc.ChangeUserPassword(ctx, 1, 2, "new password 123"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Эта ошибка возможна только после успешного поиска email и проверки bcrypt.
|
||||
_, _, err := svc.Login(ctx, " User@Example.COM ", "new password 123")
|
||||
if err == nil || err.Error() != "Не получилось получить доступ" {
|
||||
t.Fatalf("lookup/password verification failed: %v", err)
|
||||
|
||||
@@ -6,17 +6,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Письма содержат текстовую и HTML-версию; sender собирает multipart/alternative.
|
||||
|
||||
const (
|
||||
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
|
||||
// кликбейта, до 50 символов (видно на мобильных целиком).
|
||||
welcomeSubject = "Твой доступ к «Вечернему детективу»"
|
||||
// resetSubject — тема письма о сбросе пароля.
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
resetSubject = "Новый пароль для «Вечернего детектива»"
|
||||
)
|
||||
|
||||
// welcomePlain возвращает текстовую версию письма о регистрации.
|
||||
func welcomePlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -39,7 +33,6 @@ func welcomePlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// welcomeHTML возвращает HTML-версию письма о регистрации.
|
||||
func welcomeHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -54,7 +47,6 @@ func welcomeHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// resetPlain возвращает текстовую версию письма о сбросе пароля.
|
||||
func resetPlain(username, email, password, loginURL string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(greeting(username))
|
||||
@@ -77,7 +69,6 @@ func resetPlain(username, email, password, loginURL string) string {
|
||||
return sanitizeLine(b.String())
|
||||
}
|
||||
|
||||
// resetHTML возвращает HTML-версию письма о сбросе пароля.
|
||||
func resetHTML(username, email, password, loginURL string) string {
|
||||
var content strings.Builder
|
||||
content.WriteString("<p class=\"email-text\" style=\"color:#211a14;\">Привет, <strong>")
|
||||
@@ -93,8 +84,6 @@ func resetHTML(username, email, password, loginURL string) string {
|
||||
return wrapHTML(content.String(), loginURL)
|
||||
}
|
||||
|
||||
// greeting возвращает приветствие с именем пользователя; при пустом имени —
|
||||
// без обращения, чтобы не было «Привет, !».
|
||||
func greeting(username string) string {
|
||||
username = sanitizeLine(strings.TrimSpace(username))
|
||||
if username == "" {
|
||||
@@ -103,8 +92,6 @@ func greeting(username string) string {
|
||||
return fmt.Sprintf("Привет, %s!", username)
|
||||
}
|
||||
|
||||
// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке,
|
||||
// совместимой с Gmail.
|
||||
func credentialsBlockHTML(email, password string) string {
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0; border-top:1px dashed #888888; border-bottom:1px dashed #888888; table-layout:fixed;">
|
||||
<tr><td class="email-text" style="padding:16px 0 4px; font-family:Arial,Helvetica,sans-serif; font-size:12px; color:#211a14;">Логин</td></tr>
|
||||
@@ -114,7 +101,6 @@ func credentialsBlockHTML(email, password string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// buttonHTML — кнопка входа с inline-стилями и табличным фоном.
|
||||
func buttonHTML(link, label string) string {
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
|
||||
<tr>
|
||||
@@ -125,8 +111,6 @@ func buttonHTML(link, label string) string {
|
||||
</table>`
|
||||
}
|
||||
|
||||
// wrapHTML — светлая палитра по умолчанию, тёмная через media query.
|
||||
// Клиенты без поддержки CSS темы могут перекрашивать обычные цвета сами.
|
||||
func wrapHTML(content, baseURL string) string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -181,7 +165,6 @@ func wrapHTML(content, baseURL string) string {
|
||||
</html>`
|
||||
}
|
||||
|
||||
// Шрифт находится в public/email фронтенда.
|
||||
func emailAssetURL(baseURL, filename string) string {
|
||||
if baseURL == "" {
|
||||
return ""
|
||||
@@ -189,15 +172,10 @@ func emailAssetURL(baseURL, filename string) string {
|
||||
return html.EscapeString(strings.TrimRight(baseURL, "/") + "/email/" + filename)
|
||||
}
|
||||
|
||||
// sanitizeLine убирает переносы строк из пользовательского ввода,
|
||||
// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP).
|
||||
func sanitizeLine(s string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
// loginURL нормализует адрес фронтенда для ссылки в письме: срезает
|
||||
// завершающий слэш и допускает только http(s). Пустое или некорректное
|
||||
// значение — ссылка в письма не добавляется.
|
||||
func loginURL(baseURL string) string {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestEmailThemeAndContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
protected = true // У кнопки собственные согласованные цвета фона и текста.
|
||||
protected = true
|
||||
}
|
||||
if paper && n.Type == html.TextNode && strings.TrimSpace(n.Data) != "" {
|
||||
if !protected {
|
||||
|
||||
@@ -22,19 +22,12 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Версии документов, акцепт которых фиксируется. Должны совпадать с
|
||||
// редакциями, опубликованными по постоянным URL (/api/terms и /api/privacy,
|
||||
// rpc GetTerms/GetPrivacy, текст встраивается из пакета docs): при изменении
|
||||
// текста документа версия ОБЯЗАНА увеличиваться, иначе повторный акцепт
|
||||
// не будет зафиксирован как согласие с новой редакцией
|
||||
// (UNIQUE (user_id, agreement_type, version)).
|
||||
const (
|
||||
termsVersion = "1.0"
|
||||
privacyVersion = "1.0"
|
||||
)
|
||||
|
||||
// usersRepository — граница доступа к данным пользователей, реализуемая
|
||||
// *users_repo.UsersRepo; интерфейс позволяет тестировать сервис без БД.
|
||||
type usersRepository interface {
|
||||
AddUserWithAgreements(
|
||||
ctx context.Context,
|
||||
@@ -60,9 +53,7 @@ type UsersService struct {
|
||||
emailSender email_sender.IEmailSender
|
||||
processorJWT processor_jwt.IProcessorJWT
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
|
||||
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
|
||||
// письмах; пустое значение — ссылка не добавляется.
|
||||
appBaseURL string
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
// NewUsersService creates a users service.
|
||||
@@ -85,7 +76,6 @@ func NewUsersService(
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
|
||||
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
|
||||
// ErrInvalidEmail indicates that an email address is invalid.
|
||||
ErrInvalidEmail = errors.New("Некорректный email")
|
||||
@@ -126,8 +116,6 @@ func (s *UsersService) AddUser(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Создание пользователя и фиксация акцептов — в одной транзакции:
|
||||
// без записей о согласиях пользователь не существует.
|
||||
id, err := s.usersRepo.AddUserWithAgreements(
|
||||
ctx,
|
||||
username,
|
||||
@@ -153,12 +141,6 @@ func (s *UsersService) AddUser(
|
||||
return err
|
||||
}
|
||||
|
||||
// Консистентность ответа и состояния БД: если письмо с паролем не
|
||||
// ушло, пользователь не должен оставаться в системе «полусозданным»
|
||||
// (клиент получит ошибку и повторит регистрацию). Компенсация —
|
||||
// удаление созданного пользователя. Выполняется вне отменённого
|
||||
// контекста: письмо могло упасть именно из-за истечения таймаута ctx,
|
||||
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: welcomeSubject,
|
||||
@@ -175,9 +157,6 @@ func (s *UsersService) AddUser(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAccount удаляет учётную запись и все персональные данные пользователя
|
||||
// (ст. 21 152-ФЗ). Требует подтверждения паролем: обладатель украденного
|
||||
// access-токена не должен мочь удалить чужой аккаунт.
|
||||
func (s *UsersService) DeleteAccount(
|
||||
ctx context.Context,
|
||||
userID int,
|
||||
@@ -210,8 +189,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Анти-enumeration: ответ одинаков для существующего и несуществующего
|
||||
// адреса, письмо отправляется только реальному пользователю.
|
||||
user, err := s.usersRepo.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, users_repo.ErrUserNotFound) {
|
||||
@@ -229,9 +206,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return err
|
||||
}
|
||||
|
||||
// Письмо отправляется ДО смены пароля: при сбое отправки пароль
|
||||
// остаётся прежним и аккаунт не блокируется (иначе старый пароль уже
|
||||
// не работает, а новый пользователь так и не узнал).
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: user.Email,
|
||||
Subject: resetSubject,
|
||||
@@ -245,7 +219,6 @@ func (s *UsersService) RefreshPassword(
|
||||
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hashedPassword))
|
||||
}
|
||||
|
||||
// validateEmail проверяет, что строка является одиночным email-адресом.
|
||||
func validateEmail(email string) error {
|
||||
if email == "" {
|
||||
return ErrInvalidEmail
|
||||
@@ -254,7 +227,6 @@ func validateEmail(email string) error {
|
||||
if err != nil {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
// Отклоняем форму "Имя <a@b.c>" и всё, что не является чистым адресом.
|
||||
if !strings.EqualFold(addr.Address, email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
@@ -267,7 +239,6 @@ func (s *UsersService) Login(
|
||||
email string,
|
||||
password string,
|
||||
) (string, string, error) {
|
||||
// Сохраняем регистр email, как при регистрации; убираем только внешние пробелы.
|
||||
user, err := s.usersRepo.GetUserByEmail(
|
||||
ctx,
|
||||
strings.TrimSpace(email),
|
||||
@@ -418,11 +389,9 @@ var (
|
||||
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
|
||||
// ErrInvalidPasswordChange indicates that the password change request is invalid.
|
||||
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
|
||||
// ErrUserNotFound — целевой пользователь не найден.
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
ErrUserNotFound = errors.New("Пользователь не найден")
|
||||
)
|
||||
|
||||
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
|
||||
// ChangeUserPassword changes a user's password subject to authorization checks.
|
||||
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
|
||||
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
|
||||
|
||||
@@ -36,8 +36,6 @@ func (f *fakeSender) sent() []email_sender.Message {
|
||||
return append([]email_sender.Message(nil), f.messages...)
|
||||
}
|
||||
|
||||
// fakeRepo — минимальная in-memory реализация usersRepository для тестов
|
||||
// сервиса без БД.
|
||||
type fakeRepo struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*repos.User
|
||||
@@ -149,8 +147,6 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use
|
||||
}
|
||||
|
||||
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
|
||||
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
|
||||
// тестах не используется.
|
||||
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -175,7 +171,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
// Убираем внешние пробелы, сохраняя исходный регистр email.
|
||||
if _, ok := repo.users["User@Example.COM"]; !ok {
|
||||
t.Errorf("пользователь не создан с исходным регистром email: %+v", repo.users)
|
||||
}
|
||||
@@ -196,8 +191,6 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
if msgs[0].HTML == "" {
|
||||
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
|
||||
}
|
||||
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
|
||||
// но каркас письма (шапка сервиса) обязан присутствовать.
|
||||
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
|
||||
t.Error("HTML-версия письма не содержит шапку сервиса")
|
||||
}
|
||||
@@ -220,7 +213,6 @@ func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
|
||||
}
|
||||
// Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии.
|
||||
if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") {
|
||||
t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body)
|
||||
}
|
||||
@@ -250,7 +242,6 @@ func TestAddUserSendFailureCompensates(t *testing.T) {
|
||||
t.Fatal("ожидалась ошибка при сбое отправки письма")
|
||||
}
|
||||
|
||||
// Компенсация: клиенту ошибка, пользователя в БД нет.
|
||||
if repo.userCount() != 0 {
|
||||
t.Errorf("пользователь остался после ошибки отправки: %+v", repo.users)
|
||||
}
|
||||
@@ -288,7 +279,6 @@ func TestRefreshPasswordSuccess(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Внешние пробелы убираются, регистр остаётся исходным.
|
||||
err := svc.RefreshPassword(context.Background(), " User@Example.COM ")
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshPassword: %v", err)
|
||||
@@ -325,7 +315,6 @@ func TestRefreshPasswordUnknownEmail(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
svc := newTestService(repo, sender)
|
||||
|
||||
// Анти-enumeration: для несуществующего адреса — успех и без письма.
|
||||
if err := svc.RefreshPassword(context.Background(), "nobody@example.com"); err != nil {
|
||||
t.Fatalf("для несуществующего email ожидался успех, получено %v", err)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package users_service
|
||||
|
||||
import "time"
|
||||
|
||||
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
|
||||
// User describes a user exposed by the service layer.
|
||||
type User struct {
|
||||
ID int
|
||||
|
||||
Reference in New Issue
Block a user