mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-28 02:02:08 +03:00
12d51d7195
Most tests opened a throwaway panel DB with database.InitDB, which runs the full AutoMigrate + seed on an empty file every time: ~230ms, and ~850ms under -race because GORM's reflection-heavy migration is what the detector slows most. internal/web/service does this in ~550 of its 830 tests, so the CI race job spent ~10 of its ~14.6 minutes re-migrating empty databases. internal/database/dbtest.InitDB migrates once per test process, then hands each test its own copy of that file (~130ms under -race) and registers the CloseDB cleanup. The copy then goes through InitDB like a panel restart, so every test still starts from the state a fresh install has. Tests that reopen an existing file, migrate a hand-built legacy DB or target Postgres keep calling database.InitDB. Locally under -race: internal/web/service 626s (last CI run) -> 114s, internal/sub 246s -> 35s.
250 lines
6.9 KiB
Go
250 lines
6.9 KiB
Go
package email
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net"
|
|
"net/mail"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
func TestBuildMessageIsRFC5322(t *testing.T) {
|
|
raw := buildMessage("panel@example.com", "3x-ui", []string{"a@example.com", "b@example.com"}, "Тест", "<b>hi</b>")
|
|
|
|
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
|
if err != nil {
|
|
t.Fatalf("message does not parse as RFC 5322: %v", err)
|
|
}
|
|
|
|
from, err := mail.ParseAddress(msg.Header.Get("From"))
|
|
if err != nil {
|
|
t.Fatalf("From header does not parse: %v", err)
|
|
}
|
|
if from.Name != "3x-ui" || from.Address != "panel@example.com" {
|
|
t.Errorf("From = %q <%q>, want name %q addr %q", from.Name, from.Address, "3x-ui", "panel@example.com")
|
|
}
|
|
|
|
if _, err := msg.Header.Date(); err != nil {
|
|
t.Errorf("Date header missing or unparseable: %v", err)
|
|
}
|
|
|
|
id := msg.Header.Get("Message-ID")
|
|
if !strings.HasPrefix(id, "<") || !strings.HasSuffix(id, "@example.com>") {
|
|
t.Errorf("Message-ID = %q, want <token@example.com>", id)
|
|
}
|
|
|
|
subject, err := (&mime.WordDecoder{}).DecodeHeader(msg.Header.Get("Subject"))
|
|
if err != nil {
|
|
t.Fatalf("Subject does not decode: %v", err)
|
|
}
|
|
if subject != "Тест" {
|
|
t.Errorf("Subject = %q, want %q", subject, "Тест")
|
|
}
|
|
|
|
body, _ := io.ReadAll(msg.Body)
|
|
if string(body) != "<b>hi</b>" {
|
|
t.Errorf("body = %q, want %q", body, "<b>hi</b>")
|
|
}
|
|
}
|
|
|
|
func TestBuildMessageFromWithoutName(t *testing.T) {
|
|
raw := buildMessage("panel@example.com", "", []string{"a@example.com"}, "s", "b")
|
|
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
from, err := mail.ParseAddress(msg.Header.Get("From"))
|
|
if err != nil {
|
|
t.Fatalf("From header does not parse: %v", err)
|
|
}
|
|
if from.Name != "" || from.Address != "panel@example.com" {
|
|
t.Errorf("From = %q <%q>, want bare addr", from.Name, from.Address)
|
|
}
|
|
}
|
|
|
|
func startFakeSMTPServer(t *testing.T) (string, func() []string) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
|
|
var mu sync.Mutex
|
|
var lines []string
|
|
record := func(line string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
lines = append(lines, line)
|
|
}
|
|
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
reader := bufio.NewReader(conn)
|
|
fmt.Fprint(conn, "220 fake ready\r\n")
|
|
inData := false
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
record(line)
|
|
if inData {
|
|
if line == "." {
|
|
inData = false
|
|
fmt.Fprint(conn, "250 ok\r\n")
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(line, "DATA"):
|
|
inData = true
|
|
fmt.Fprint(conn, "354 send\r\n")
|
|
case strings.HasPrefix(line, "QUIT"):
|
|
fmt.Fprint(conn, "221 bye\r\n")
|
|
return
|
|
default:
|
|
fmt.Fprint(conn, "250 ok\r\n")
|
|
}
|
|
}
|
|
}()
|
|
|
|
return ln.Addr().String(), func() []string {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return append([]string(nil), lines...)
|
|
}
|
|
}
|
|
|
|
func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) {
|
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
|
|
|
addr, recordedLines := startFakeSMTPServer(t)
|
|
host, portStr, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
port, err := strconv.Atoi(portStr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
settingService := service.SettingService{}
|
|
mustSet := func(name string, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("set %s: %v", name, err)
|
|
}
|
|
}
|
|
mustSet("host", settingService.SetSmtpHost(host))
|
|
mustSet("port", settingService.SetSmtpPort(port))
|
|
mustSet("from", settingService.SetSmtpFrom("3x-ui Panel <panel@example.com>"))
|
|
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
|
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
|
|
|
if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
|
|
var mailFrom, fromHeader string
|
|
for _, line := range recordedLines() {
|
|
if strings.HasPrefix(line, "MAIL FROM:") {
|
|
mailFrom = line
|
|
}
|
|
if strings.HasPrefix(line, "From: ") {
|
|
fromHeader = line
|
|
}
|
|
}
|
|
if want := "MAIL FROM:<panel@example.com>"; mailFrom != want {
|
|
t.Errorf("envelope sender = %q, want %q", mailFrom, want)
|
|
}
|
|
if want := `From: "3x-ui Panel" <panel@example.com>`; fromHeader != want {
|
|
t.Errorf("from header = %q, want %q", fromHeader, want)
|
|
}
|
|
}
|
|
|
|
func TestConnectionReportsMissingFrom(t *testing.T) {
|
|
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
|
|
|
settingService := service.SettingService{}
|
|
mustSet := func(name string, err error) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("set %s: %v", name, err)
|
|
}
|
|
}
|
|
mustSet("host", settingService.SetSmtpHost("127.0.0.1"))
|
|
mustSet("port", settingService.SetSmtpPort(1))
|
|
mustSet("to", settingService.SetSmtpTo("admin@example.com"))
|
|
mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
|
|
|
|
got := NewEmailService(settingService).TestConnection()
|
|
want := SMTPTestResult{Success: false, Stage: "send", Message: "smtpFromNotConfigured"}
|
|
if got != want {
|
|
t.Errorf("TestConnection() = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildMessageStripsHeaderInjection(t *testing.T) {
|
|
raw := buildMessage(
|
|
"panel@example.com\r\nBcc: evil@example.com",
|
|
"Name\r\nX-Evil: 1",
|
|
[]string{"a@example.com"}, "s", "b",
|
|
)
|
|
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got := msg.Header.Get("Bcc"); got != "" {
|
|
t.Errorf("injected Bcc header leaked: %q", got)
|
|
}
|
|
if got := msg.Header.Get("X-Evil"); got != "" {
|
|
t.Errorf("injected X-Evil header leaked: %q", got)
|
|
}
|
|
}
|
|
|
|
// Every Message this file emits is rendered by the frontend under a single
|
|
// "pages.settings." prefix, so a key that carries its own resolves to nothing
|
|
// and the alert shows the raw key instead of the classified reason.
|
|
func TestClassifySMTPErrorReturnsUnprefixedKeys(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{"auth", errors.New("535 5.7.8 Authentication credentials invalid"), "smtpErrorAuth"},
|
|
{"starttls", errors.New("534 must issue a STARTTLS command first"), "smtpErrorStarttls"},
|
|
{"refused", errors.New("dial tcp 127.0.0.1:25: connection refused"), "smtpErrorRefused"},
|
|
{"timeout", errors.New("i/o timeout"), "smtpErrorTimeout"},
|
|
{"relay", errors.New("550 relay not permitted"), "smtpErrorRelay"},
|
|
{"eof", errors.New("unexpected EOF"), "smtpErrorEof"},
|
|
{"unknown", errors.New("something nobody classified"), "smtpErrorUnknown"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := classifySMTPError(tc.err)
|
|
if got != tc.want {
|
|
t.Fatalf("classifySMTPError = %q, want %q", got, tc.want)
|
|
}
|
|
if strings.HasPrefix(got, "pages.settings.") {
|
|
t.Fatalf("key %q carries the prefix the frontend adds, so it resolves to nothing", got)
|
|
}
|
|
})
|
|
}
|
|
}
|