diff --git a/.env.example b/.env.example index f33f3d1..b5c7ba1 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,20 @@ SMTP_HOST=mail.crabs-games.art SMTP_PORT=465 SMTP_USER=evening_detective@crabs-games.art SMTP_PASSWORD=your_password +# Отображаемое имя отправителя: Gmail показывает его вместо голого адреса, +# письма от «имени» выглядят человечнее и реже попадают в спам +SMTP_FROM_NAME=Вечерний детектив +# Адрес, на который пользователь сможет ответить на письмо +SMTP_REPLY_TO=evening_detective@crabs-games.art # Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s SMTP_TIMEOUT=10s JWT_SECRET=your_secret +# Публичный адрес фронтенда: используется для ссылки «Войти в игру» в +# письмах. Если не задан или не http(s) — ссылка в письма не добавляется. +APP_BASE_URL=https://evening-detective.crabs-games.art + S3_HOST=http://0.0.0.0:9000 S3_USER=rustfs S3_PASSWORD=your_password diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 320d09c..b09a39e 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 bed5cb5..6452ddb 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -71,11 +71,13 @@ func main() { os.Getenv("SMTP_PORT"), os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASSWORD"), + os.Getenv("SMTP_FROM_NAME"), + os.Getenv("SMTP_REPLY_TO"), smtpTimeout, ) processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET")) refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool) - usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo) + usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL")) uiService := ui_service.NewUiService() fileStorage, err := file_storage.NewRustFSStorage( os.Getenv("S3_HOST"), diff --git a/internal/modules/email_sender/interface.go b/internal/modules/email_sender/interface.go index 08ded4f..6973c4a 100644 --- a/internal/modules/email_sender/interface.go +++ b/internal/modules/email_sender/interface.go @@ -5,7 +5,11 @@ import "context" type Message struct { To string Subject string - Body string + // Body — текстовая версия письма (text/plain). + Body string + // HTML — версия письма для почтовых клиентов (text/html). + // Если пусто, письмо уходит только в text/plain. + HTML string } type IEmailSender interface { diff --git a/internal/modules/email_sender/sender.go b/internal/modules/email_sender/sender.go index 96c9dd3..521fea8 100644 --- a/internal/modules/email_sender/sender.go +++ b/internal/modules/email_sender/sender.go @@ -2,6 +2,7 @@ package email_sender import ( "context" + "crypto/rand" "crypto/tls" "errors" "fmt" @@ -19,6 +20,8 @@ type sender struct { smtpUser string smtpPassword string from string + fromName string + replyTo string timeout time.Duration tlsConfig *tls.Config } @@ -28,6 +31,8 @@ func NewSender( smtpPort string, smtpUser string, smtpPassword string, + fromName string, + replyTo string, timeout time.Duration, ) IEmailSender { // From заголовка письма по умолчанию совпадает с учётной записью SMTP. @@ -41,6 +46,8 @@ func NewSender( smtpUser: smtpUser, smtpPassword: smtpPassword, from: from, + fromName: sanitizeHeader(fromName), + replyTo: sanitizeHeader(replyTo), timeout: timeout, // Проверка имени сервера включена всегда; поле переопределяется // только в тестах (свой RootCAs для самоподписанного сертификата). @@ -59,7 +66,7 @@ func (s *sender) Send(ctx context.Context, message Message) error { if err := validateMessage(to, subject); err != nil { return err } - body := buildMessage(s.from, to, subject, message.Body) + body := buildMessage(s.from, s.fromName, to, subject, s.replyTo, message.Body, message.HTML) ctx, cancel := context.WithTimeout(ctx, s.timeout) defer cancel() @@ -115,29 +122,95 @@ func (s *sender) Send(ctx context.Context, message Message) error { return nil } -// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст. -// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица -// иначе может быть испорчена промежуточными серверами). -func buildMessage(from, to, subject, body string) []byte { +// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому +// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе +// кириллица может быть испорчена промежуточными серверами. Заголовки +// Date и Message-ID добавляются явно: их отсутствие — типичный признак +// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия, +// письмо собирается как multipart/alternative (text/plain + text/html). +func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte { var b strings.Builder - b.Grow(len(from) + len(to) + len(subject) + len(body) + 128) + b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512) b.WriteString("From: ") - b.WriteString(from) - b.WriteString("\r\n") + if fromName != "" { + b.WriteString(mime.QEncoding.Encode("utf-8", fromName)) + b.WriteString(" <") + b.WriteString(from) + b.WriteString(">\r\n") + } else { + 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("Date: ") + b.WriteString(time.Now().UTC().Format(time.RFC1123Z)) + b.WriteString("\r\n") + b.WriteString("Message-ID: ") + b.WriteString(messageID(from)) + b.WriteString("\r\n") + if replyTo != "" { + b.WriteString("Reply-To: ") + b.WriteString(replyTo) + b.WriteString("\r\n") + } b.WriteString("MIME-Version: 1.0\r\n") + if html == "" { + 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()) + } + + boundary := fmt.Sprintf("----=_evening_detective_%s", randomHex(10)) + b.WriteString("Content-Type: multipart/alternative; boundary=\"") + b.WriteString(boundary) + b.WriteString("\"\r\n\r\n") + b.WriteString("--") + b.WriteString(boundary) + b.WriteString("\r\n") b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") - b.WriteString("\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") b.WriteString(body) + b.WriteString("\r\n--") + b.WriteString(boundary) b.WriteString("\r\n") + b.WriteString("Content-Type: text/html; charset=utf-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n") + b.WriteString(html) + b.WriteString("\r\n--") + b.WriteString(boundary) + b.WriteString("--\r\n") return []byte(b.String()) } +// messageID генерирует уникальный Message-ID в домене отправителя. +func messageID(from string) string { + domain := "" + if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 { + domain = from[i+1:] + } + if domain == "" { + domain = "localhost" + } + return "<" + randomHex(12) + "@" + domain + ">" +} + +// randomHex возвращает случайную hex-строку (криптостойкий генератор; +// при сбое — значение из таймера, чтобы отправка не падала). +func randomHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%x", time.Now().UnixNano()) + } + return fmt.Sprintf("%x", b) +} + // sanitizeHeader удаляет символы, ломающие структуру заголовков письма // (CRLF-инъекция заголовков, NUL). func sanitizeHeader(s string) string { diff --git a/internal/modules/email_sender/sender_test.go b/internal/modules/email_sender/sender_test.go index d58a167..176e4e7 100644 --- a/internal/modules/email_sender/sender_test.go +++ b/internal/modules/email_sender/sender_test.go @@ -147,6 +147,8 @@ func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certi smtpUser: "sender@example.com", smtpPassword: "secret", from: "sender@example.com", + fromName: "Вечерний детектив", + replyTo: "support@example.com", timeout: timeout, tlsConfig: &tls.Config{ServerName: host, RootCAs: pool}, } @@ -170,6 +172,7 @@ func TestSendSuccess(t *testing.T) { To: "user@example.com", Subject: "Привет, детектив!", Body: "Текст письма", + HTML: "Текст письма", }) if err != nil { t.Fatalf("Send: %v", err) @@ -179,13 +182,21 @@ func TestSendSuccess(t *testing.T) { case data := <-messages: raw := string(data) for _, want := range []string{ - "From: sender@example.com", + // From с отображаемым именем (RFC 2047). + "From: =?utf-8?q?", + "", "To: user@example.com", "Subject: =?utf-8?q?", + "Date: ", + "Message-ID: <", + "@example.com>", + "Reply-To: support@example.com", "MIME-Version: 1.0", + "Content-Type: multipart/alternative; boundary=", "Content-Type: text/plain; charset=utf-8", - "\r\n\r\n", "Текст письма", + "Content-Type: text/html; charset=utf-8", + "Текст письма", } { if !strings.Contains(raw, want) { t.Errorf("письмо не содержит %q:\n%s", want, raw) @@ -252,15 +263,71 @@ func TestSendTimeout(t *testing.T) { } func TestBuildMessage(t *testing.T) { - raw := string(buildMessage("sender@example.com", "user@example.com", "Привет", "текст")) + // С HTML-версией — multipart/alternative. + raw := string(buildMessage( + "sender@example.com", "Вечерний детектив", + "user@example.com", "Привет", "support@example.com", + "текст", "текст", + )) for _, want := range []string{ - "From: sender@example.com", + "From: =?utf-8?q?", + "", "To: user@example.com", "Subject: =?utf-8?q?", + "Date: ", + "Message-ID: <", + "Reply-To: support@example.com", "MIME-Version: 1.0", + "Content-Type: multipart/alternative; boundary=", "Content-Type: text/plain; charset=utf-8", - "\r\n\r\n", "текст", + "Content-Type: text/html; charset=utf-8", + "текст", + } { + if !strings.Contains(raw, want) { + t.Errorf("письмо не содержит %q:\n%s", want, raw) + } + } + + // Без HTML-версии — только text/plain (обратная совместимость). + raw = string(buildMessage( + "sender@example.com", "Вечерний детектив", + "user@example.com", "Привет", "", + "текст", "", + )) + for _, want := range []string{ + "Content-Type: text/plain; charset=utf-8", + "текст", + } { + if !strings.Contains(raw, want) { + t.Errorf("plain-письмо не содержит %q:\n%s", want, raw) + } + } + if strings.Contains(raw, "multipart/alternative") { + t.Errorf("plain-письмо не должно быть multipart:\n%s", raw) + } +} + +func TestBuildMessageNoFromName(t *testing.T) { + raw := string(buildMessage( + "sender@example.com", "", + "user@example.com", "Привет", "", + "текст", "", + )) + if !strings.Contains(raw, "From: sender@example.com\r\n") { + t.Errorf("адрес без имени отправителя должен идти без угловых скобок:\n%s", raw) + } +} + +func TestBuildMessageMessageIDDomain(t *testing.T) { + raw := string(buildMessage( + "evening_detective@crabs-games.art", "Вечерний детектив", + "user@example.com", "Привет", "", + "текст", "", + )) + for _, want := range []string{ + "Message-ID: <", + "@crabs-games.art>", } { if !strings.Contains(raw, want) { t.Errorf("письмо не содержит %q:\n%s", want, raw) diff --git a/internal/services/users_service/emails.go b/internal/services/users_service/emails.go new file mode 100644 index 0000000..dd53a9a --- /dev/null +++ b/internal/services/users_service/emails.go @@ -0,0 +1,197 @@ +package users_service + +import ( + "fmt" + "html" + "strings" +) + +// Шаблоны писем сервиса. Структура по базе знаний копирайтера (email.md): +// приветствие → контекст («почему это письмо») → ценность (данные для входа) +// → CTA → подпись → P.S. Письма транзакционные, но по форме — «человек +// человеку»: Gmail и другие почтовики агрессивнее фильтруют короткие +// «роботные» письма без структуры. +// +// Каждое письмо формируется в двух версиях: text/plain (для старых клиентов +// и фильтров) и text/html (для отображения); sender собирает их в +// multipart/alternative. + +const ( + // welcomeSubject — тема письма о регистрации: конкретика без КАПСА и + // кликбейта, до 50 символов (видно на мобильных целиком). + welcomeSubject = "Твой доступ к «Вечернему детективу»" + // resetSubject — тема письма о сбросе пароля. + resetSubject = "Новый пароль для «Вечернего детектива»" +) + +// welcomePlain возвращает текстовую версию письма о регистрации. +func welcomePlain(username, email, password, loginURL string) string { + var b strings.Builder + b.WriteString(greeting(username)) + b.WriteString("\n\n") + b.WriteString("Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:\n\n") + b.WriteString(" Логин: ") + b.WriteString(email) + b.WriteString("\n") + b.WriteString(" Пароль: ") + b.WriteString(password) + b.WriteString("\n\n") + if loginURL != "" { + b.WriteString("Войти в игру: ") + b.WriteString(loginURL) + b.WriteString("\n\n") + } + b.WriteString("Если ты не регистрировался — просто проигнорируй это письмо.\n\n") + b.WriteString("— Команда «Вечернего детектива»\n") + b.WriteString("P.S. Сохрани это письмо: пароль понадобится для входа.") + return sanitizeLine(b.String()) +} + +// welcomeHTML возвращает HTML-версию письма о регистрации. +func welcomeHTML(username, email, password, loginURL string) string { + var content strings.Builder + content.WriteString("

Привет, ") + content.WriteString(html.EscapeString(username)) + content.WriteString("!

\n") + content.WriteString("

Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:

\n") + content.WriteString(credentialsBlockHTML(email, password)) + if loginURL != "" { + content.WriteString(buttonHTML(loginURL, "Войти в игру")) + } + content.WriteString("

Если ты не регистрировался — просто проигнорируй это письмо.

") + return wrapHTML(content.String()) +} + +// resetPlain возвращает текстовую версию письма о сбросе пароля. +func resetPlain(username, email, password, loginURL string) string { + var b strings.Builder + b.WriteString(greeting(username)) + b.WriteString("\n\n") + b.WriteString("Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:\n\n") + b.WriteString(" Логин: ") + b.WriteString(email) + b.WriteString("\n") + b.WriteString(" Пароль: ") + b.WriteString(password) + b.WriteString("\n\n") + if loginURL != "" { + b.WriteString("Войти в игру: ") + b.WriteString(loginURL) + b.WriteString("\n\n") + } + b.WriteString("Никому не сообщай пароль и не пересылай это письмо.\n") + b.WriteString("Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.\n\n") + b.WriteString("— Команда «Вечернего детектива»\n") + return sanitizeLine(b.String()) +} + +// resetHTML возвращает HTML-версию письма о сбросе пароля. +func resetHTML(username, email, password, loginURL string) string { + var content strings.Builder + content.WriteString("

Привет, ") + content.WriteString(html.EscapeString(username)) + content.WriteString("!

\n") + content.WriteString("

Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:

\n") + content.WriteString(credentialsBlockHTML(email, password)) + if loginURL != "" { + content.WriteString(buttonHTML(loginURL, "Войти в игру")) + } + content.WriteString("

Никому не сообщай пароль и не пересылай это письмо.

\n") + content.WriteString("

Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.

") + return wrapHTML(content.String()) +} + +// greeting возвращает приветствие с именем пользователя; при пустом имени — +// без обращения, чтобы не было «Привет, !». +func greeting(username string) string { + username = sanitizeLine(strings.TrimSpace(username)) + if username == "" { + return "Привет!" + } + return fmt.Sprintf("Привет, %s!", username) +} + +// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке, +// совместимой с Gmail. +func credentialsBlockHTML(email, password string) string { + return ` + + + + + + + + +
Логин` + html.EscapeString(email) + `
Пароль` + html.EscapeString(password) + `
` +} + +// buttonHTML — CTA-кнопка табличной вёрсткой (Gmail не поддерживает +// margin/padding на , поэтому отступы — на ячейке ). +func buttonHTML(link, label string) string { + return ` + + + +
+` + html.EscapeString(label) + ` +
` +} + +// wrapHTML — каркас письма: тёмная шапка с названием сервиса, контент, +// подвал с дисклеймером. Только табличная вёрстка и inline-стили: внешние +// таблицы стилей Gmail вырезает. +func wrapHTML(content string) string { + return ` + + + + + + + + +
+ + + + + + + + + + +
+ВЕЧЕРНИЙ ДЕТЕКТИВ +
+` + content + ` +
+Это автоматическое письмо сервиса «Вечерний детектив».
+Если ты получил его по ошибке — просто ответь на это письмо. +
+
+ +` +} + +// 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, "/") + if baseURL == "" { + return "" + } + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + return "" + } + return baseURL +} diff --git a/internal/services/users_service/service.go b/internal/services/users_service/service.go index 0c87610..34bce5f 100644 --- a/internal/services/users_service/service.go +++ b/internal/services/users_service/service.go @@ -58,6 +58,9 @@ type UsersService struct { emailSender email_sender.IEmailSender processorJWT processor_jwt.IProcessorJWT refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo + // appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в + // письмах; пустое значение — ссылка не добавляется. + appBaseURL string } func NewUsersService( @@ -66,6 +69,7 @@ func NewUsersService( emailSender email_sender.IEmailSender, processorJWT processor_jwt.IProcessorJWT, refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo, + appBaseURL string, ) *UsersService { return &UsersService{ usersRepo: usersRepo, @@ -73,6 +77,7 @@ func NewUsersService( emailSender: emailSender, processorJWT: processorJWT, refreshTokensRepo: refreshTokensRepo, + appBaseURL: loginURL(appBaseURL), } } @@ -140,8 +145,9 @@ func (s *UsersService) AddUser( // и тогда тот же ctx не дал бы выполнить удаление. err = s.emailSender.Send(ctx, email_sender.Message{ To: email, - Subject: "Приветствую тебя, детектив!", - Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password), + Subject: welcomeSubject, + Body: welcomePlain(username, email, password, s.appBaseURL), + HTML: welcomeHTML(username, email, password, s.appBaseURL), }) if err != nil { if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil { @@ -204,8 +210,9 @@ func (s *UsersService) RefreshPassword( // не работает, а новый пользователь так и не узнал). err = s.emailSender.Send(ctx, email_sender.Message{ To: user.Email, - Subject: "Сброс пароля", - Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password), + Subject: resetSubject, + Body: resetPlain(user.Username, user.Email, password, s.appBaseURL), + HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL), }) if err != nil { return fmt.Errorf("email send: %w", err) diff --git a/internal/services/users_service/service_test.go b/internal/services/users_service/service_test.go index 10c367a..7338b25 100644 --- a/internal/services/users_service/service_test.go +++ b/internal/services/users_service/service_test.go @@ -145,6 +145,10 @@ func (r *fakeRepo) seed(email, passwordHash string) { } func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService { + return newTestServiceWithBaseURL(repo, sender, "") +} + +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") @@ -157,6 +161,7 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use sender, processor_jwt.NewProcessor("test-secret"), refresh_tokens_repo.NewRefreshTokensRepo(pool), + baseURL, ) } @@ -182,8 +187,56 @@ func TestAddUserSuccess(t *testing.T) { if msgs[0].To != "user@example.com" { t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To) } + if !strings.Contains(msgs[0].Subject, "Вечернему детективу") { + t.Errorf("неожиданная тема письма: %q", msgs[0].Subject) + } if !strings.Contains(msgs[0].Body, "пароль") { - t.Error("в письме не упоминается пароль") + t.Error("в текстовой версии письма не упоминается пароль") + } + if msgs[0].HTML == "" { + t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма") + } + // APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется, + // но каркас письма (шапка сервиса) обязан присутствовать. + if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") { + t.Error("HTML-версия письма не содержит шапку сервиса") + } + if strings.Contains(msgs[0].HTML, "Войти в игру") { + t.Error("кнопка входа не должна добавляться без APP_BASE_URL") + } +} + +func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) { + repo := newFakeRepo() + sender := &fakeSender{} + svc := newTestServiceWithBaseURL(repo, sender, "https://evening-detective.crabs-games.art/") + + err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "") + if err != nil { + t.Fatalf("AddUser: %v", err) + } + + msgs := sender.sent() + 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) + } + if !strings.Contains(msgs[0].HTML, "https://evening-detective.crabs-games.art") { + t.Error("HTML-версия не содержит ссылку на вход") + } + if !strings.Contains(msgs[0].HTML, "Войти в игру") { + t.Error("HTML-версия не содержит кнопку «Войти в игру»") + } +} + +func TestLoginURLInvalid(t *testing.T) { + for _, in := range []string{"", " ", "ftp://example.com", "not-a-url", "javascript:alert(1)"} { + if got := loginURL(in); got != "" { + t.Errorf("loginURL(%q) = %q, want пусто", in, got) + } } }