axe/internal/mailer/mailer_test.go
2026-10-18 22:07:25 +03:00

228 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mailer
import (
"encoding/base64"
"io"
"log/slog"
"strings"
"testing"
"time"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// decodePart — вырезает часть multipart по её Content-Type и декодирует
// base64-тело обратно в строку.
func decodePart(t *testing.T, msg, contentType string) string {
t.Helper()
idx := strings.Index(msg, "Content-Type: "+contentType)
if idx < 0 {
t.Fatalf("no part %q in message:\n%s", contentType, msg)
}
rest := msg[idx:]
sep := strings.Index(rest, "\r\n\r\n")
if sep < 0 {
t.Fatalf("no body separator after %q", contentType)
}
body := rest[sep+4:]
if end := strings.Index(body, "\r\n--"+altBoundary); end >= 0 {
body = body[:end]
}
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(body, "\r\n", ""))
if err != nil {
t.Fatalf("part %q decode: %v", contentType, err)
}
return string(decoded)
}
// TestBuildMessage — из письма не должен сыпаться UTF-8: тема в
// encoded-word (RFC 2047), обе части тела в base64 (RFC 2045),
// разделители CRLF, multipart/alternative (текст + HTML).
func TestBuildMessage(t *testing.T) {
const text = "Здравствуйте, Иван!\n"
const htmlBody = "<p>Здравствуйте, Иван!</p>\n"
msg := buildMessage("from@example.test", "to@example.test", "Аккаунт подтверждён", text, htmlBody)
if !strings.HasPrefix(msg, "From: from@example.test\r\nTo: to@example.test\r\n") {
t.Errorf("envelope headers:\n%q", msg)
}
if !strings.Contains(msg, "\r\nSubject: =?UTF-8?B?") &&
!strings.Contains(msg, "\r\nSubject: =?UTF-8?b?") {
t.Errorf("subject must be encoded-word (RFC 2047): %q", msg)
}
if !strings.Contains(msg, "Content-Type: multipart/alternative; boundary=\""+altBoundary+"\"") {
t.Errorf("missing multipart/alternative header: %q", msg)
}
// Обе части на месте и декодируются обратно без потерь.
if got := decodePart(t, msg, "text/plain; charset=UTF-8"); got != text {
t.Errorf("text part = %q, want %q", got, text)
}
if got := decodePart(t, msg, "text/html; charset=UTF-8"); got != htmlBody {
t.Errorf("html part = %q, want %q", got, htmlBody)
}
// Последняя граница закрыта.
if !strings.HasSuffix(msg, "--"+altBoundary+"--\r\n") {
t.Errorf("multipart not closed: %q", msg[len(msg)-80:])
}
}
// TestConfirmedContent — состав письма: ссылка на вход, роль
// по-человечески, дата подтверждения. Имя/email пользователя —
// данные из аккаунта, в HTML они обязаны быть экранированы.
func TestConfirmedContent(t *testing.T) {
a := AccountConfirmed{
To: "user@example.test",
Name: `Иван <script>alert(1)</script>`,
Role: "worker",
ConfirmedAt: time.Date(2026, 10, 6, 19, 4, 0, 0, time.UTC),
}
const base = "https://example.test/"
text := confirmedText(a, base)
for _, want := range []string{
"Email аккаунта: user@example.test",
"Роль: Мастер",
"Подтверждено: 06.10.2026 19:04",
"Вход: https://example.test/#/login",
} {
if !strings.Contains(text, want) {
t.Errorf("text part misses %q:\n%s", want, text)
}
}
page := confirmedHTML(a, base)
if strings.Contains(page, "<script>") {
t.Errorf("HTML must escape the name:\n%s", page)
}
if !strings.Contains(page, "Иван &lt;script&gt;") {
t.Errorf("escaped name not found:\n%s", page)
}
for _, want := range []string{
`href="https://example.test/#/login"`,
"Мастер",
"user@example.test",
} {
if !strings.Contains(page, want) {
t.Errorf("HTML misses %q", want)
}
}
// Без даты (нулевое время) строка «Подтверждено» опускается.
noDate := confirmedHTML(
AccountConfirmed{To: "a@b.c", Name: "N", Role: "client"},
"http://localhost:3000")
if strings.Contains(noDate, "Подтверждено") {
t.Errorf("zero ConfirmedAt must omit the date row:\n%s", noDate)
}
}
// TestResetContent — состав письма сброса: ссылка с токеном, срок
// действия, email. Имя/адрес — данные пользователя, в HTML обязаны
// быть экранированы.
func TestResetContent(t *testing.T) {
p := PasswordReset{
To: "user@example.test",
Name: `Иван <script>alert(1)</script>`,
Token: "abc123TOKEN",
ExpiresAt: time.Date(2026, 10, 7, 12, 30, 0, 0, time.UTC),
}
const base = "https://example.test/"
text := resetText(p, base)
for _, want := range []string{
"Email аккаунта: user@example.test",
"https://example.test/#/reset-password?token=abc123TOKEN",
"до 07.10.2026 12:30",
} {
if !strings.Contains(text, want) {
t.Errorf("text part misses %q:\n%s", want, text)
}
}
page := resetHTML(p, base)
if strings.Contains(page, "<script>") {
t.Errorf("HTML must escape the name:\n%s", page)
}
if !strings.Contains(page, "Иван &lt;script&gt;") {
t.Errorf("escaped name not found:\n%s", page)
}
for _, want := range []string{
`href="https://example.test/#/reset-password?token=abc123TOKEN"`,
"Задать новый пароль",
"user@example.test",
"07.10.2026 12:30",
} {
if !strings.Contains(page, want) {
t.Errorf("HTML misses %q", want)
}
}
}
// TestWrapBase64 — длина строки 76 символов (RFC 2045), содержимое
// сохраняется при склейке.
func TestWrapBase64(t *testing.T) {
in := strings.Repeat("ы", 300)
out := wrapBase64(in)
for i, line := range strings.Split(strings.TrimRight(out, "\r\n"), "\r\n") {
if len(line) > 76 {
t.Errorf("line %d: %d bytes, want <= 76", i, len(line))
}
}
joined := strings.ReplaceAll(out, "\r\n", "")
decoded, err := base64.StdEncoding.DecodeString(joined)
if err != nil {
t.Fatalf("decode: %v", err)
}
if string(decoded) != in {
t.Error("content lost after wrap/unwrap")
}
}
// TestNewValidatesConfig — фейл-фаст на старте: пустой хост — заглушка,
// кривые порт/режим/адрес — ошибка, а не сюрприз на первом письме.
func TestNewValidatesConfig(t *testing.T) {
if _, err := New(Config{}, testLogger()); err != nil {
t.Errorf("empty host must be disabled mailer, got %v", err)
}
if _, err := New(Config{Host: "smtp.example", Port: "abc", From: "a@b.c"}, testLogger()); err == nil {
t.Error("invalid SMTP_PORT must fail")
}
if _, err := New(Config{Host: "smtp.example", Port: "587", TLS: "bogus", From: "a@b.c"}, testLogger()); err == nil {
t.Error("invalid SMTP_TLS must fail")
}
if _, err := New(Config{Host: "smtp.example", Port: "587"}, testLogger()); err == nil {
t.Error("empty From and User must fail")
}
}
// TestNewDefaults — дефолты: порт 587, режим по порту (465 → implicit TLS),
// адрес отправителя берётся из пользователя.
func TestNewDefaults(t *testing.T) {
m, err := New(Config{Host: "smtp.example", User: "me@example.test"}, testLogger())
if err != nil {
t.Fatalf("new: %v", err)
}
sm, ok := m.(*smtpMailer)
if !ok {
t.Fatalf("type = %T, want *smtpMailer", m)
}
if sm.cfg.Port != "587" || sm.mode != tlsStart {
t.Errorf("defaults: port=%s mode=%s, want 587/starttls", sm.cfg.Port, sm.mode)
}
if sm.cfg.From != "me@example.test" {
t.Errorf("from = %q, want SMTP_USER", sm.cfg.From)
}
m, err = New(Config{Host: "smtp.example", Port: "465", From: "a@b.c"}, testLogger())
if err != nil {
t.Fatalf("new 465: %v", err)
}
if sm := m.(*smtpMailer); sm.mode != tlsImplicit {
t.Errorf("port 465 mode = %s, want %s", sm.mode, tlsImplicit)
}
}