mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
bddb808b25
Fixes all our Go file imports to match the preferred spec that we've _mostly_ been using. For example: ``` import ( "context" "time" "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" "gopkg.in/natefinch/lumberjack.v2" "cdr.dev/slog/v3" "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/serpent" ) ``` 3 groups: standard library, 3rd partly libs, Coder libs. This PR makes the change across the codebase. The PR in the stack above modifies our formatting to maintain this state of affairs, and is a separate PR so it's possible to review that one in detail.
518 lines
13 KiB
Go
518 lines
13 KiB
Go
package dispatch_test
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/emersion/go-sasl"
|
|
"github.com/emersion/go-smtp"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"cdr.dev/slog/v3/sloggers/slogtest"
|
|
"github.com/coder/coder/v2/coderd/notifications/dispatch"
|
|
"github.com/coder/coder/v2/coderd/notifications/dispatch/smtptest"
|
|
"github.com/coder/coder/v2/coderd/notifications/types"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
|
}
|
|
|
|
func TestSMTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
username = "bob"
|
|
password = "🤫"
|
|
|
|
hello = "localhost"
|
|
|
|
identity = "robert"
|
|
from = "system@coder.com"
|
|
to = "bob@bob.com"
|
|
|
|
subject = "This is the subject"
|
|
body = "This is the body"
|
|
|
|
caFile = "smtptest/fixtures/ca.crt"
|
|
certFile = "smtptest/fixtures/server.crt"
|
|
keyFile = "smtptest/fixtures/server.key"
|
|
)
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug)
|
|
tests := []struct {
|
|
name string
|
|
cfg codersdk.NotificationsEmailConfig
|
|
toAddrs []string
|
|
authMechs []string
|
|
expectedAuthMeth string
|
|
expectedErr string
|
|
retryable bool
|
|
useTLS bool
|
|
failOnDataFn func() error
|
|
}{
|
|
/**
|
|
* LOGIN auth mechanism
|
|
*/
|
|
{
|
|
name: "LOGIN auth",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
},
|
|
{
|
|
name: "invalid LOGIN auth user",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username + "-wrong",
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
expectedErr: "unknown user",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "invalid LOGIN auth credentials",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password + "-wrong",
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
expectedErr: "incorrect password",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "password from file",
|
|
authMechs: []string{sasl.Login},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
PasswordFile: "smtptest/fixtures/password.txt",
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Login,
|
|
},
|
|
/**
|
|
* PLAIN auth mechanism
|
|
*/
|
|
{
|
|
name: "PLAIN auth",
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
{
|
|
name: "PLAIN auth without identity",
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: "",
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
{
|
|
name: "PLAIN+LOGIN, choose PLAIN",
|
|
authMechs: []string{sasl.Login, sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
/**
|
|
* No auth mechanism
|
|
*/
|
|
{
|
|
name: "No auth mechanisms supported",
|
|
authMechs: []string{},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
expectedErr: "no authentication mechanisms supported by server",
|
|
retryable: false,
|
|
},
|
|
{
|
|
name: "No auth mechanisms supported, none configured",
|
|
authMechs: []string{},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
},
|
|
{
|
|
name: "Auth mechanisms supported optionally, none configured",
|
|
authMechs: []string{sasl.Login, sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: "",
|
|
},
|
|
/**
|
|
* TLS connections
|
|
*/
|
|
{
|
|
// TLS is forced but certificate used by mock server is untrusted.
|
|
name: "TLS: x509 untrusted",
|
|
useTLS: true,
|
|
expectedErr: "tls: failed to verify certificate",
|
|
retryable: true,
|
|
},
|
|
{
|
|
// TLS is forced and self-signed certificate used by mock server is not verified.
|
|
name: "TLS: x509 untrusted ignored",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
ForceTLS: true,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS is forced and STARTTLS is configured, but STARTTLS cannot be used by TLS connections.
|
|
// STARTTLS should be disabled and connection should succeed.
|
|
name: "TLS: STARTTLS is ignored",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
StartTLS: true,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// Plain connection is established and upgraded via STARTTLS, but certificate is untrusted.
|
|
name: "TLS: STARTTLS untrusted",
|
|
useTLS: false,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: false,
|
|
StartTLS: true,
|
|
},
|
|
ForceTLS: false,
|
|
},
|
|
expectedErr: "tls: failed to verify certificate",
|
|
retryable: true,
|
|
},
|
|
{
|
|
// Plain connection is established and upgraded via STARTTLS, certificate is not verified.
|
|
name: "TLS: STARTTLS",
|
|
useTLS: false,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
InsecureSkipVerify: true,
|
|
StartTLS: true,
|
|
},
|
|
ForceTLS: false,
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS connection using self-signed certificate.
|
|
name: "TLS: self-signed",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
// TLS connection using self-signed certificate & specifying the DNS name configured in the certificate.
|
|
name: "TLS: self-signed + SNI",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
ServerName: "myserver.local",
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
},
|
|
{
|
|
name: "TLS: load CA",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: "nope.crt",
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open nope.crt:",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "TLS: load cert",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: "smtptest/fixtures/nope.cert",
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open smtptest/fixtures/nope.cert:",
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "TLS: load cert key",
|
|
useTLS: true,
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: "smtptest/fixtures/nope.key",
|
|
},
|
|
},
|
|
// not using full error message here since it differs on *nix and Windows:
|
|
// *nix: no such file or directory
|
|
// Windows: The system cannot find the file specified.
|
|
expectedErr: "open smtptest/fixtures/nope.key:",
|
|
retryable: true,
|
|
},
|
|
/**
|
|
* Kitchen sink
|
|
*/
|
|
{
|
|
name: "PLAIN auth and TLS",
|
|
useTLS: true,
|
|
authMechs: []string{sasl.Plain},
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
Auth: codersdk.NotificationsEmailAuthConfig{
|
|
Identity: identity,
|
|
Username: username,
|
|
Password: password,
|
|
},
|
|
TLS: codersdk.NotificationsEmailTLSConfig{
|
|
CAFile: caFile,
|
|
CertFile: certFile,
|
|
KeyFile: keyFile,
|
|
},
|
|
},
|
|
toAddrs: []string{to},
|
|
expectedAuthMeth: sasl.Plain,
|
|
},
|
|
/**
|
|
* Other errors
|
|
*/
|
|
{
|
|
name: "Rejected on DATA",
|
|
cfg: codersdk.NotificationsEmailConfig{
|
|
Hello: hello,
|
|
From: from,
|
|
},
|
|
failOnDataFn: func() error {
|
|
return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"}
|
|
},
|
|
expectedErr: "SMTP error 501: Rejected!",
|
|
retryable: true,
|
|
},
|
|
}
|
|
|
|
// nolint:paralleltest // Reinitialization is not required as of Go v1.22.
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
tc.cfg.ForceTLS = serpent.Bool(tc.useTLS)
|
|
|
|
backend := smtptest.NewBackend(smtptest.Config{
|
|
AuthMechanisms: tc.authMechs,
|
|
|
|
AcceptedIdentity: tc.cfg.Auth.Identity.String(),
|
|
AcceptedUsername: username,
|
|
AcceptedPassword: password,
|
|
|
|
FailOnDataFn: tc.failOnDataFn,
|
|
})
|
|
|
|
// Create a mock SMTP server which conditionally listens for plain or TLS connections.
|
|
srv, listen, err := smtptest.CreateMockSMTPServer(backend, tc.useTLS)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
// We expect that the server has already been closed in the test
|
|
assert.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed)
|
|
})
|
|
|
|
errs := bytes.NewBuffer(nil)
|
|
srv.ErrorLog = log.New(errs, "oops", 0)
|
|
// Enable this to debug mock SMTP server.
|
|
// srv.Debug = os.Stderr
|
|
|
|
var hp serpent.HostPort
|
|
require.NoError(t, hp.Set(listen.Addr().String()))
|
|
tc.cfg.Smarthost = serpent.String(hp.String())
|
|
|
|
handler := dispatch.NewSMTPHandler(tc.cfg, logger.Named("smtp"))
|
|
|
|
// Start mock SMTP server in the background.
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
assert.NoError(t, srv.Serve(listen))
|
|
}()
|
|
|
|
// Wait for the server to become pingable.
|
|
require.Eventually(t, func() bool {
|
|
cl, err := smtptest.PingClient(listen, tc.useTLS, tc.cfg.TLS.StartTLS.Value())
|
|
if err != nil {
|
|
t.Logf("smtp not yet dialable: %s", err)
|
|
return false
|
|
}
|
|
|
|
if err = cl.Noop(); err != nil {
|
|
t.Logf("smtp not yet noopable: %s", err)
|
|
return false
|
|
}
|
|
|
|
if err = cl.Close(); err != nil {
|
|
t.Logf("smtp didn't close properly: %s", err)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}, testutil.WaitShort, testutil.IntervalFast)
|
|
|
|
// Build a fake payload.
|
|
payload := types.MessagePayload{
|
|
Version: "1.0",
|
|
UserEmail: to,
|
|
Labels: make(map[string]string),
|
|
}
|
|
|
|
dispatchFn, err := handler.Dispatcher(payload, subject, body, helpers())
|
|
require.NoError(t, err)
|
|
|
|
msgID := uuid.New()
|
|
retryable, err := dispatchFn(ctx, msgID)
|
|
|
|
if tc.expectedErr == "" {
|
|
require.Nil(t, err)
|
|
require.Empty(t, errs.Bytes())
|
|
|
|
msg := backend.LastMessage()
|
|
require.NotNil(t, msg)
|
|
backend.Reset()
|
|
|
|
require.Equal(t, tc.expectedAuthMeth, msg.AuthMech)
|
|
require.Equal(t, from, msg.From)
|
|
require.Equal(t, tc.toAddrs, msg.To)
|
|
if !tc.cfg.Auth.Empty() {
|
|
require.Equal(t, tc.cfg.Auth.Identity.String(), msg.Identity)
|
|
require.Equal(t, username, msg.Username)
|
|
require.Equal(t, password, msg.Password)
|
|
}
|
|
require.Contains(t, msg.Contents, subject)
|
|
require.Contains(t, msg.Contents, body)
|
|
require.Contains(t, msg.Contents, fmt.Sprintf("Message-Id: %s", msgID))
|
|
} else {
|
|
require.ErrorContains(t, err, tc.expectedErr)
|
|
}
|
|
|
|
require.Equal(t, tc.retryable, retryable)
|
|
|
|
require.NoError(t, srv.Shutdown(ctx))
|
|
wg.Wait()
|
|
})
|
|
}
|
|
}
|