Scert/backend/internal/notification/smtp_test.go
xuans 07229adadc 收敛Smail系统通知SMTP通道并更新验收清单
完成Smail系统通知SMTP路由、验证码发送约束和相关回归测试,更新Linux生产测试记录、整改主清单及任务执行清单。
2026-10-01 05:57:07 +08:00

435 lines
14 KiB
Go

package notification
import (
"bufio"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type smtpTestServer struct {
listener net.Listener
tlsConfig *tls.Config
implicitTLS bool
failMailOnce bool
rejectAuth bool
mu sync.Mutex
messages [][]byte
mailFrom string
connections int
}
func newSMTPTestServer(t *testing.T, implicitTLS, startTLS bool) *smtpTestServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("create SMTP test listener: %v", err)
}
server := &smtpTestServer{listener: listener, implicitTLS: implicitTLS}
if implicitTLS || startTLS {
server.tlsConfig = newSMTPTestTLSConfig(t)
}
go server.serve(startTLS)
t.Cleanup(func() { _ = listener.Close() })
return server
}
func (s *smtpTestServer) address() (string, int) {
address := s.listener.Addr().(*net.TCPAddr)
return address.IP.String(), address.Port
}
func (s *smtpTestServer) serve(startTLS bool) {
for {
connection, err := s.listener.Accept()
if err != nil {
return
}
s.mu.Lock()
s.connections++
s.mu.Unlock()
go s.handle(connection, startTLS)
}
}
func (s *smtpTestServer) handle(connection net.Conn, startTLS bool) {
defer connection.Close()
if s.implicitTLS {
tlsConnection := tls.Server(connection, s.tlsConfig)
if err := tlsConnection.Handshake(); err != nil {
return
}
connection = tlsConnection
}
reader := bufio.NewReader(connection)
writeLine := func(value string) error {
_, err := io.WriteString(connection, value+"\r\n")
return err
}
if writeLine("220 smtp.test.local ESMTP") != nil {
return
}
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
command := strings.TrimSpace(line)
upper := strings.ToUpper(command)
switch {
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
if writeLine("250-smtp.test.local") != nil {
return
}
if startTLS && writeLine("250-STARTTLS") != nil {
return
}
if writeLine("250 AUTH PLAIN") != nil {
return
}
case strings.HasPrefix(upper, "STARTTLS"):
if !startTLS || writeLine("220 ready for TLS") != nil {
return
}
tlsConnection := tls.Server(connection, s.tlsConfig)
if err := tlsConnection.Handshake(); err != nil {
return
}
connection = tlsConnection
reader = bufio.NewReader(connection)
case strings.HasPrefix(upper, "AUTH PLAIN"):
if s.rejectAuth {
if writeLine("535 authentication failed") != nil {
return
}
continue
}
if writeLine("235 authentication successful") != nil {
return
}
case strings.HasPrefix(upper, "MAIL FROM:"):
s.mu.Lock()
s.mailFrom = command
fail := s.failMailOnce
s.failMailOnce = false
s.mu.Unlock()
if fail {
if writeLine("451 temporary failure") != nil {
return
}
return
}
if writeLine("250 sender accepted") != nil {
return
}
case strings.HasPrefix(upper, "RCPT TO:"):
if writeLine("250 recipient accepted") != nil {
return
}
case upper == "DATA":
if writeLine("354 end with <CRLF>.<CRLF>") != nil {
return
}
var message strings.Builder
for {
dataLine, readErr := reader.ReadString('\n')
if readErr != nil {
return
}
if dataLine == ".\r\n" {
break
}
message.WriteString(dataLine)
}
s.mu.Lock()
s.messages = append(s.messages, []byte(message.String()))
s.mu.Unlock()
if writeLine("250 queued") != nil {
return
}
case upper == "QUIT":
_ = writeLine("221 closing connection")
return
default:
if writeLine("250 ok") != nil {
return
}
}
}
}
func (s *smtpTestServer) clientTLSConfig(host string) *tls.Config {
s.mu.Lock()
defer s.mu.Unlock()
pool := x509.NewCertPool()
if len(s.tlsConfig.Certificates) == 0 || len(s.tlsConfig.Certificates[0].Certificate) == 0 {
return &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
}
pool.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: s.tlsConfig.Certificates[0].Certificate[0]}))
return &tls.Config{ServerName: host, RootCAs: pool, MinVersion: tls.VersionTLS12}
}
func (s *smtpTestServer) message() []byte {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.messages) == 0 {
return nil
}
return append([]byte(nil), s.messages[len(s.messages)-1]...)
}
func newSMTPTestTLSConfig(t *testing.T) *tls.Config {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate SMTP test key: %v", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
if err != nil {
t.Fatalf("generate SMTP test serial: %v", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "127.0.0.1"},
DNSNames: []string{"smtp.test.local"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("generate SMTP test certificate: %v", err)
}
certificate, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}),
)
if err != nil {
t.Fatalf("load SMTP test certificate: %v", err)
}
return &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
}
func smtpTestConfig(host string, port int, mode string) config.NotificationConfig {
return config.NotificationConfig{
SMTPEnabled: true,
SMTPHost: host,
SMTPPort: port,
SMTPUsername: "user",
SMTPCredentialRef: "secret://notification/smtp/test",
SMTPMode: mode,
TimeoutSecs: 2,
RetryCount: 0,
SenderName: "Scert",
SenderEmail: "sender@example.com",
Recipients: []string{"receiver@example.com"},
}
}
func TestSMTPRejectsPlainAuthenticationByDefault(t *testing.T) {
server := newSMTPTestServer(t, false, false)
host, port := server.address()
service := New(smtpTestConfig(host, port, "plain"), credentialReaderStub{values: map[string]string{
"username": "user",
"password": "test-password",
}})
err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"})
if err == nil || !strings.Contains(err.Error(), "\u660e\u6587\u8fde\u63a5\u7981\u6b62\u8ba4\u8bc1") {
t.Fatalf("plain SMTP authentication should be rejected: %v", err)
}
if strings.Contains(err.Error(), "test-password") {
t.Fatalf("SMTP errors must not expose password: %v", err)
}
}
func TestSMTPSendsUTF8WithAllowedPlainAuthentication(t *testing.T) {
server := newSMTPTestServer(t, false, false)
host, port := server.address()
cfg := smtpTestConfig(host, port, "plain")
cfg.SMTPAllowPlaintextAuth = true
service := New(cfg, credentialReaderStub{values: map[string]string{
"username": "user",
"password": "test-password",
}})
if err := service.Send(context.Background(), Event{Type: "certificate_renewal", Status: "success", Domain: "example.test"}); err != nil {
t.Fatalf("send UTF-8 SMTP notification: %v", err)
}
message := string(server.message())
if !strings.Contains(message, "Subject: =?UTF-8?") || !strings.Contains(message, "example.test") {
t.Fatalf("SMTP message was not generated correctly: %q", message)
}
}
func TestSmailSystemSenderUsesSameEnvelopeAndHeader(t *testing.T) {
for _, security := range []bool{false, true} {
t.Run(map[bool]string{false: "notification", true: "security_code"}[security], func(t *testing.T) {
server := newSMTPTestServer(t, false, true)
host, port := server.address()
cfg := smtpTestConfig(host, port, "starttls")
cfg.SMTPUsername = "notify:scert@example.test"
cfg.SenderEmail = "admin@other.test"
service := New(cfg, credentialReaderStub{values: map[string]string{"password": "test-only-notification-credential"}})
service.smtpTLSConfig = server.clientTLSConfig
var err error
if security {
err = service.SendSecurityCode(t.Context(), "bound@example.test", "01234567")
} else {
err = service.Send(t.Context(), Event{Type: "certificate_issue", Status: "success", Domain: "example.test"})
}
if err != nil {
t.Fatal(err)
}
server.mu.Lock()
envelope := server.mailFrom
server.mu.Unlock()
if !strings.HasPrefix(envelope, "MAIL FROM:<notification+system@example.test>") {
t.Fatal("SMTP 信封没有使用系统通知地址")
}
message := string(server.message())
if !strings.Contains(message, formatSMTPAddress(address{Name: "系统通知", Email: "notification+system@example.test"})) || strings.Contains(message, "admin@other.test") {
t.Fatal("邮件头没有使用固定系统通知身份,或泄漏旧管理员发件地址")
}
if security && strings.Contains(message, "receiver@example.com") {
t.Fatal("验证码不得发送给普通通知收件人")
}
})
}
}
func TestSmailSystemNotificationUsesSMTPOnly(t *testing.T) {
server := newSMTPTestServer(t, true, false)
host, port := server.address()
calls := 0
endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadGateway)
}))
defer endpoint.Close()
cfg := smtpTestConfig(host, port, "implicit_tls")
cfg.SMTPUsername = "notify:scert@example.test"
cfg.HTTPEnabled = true
cfg.Endpoint = endpoint.URL
cfg.HTTPPath = "/api/email/send"
service := New(cfg, credentialReaderStub{values: map[string]string{"password": "test-only-notification-credential"}})
service.smtpTLSConfig = server.clientTLSConfig
if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.test"}); err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatalf("Smail 系统通知不应调用 HTTP 通道,调用次数:%d", calls)
}
server.mu.Lock()
defer server.mu.Unlock()
if len(server.messages) != 1 {
t.Fatalf("SMTP 系统通知数量错误:%d", len(server.messages))
}
}
func TestSMTPStartTLS(t *testing.T) {
server := newSMTPTestServer(t, false, true)
host, port := server.address()
service := New(smtpTestConfig(host, port, "starttls"), credentialReaderStub{values: map[string]string{
"username": "user",
"password": "test-password",
}})
service.smtpTLSConfig = server.clientTLSConfig
if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"}); err != nil {
t.Fatalf("send SMTP STARTTLS notification: %v", err)
}
}
func TestSMTPImplicitTLS(t *testing.T) {
server := newSMTPTestServer(t, true, false)
host, port := server.address()
service := New(smtpTestConfig(host, port, "implicit_tls"), credentialReaderStub{values: map[string]string{
"username": "user",
"password": "test-password",
}})
service.smtpTLSConfig = server.clientTLSConfig
if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"}); err != nil {
t.Fatalf("send SMTP implicit TLS notification: %v", err)
}
}
func TestSMTPRetriesTemporaryFailure(t *testing.T) {
server := newSMTPTestServer(t, false, false)
server.failMailOnce = true
host, port := server.address()
cfg := smtpTestConfig(host, port, "plain")
cfg.SMTPAllowPlaintextAuth = true
cfg.RetryCount = 1
cfg.RetryIntervalsSecs = []int{0}
service := New(cfg, credentialReaderStub{values: map[string]string{
"username": "user",
"password": "test-password",
}})
service.sleep = func(context.Context, time.Duration) error { return nil }
if err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"}); err != nil {
t.Fatalf("SMTP retry should recover from temporary failure: %v", err)
}
server.mu.Lock()
connections := server.connections
messages := len(server.messages)
server.mu.Unlock()
if connections < 2 || messages != 1 {
t.Fatalf("SMTP retry result is incorrect: connections=%d messages=%d", connections, messages)
}
}
func TestSMTPAuthFailureDoesNotLeakPassword(t *testing.T) {
server := newSMTPTestServer(t, false, true)
server.rejectAuth = true
host, port := server.address()
service := New(smtpTestConfig(host, port, "starttls"), credentialReaderStub{values: map[string]string{
"username": "user",
"password": "wrong-password",
}})
service.smtpTLSConfig = server.clientTLSConfig
err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"})
if err == nil || !strings.Contains(err.Error(), "\u8ba4\u8bc1\u5931\u8d25") {
t.Fatalf("SMTP authentication failure should be reported: %v", err)
}
if strings.Contains(err.Error(), "wrong-password") {
t.Fatalf("SMTP authentication errors must not expose password: %v", err)
}
}
func TestSMTPRejectsMissingHost(t *testing.T) {
cfg := smtpTestConfig("", 587, "starttls")
service := New(cfg, nil)
err := service.Send(context.Background(), Event{Type: "certificate_issue", Status: "success", Domain: "example.com"})
if err == nil || !strings.Contains(err.Error(), "SMTP \u4e3b\u673a\u4e0d\u80fd\u4e3a\u7a7a") {
t.Fatalf("missing SMTP host should return a clear error: %v", err)
}
}
func TestSMTPAddressFormatting(t *testing.T) {
value := formatSMTPAddress(address{Name: "\u7ba1\u7406\u670d\u52a1", Email: "admin@example.com"})
if !strings.Contains(value, "admin@example.com") || !strings.Contains(value, "=?UTF-8?") {
t.Fatalf("SMTP address encoding is incorrect: %s", value)
}
}