This commit is contained in:
2026-10-03 13:22:11 +07:00
parent 05090722a0
commit 2caa2c1a88
74 changed files with 44 additions and 676 deletions
@@ -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)
+1 -23
View File
@@ -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 {
+2 -33
View File
@@ -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)
}
-1
View File
@@ -2,7 +2,6 @@ package users_service
import "time"
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
// User describes a user exposed by the service layer.
type User struct {
ID int