Files
worker/internal/sender/email_context_test.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

274 строки
8.4 KiB
Go

package sender
import (
"bufio"
"context"
"crypto/hmac"
"crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"math/big"
"net"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"rocketgit.ru/rsmon/worker/internal/wire"
)
type fakeSMTPServer struct {
listener net.Listener
done chan error
tls *tls.Config
}
func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)}
go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }()
t.Cleanup(func() {
_ = listener.Close()
select {
case err := <-server.done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Error("fake SMTP server did not exit")
}
})
return server
}
func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error {
conn, err := s.listener.Accept()
if err != nil {
return nil
}
defer conn.Close() //nolint:errcheck
if implicitTLS {
conn = tls.Server(conn, s.tls)
if err := conn.(*tls.Conn).Handshake(); err != nil {
return err
}
}
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
if err := smtpReply(writer, "220 fake smtp"); err != nil {
return err
}
line, err := smtpCommand(reader)
if err != nil {
return err
}
if !strings.HasPrefix(line, "EHLO ") {
return errors.New("expected EHLO")
}
if blockEHLO {
_, _ = reader.ReadByte()
return nil
}
capabilities := []string{"250-fake"}
if startTLS {
capabilities = append(capabilities, "250-STARTTLS")
}
if auth != "" {
capabilities = append(capabilities, "250-AUTH "+auth)
}
capabilities = append(capabilities, "250 OK")
if err := smtpReplies(writer, capabilities...); err != nil {
return err
}
if startTLS {
if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" {
return errors.New("expected STARTTLS")
}
if err := smtpReply(writer, "220 ready for TLS"); err != nil {
return err
}
conn = tls.Server(conn, s.tls)
if err := conn.(*tls.Conn).Handshake(); err != nil {
return err
}
reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn)
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") {
return errors.New("expected EHLO after STARTTLS")
}
capabilities = []string{"250-fake"}
if auth != "" {
capabilities = append(capabilities, "250-AUTH "+auth)
}
capabilities = append(capabilities, "250 OK")
if err := smtpReplies(writer, capabilities...); err != nil {
return err
}
}
if err := smtpAuthenticate(reader, writer, auth); err != nil {
return err
}
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:<sender@example.test>") {
return errors.New("expected MAIL FROM")
}
if err := smtpReply(writer, "250 sender ok"); err != nil {
return err
}
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:<recipient@example.test>") {
return errors.New("expected RCPT TO")
}
if err := smtpReply(writer, "250 recipient ok"); err != nil {
return err
}
if line, err = smtpCommand(reader); err != nil || line != "DATA" {
return errors.New("expected DATA")
}
if err := smtpReply(writer, "354 send data"); err != nil {
return err
}
dataLines := 0
for {
line, err = smtpCommand(reader)
if err != nil {
return err
}
if line == "." {
break
}
dataLines++
}
if dataLines == 0 {
return errors.New("expected non-empty DATA payload")
}
if err := smtpReply(writer, "250 queued"); err != nil {
return err
}
line, err = smtpCommand(reader)
if err != nil || line != "QUIT" {
return errors.New("expected QUIT")
}
return smtpReply(writer, "221 bye")
}
func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error {
if auth == "" {
return nil
}
line, err := smtpCommand(reader)
if err != nil {
return err
}
if strings.Contains(auth, "CRAM-MD5") {
if line != "AUTH CRAM-MD5" {
return errors.New("expected capability-selected CRAM-MD5 authentication")
}
challenge := []byte("fake-cram-challenge")
if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil {
return err
}
response, err := smtpCommand(reader)
if err != nil {
return err
}
decoded, err := base64.StdEncoding.DecodeString(response)
if err != nil {
return err
}
mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
_, _ = mac.Write(challenge)
if string(decoded) != "user "+fmtHex(mac.Sum(nil)) {
return errors.New("invalid CRAM-MD5 response")
}
return smtpReply(writer, "235 authenticated")
}
if line != "AUTH LOGIN" {
return errors.New("expected capability-selected LOGIN authentication")
}
if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil {
return err
}
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) {
return errors.New("invalid LOGIN username")
}
if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil {
return err
}
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) {
return errors.New("invalid LOGIN password")
}
return smtpReply(writer, "235 authenticated")
}
func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) {
server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN")
cred := fakeSMTPCredential(t, server.listener.Addr().String())
require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred, true))
}
func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) {
server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN")
cred := fakeSMTPCredential(t, server.listener.Addr().String())
require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred))
}
func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) {
server := newFakeSMTPServer(t, false, false, true, "")
cred := fakeSMTPCredential(t, server.listener.Addr().String())
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "<b>body</b>", cred)
require.ErrorIs(t, err, context.DeadlineExceeded)
}
func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential {
t.Helper()
host, port, err := net.SplitHostPort(address)
require.NoError(t, err)
portNumber, err := net.LookupPort("tcp", port)
require.NoError(t, err)
return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true}
}
func fakeSMTPTLSConfig(t *testing.T) *tls.Config {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, 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)}))
require.NoError(t, err)
return &tls.Config{Certificates: []tls.Certificate{certificate}}
}
func smtpCommand(reader *bufio.Reader) (string, error) {
line, err := reader.ReadString('\n')
return strings.TrimRight(line, "\r\n"), err
}
func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) }
func smtpReplies(writer *bufio.Writer, lines ...string) error {
for _, line := range lines {
if _, err := writer.WriteString(line + "\r\n"); err != nil {
return err
}
}
return writer.Flush()
}
func fmtHex(value []byte) string {
const hex = "0123456789abcdef"
result := make([]byte, len(value)*2)
for i, b := range value {
result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f]
}
return string(result)
}