Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
384 lines
11 KiB
Go
384 lines
11 KiB
Go
//go:build unit
|
||
|
||
package service
|
||
|
||
import (
|
||
"bufio"
|
||
"crypto/ecdsa"
|
||
"crypto/elliptic"
|
||
"crypto/rand"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"crypto/x509/pkix"
|
||
"math/big"
|
||
"net"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// newSMTPTestCert 生成 127.0.0.1/localhost 的自签证书及其信任池。
|
||
func newSMTPTestCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||
t.Helper()
|
||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||
if err != nil {
|
||
t.Fatalf("generate key: %v", err)
|
||
}
|
||
template := x509.Certificate{
|
||
SerialNumber: big.NewInt(1),
|
||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||
NotBefore: time.Now().Add(-time.Hour),
|
||
NotAfter: time.Now().Add(time.Hour),
|
||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||
BasicConstraintsValid: true,
|
||
IsCA: true,
|
||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||
DNSNames: []string{"localhost"},
|
||
}
|
||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||
if err != nil {
|
||
t.Fatalf("create certificate: %v", err)
|
||
}
|
||
leaf, err := x509.ParseCertificate(der)
|
||
if err != nil {
|
||
t.Fatalf("parse certificate: %v", err)
|
||
}
|
||
pool := x509.NewCertPool()
|
||
pool.AddCert(leaf)
|
||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, pool
|
||
}
|
||
|
||
// fakeSMTPServer 是覆盖三种连接形态的最小 SMTP 服务器:
|
||
// 隐式 TLS(465 语义)、明文+STARTTLS(587 语义)、纯明文。
|
||
type fakeSMTPServer struct {
|
||
listener net.Listener
|
||
tlsConfig *tls.Config
|
||
advertiseStartTLS bool
|
||
|
||
mu sync.Mutex
|
||
commands []string
|
||
conns atomic.Int64
|
||
wg sync.WaitGroup
|
||
}
|
||
|
||
func startFakeSMTPServer(t *testing.T, implicitTLS, advertiseStartTLS bool) (*fakeSMTPServer, int) {
|
||
t.Helper()
|
||
cert, pool := newSMTPTestCert(t)
|
||
prevPool := smtpTestRootCAs
|
||
smtpTestRootCAs = pool
|
||
t.Cleanup(func() { smtpTestRootCAs = prevPool })
|
||
|
||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("listen: %v", err)
|
||
}
|
||
srv := &fakeSMTPServer{
|
||
listener: listener,
|
||
tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||
advertiseStartTLS: advertiseStartTLS,
|
||
}
|
||
if implicitTLS {
|
||
srv.listener = tls.NewListener(listener, srv.tlsConfig)
|
||
}
|
||
t.Cleanup(func() {
|
||
_ = srv.listener.Close()
|
||
srv.wg.Wait()
|
||
})
|
||
|
||
srv.wg.Add(1)
|
||
go func() {
|
||
defer srv.wg.Done()
|
||
for {
|
||
conn, err := srv.listener.Accept()
|
||
if err != nil {
|
||
return
|
||
}
|
||
srv.conns.Add(1)
|
||
srv.wg.Add(1)
|
||
go func() {
|
||
defer srv.wg.Done()
|
||
defer func() { _ = conn.Close() }()
|
||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||
srv.serve(conn, srv.advertiseStartTLS)
|
||
}()
|
||
}
|
||
}()
|
||
|
||
port := listener.Addr().(*net.TCPAddr).Port
|
||
return srv, port
|
||
}
|
||
|
||
func (srv *fakeSMTPServer) record(cmd string) {
|
||
srv.mu.Lock()
|
||
defer srv.mu.Unlock()
|
||
srv.commands = append(srv.commands, cmd)
|
||
}
|
||
|
||
func (srv *fakeSMTPServer) sawCommand(prefix string) bool {
|
||
srv.mu.Lock()
|
||
defer srv.mu.Unlock()
|
||
for _, cmd := range srv.commands {
|
||
if strings.HasPrefix(strings.ToUpper(cmd), prefix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (srv *fakeSMTPServer) serve(conn net.Conn, allowStartTLS bool) {
|
||
reader := bufio.NewReader(conn)
|
||
writer := bufio.NewWriter(conn)
|
||
writeLine := func(line string) bool {
|
||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||
return false
|
||
}
|
||
return writer.Flush() == nil
|
||
}
|
||
if !writeLine("220 fake.test ESMTP ready") {
|
||
return
|
||
}
|
||
for {
|
||
line, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
return
|
||
}
|
||
cmd := strings.TrimSpace(line)
|
||
srv.record(cmd)
|
||
upper := strings.ToUpper(cmd)
|
||
switch {
|
||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||
ok := writeLine("250-fake.test")
|
||
if allowStartTLS {
|
||
ok = ok && writeLine("250-STARTTLS")
|
||
}
|
||
if !(ok && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||
return
|
||
}
|
||
case upper == "STARTTLS" && allowStartTLS:
|
||
if !writeLine("220 2.0.0 ready to start TLS") {
|
||
return
|
||
}
|
||
tlsConn := tls.Server(conn, srv.tlsConfig)
|
||
if err := tlsConn.Handshake(); err != nil {
|
||
return
|
||
}
|
||
srv.serveUpgraded(tlsConn)
|
||
return
|
||
case strings.HasPrefix(upper, "AUTH"):
|
||
if !writeLine("235 2.7.0 authentication successful") {
|
||
return
|
||
}
|
||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||
if !writeLine("250 ok") {
|
||
return
|
||
}
|
||
case upper == "DATA":
|
||
if !writeLine("354 go ahead") {
|
||
return
|
||
}
|
||
for {
|
||
dataLine, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
return
|
||
}
|
||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||
break
|
||
}
|
||
}
|
||
if !writeLine("250 message accepted") {
|
||
return
|
||
}
|
||
case upper == "QUIT":
|
||
_ = writeLine("221 bye")
|
||
return
|
||
default:
|
||
if !writeLine("250 ok") {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// serveUpgraded 复用命令循环处理 STARTTLS 升级后的会话(升级后不再提供 STARTTLS)。
|
||
func (srv *fakeSMTPServer) serveUpgraded(conn net.Conn) {
|
||
reader := bufio.NewReader(conn)
|
||
writer := bufio.NewWriter(conn)
|
||
// net/smtp 在 StartTLS 成功后会重新发送 EHLO,直接进入命令循环即可。
|
||
srv.serveCommands(reader, writer)
|
||
}
|
||
|
||
func (srv *fakeSMTPServer) serveCommands(reader *bufio.Reader, writer *bufio.Writer) {
|
||
writeLine := func(line string) bool {
|
||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||
return false
|
||
}
|
||
return writer.Flush() == nil
|
||
}
|
||
for {
|
||
line, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
return
|
||
}
|
||
cmd := strings.TrimSpace(line)
|
||
srv.record(cmd)
|
||
upper := strings.ToUpper(cmd)
|
||
switch {
|
||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||
if !(writeLine("250-fake.test") && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||
return
|
||
}
|
||
case strings.HasPrefix(upper, "AUTH"):
|
||
if !writeLine("235 2.7.0 authentication successful") {
|
||
return
|
||
}
|
||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||
if !writeLine("250 ok") {
|
||
return
|
||
}
|
||
case upper == "DATA":
|
||
if !writeLine("354 go ahead") {
|
||
return
|
||
}
|
||
for {
|
||
dataLine, err := reader.ReadString('\n')
|
||
if err != nil {
|
||
return
|
||
}
|
||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||
break
|
||
}
|
||
}
|
||
if !writeLine("250 message accepted") {
|
||
return
|
||
}
|
||
case upper == "QUIT":
|
||
_ = writeLine("221 bye")
|
||
return
|
||
default:
|
||
if !writeLine("250 ok") {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func smtpTestConfig(port int, useTLS bool) *SMTPConfig {
|
||
return &SMTPConfig{
|
||
Host: "127.0.0.1",
|
||
Port: port,
|
||
Username: "user",
|
||
Password: "pass",
|
||
From: "noreply@example.com",
|
||
FromName: "Test",
|
||
UseTLS: useTLS,
|
||
}
|
||
}
|
||
|
||
// 465 语义:UseTLS=true + 隐式 TLS 服务器,原有路径保持可用。
|
||
func TestSMTPConnectionImplicitTLS(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, true, false)
|
||
svc := &EmailService{}
|
||
|
||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||
t.Fatalf("expected implicit TLS connection to succeed, got: %v", err)
|
||
}
|
||
if !srv.sawCommand("EHLO") {
|
||
t.Fatal("expected server to receive EHLO")
|
||
}
|
||
}
|
||
|
||
// 587 语义(#1470/#1488 核心场景):UseTLS=true + 明文问候的 STARTTLS 服务器,
|
||
// 隐式 TLS 失败后必须自动降级为强制 STARTTLS 并成功。
|
||
func TestSMTPConnectionStartTLSFallbackWhenTLSEnabled(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, false, true)
|
||
svc := &EmailService{}
|
||
|
||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||
t.Fatalf("expected STARTTLS fallback to succeed, got: %v", err)
|
||
}
|
||
if !srv.sawCommand("STARTTLS") {
|
||
t.Fatal("expected server to receive STARTTLS command")
|
||
}
|
||
if got := srv.conns.Load(); got < 2 {
|
||
t.Fatalf("expected implicit TLS attempt before STARTTLS fallback (>=2 connections), got %d", got)
|
||
}
|
||
}
|
||
|
||
// UseTLS=true 但服务器不支持 STARTTLS:必须报错,且绝不能把凭据发到明文连接上。
|
||
func TestSMTPConnectionMandatoryStartTLSRefusesPlaintext(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, false, false)
|
||
svc := &EmailService{}
|
||
|
||
err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true))
|
||
if err == nil {
|
||
t.Fatal("expected error when server does not support STARTTLS")
|
||
}
|
||
if !strings.Contains(err.Error(), "STARTTLS") {
|
||
t.Fatalf("expected STARTTLS-related error, got: %v", err)
|
||
}
|
||
if srv.sawCommand("AUTH") {
|
||
t.Fatal("credentials must not be sent over plaintext when TLS is required")
|
||
}
|
||
}
|
||
|
||
// UseTLS=false + 服务器支持 STARTTLS:测试连接与发送路径一致,机会式升级后认证成功。
|
||
// 这是 #1488 评论"测试连接不成功,发送测试邮件实际上能发"的回归用例。
|
||
func TestSMTPConnectionOpportunisticStartTLSWhenTLSDisabled(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, false, true)
|
||
svc := &EmailService{}
|
||
|
||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||
t.Fatalf("expected opportunistic STARTTLS test connection to succeed, got: %v", err)
|
||
}
|
||
if !srv.sawCommand("STARTTLS") {
|
||
t.Fatal("expected test connection to upgrade via STARTTLS like the send path")
|
||
}
|
||
}
|
||
|
||
// UseTLS=false + 服务器不支持 STARTTLS:保持明文直连(既有行为不回归)。
|
||
func TestSMTPConnectionPlainWhenNoStartTLS(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, false, false)
|
||
svc := &EmailService{}
|
||
|
||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||
t.Fatalf("expected plain connection to succeed, got: %v", err)
|
||
}
|
||
if srv.sawCommand("STARTTLS") {
|
||
t.Fatal("did not expect STARTTLS command when server does not advertise it")
|
||
}
|
||
}
|
||
|
||
// 发送路径全流程:UseTLS=true + STARTTLS 服务器(587 语义)完整走完 MAIL/RCPT/DATA。
|
||
func TestSendEmailWithConfigStartTLSFallback(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, false, true)
|
||
svc := &EmailService{}
|
||
|
||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||
if err != nil {
|
||
t.Fatalf("expected send via STARTTLS fallback to succeed, got: %v", err)
|
||
}
|
||
if !srv.sawCommand("STARTTLS") {
|
||
t.Fatal("expected send path to upgrade via STARTTLS")
|
||
}
|
||
if !srv.sawCommand("DATA") {
|
||
t.Fatal("expected send path to reach DATA")
|
||
}
|
||
}
|
||
|
||
// 发送路径全流程:UseTLS=true + 隐式 TLS 服务器(465 语义)保持既有行为。
|
||
func TestSendEmailWithConfigImplicitTLS(t *testing.T) {
|
||
srv, port := startFakeSMTPServer(t, true, false)
|
||
svc := &EmailService{}
|
||
|
||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||
if err != nil {
|
||
t.Fatalf("expected send via implicit TLS to succeed, got: %v", err)
|
||
}
|
||
if !srv.sawCommand("DATA") {
|
||
t.Fatal("expected send path to reach DATA")
|
||
}
|
||
}
|