axe/internal/mailer/mailer_test.go

228 lines
7.8 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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)
}
}