Skip to content

Commit

Permalink
fix: fix validation for email configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
soerenschneider committed Sep 18, 2024
1 parent fd85a3a commit 92a43ee
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 24 deletions.
87 changes: 65 additions & 22 deletions internal/conf/notifications_email.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
package conf

import (
"errors"
"fmt"
"os"
"strings"

"github.com/go-playground/validator/v10"
)

type EmailConfig struct {
From string `yaml:"from" env:"EMAIL_FROM" validate:"required_with=SmtpHost,omitempty,required_without=FromFile"`
FromFile string `yaml:"from_file" env:"EMAIL_FROM_FILE" validate:"required_with=SmtpHost,omitempty,required_without=From"`
To []string `yaml:"to" env:"EMAIL_TO" envSeparator:";" validate:"required_with=SmtpHost,omitempty,required_without=ToFile"`
ToFile string `yaml:"to_file" env:"EMAIL_TO" validate:"required_with=SmtpHost,omitempty,required_without=To"`
SmtpHost string `yaml:"host" env:"EMAIL_HOST" validate:"omitempty,required"`
SmtpPort int `yaml:"port" env:"EMAIL_PORT" validate:"omitempty,required"`
SmtpUsername string `yaml:"user" env:"EMAIL_USER" validate:"required_with=SmtpHost,omitempty,required_without=SmtpUsernameFile"`
SmtpUsernameFile string `yaml:"user_file" env:"EMAIL_USER_FILE" validate:"required_with=SmtpHost,omitempty,required_without=smtpUsername"`
SmtpPassword string `yaml:"password" env:"EMAIL_PASSWORD" validate:"required_with=SmtpHost,omitempty,required_without=SmtpPasswordFile"`
SmtpPasswordFile string `yaml:"password_file" env:"EMAIL_PASSWORD_FILE" validate:"required_with=SmtpHost,omitempty,required_without=SmtpPassword"`
From string `yaml:"from" env:"EMAIL_FROM" validate:"omitempty,email"`
FromFile string `yaml:"from_file" env:"EMAIL_FROM_FILE" validate:"omitempty,filepath"`
To []string `yaml:"to" env:"EMAIL_TO" envSeparator:";" validate:"omitempty,dive,email"`
ToFile string `yaml:"to_file" env:"EMAIL_TO" validate:"omitempty,filepath"`
SmtpHost string `yaml:"host" env:"EMAIL_HOST" validate:"omitempty,hostname"`
SmtpPort int `yaml:"port" env:"EMAIL_PORT" validate:"omitempty,gte=25,lte=65535"`
SmtpUsername string `yaml:"user" env:"EMAIL_USER"`
SmtpUsernameFile string `yaml:"user_file" env:"EMAIL_USER_FILE" validate:"omitempty,filepath"`
SmtpPassword string `yaml:"password" env:"EMAIL_PASSWORD"`
SmtpPasswordFile string `yaml:"password_file" env:"EMAIL_PASSWORD_FILE" validate:"omitempty,filepath"`
}

func (e *EmailConfig) String() string {
var sb strings.Builder

sb.WriteString("EmailConfig {")
appendIfNotEmpty(&sb, "From", e.From)
appendIfNotEmpty(&sb, "FromFile", e.FromFile)
if len(e.To) > 0 {
sb.WriteString(fmt.Sprintf(" To: %v,", e.To))
}
appendIfNotEmpty(&sb, "ToFile", e.ToFile)
appendIfNotEmpty(&sb, "SmtpHost", e.SmtpHost)
if e.SmtpPort != 0 {
sb.WriteString(fmt.Sprintf(" SmtpPort: %d,", e.SmtpPort))
}
appendIfNotEmpty(&sb, "SmtpUsername", e.SmtpUsername)
appendIfNotEmpty(&sb, "SmtpUsernameFile", e.SmtpUsernameFile)
// Note: We deliberately exclude SmtpPassword from the output
appendIfNotEmpty(&sb, "SmtpPasswordFile", e.SmtpPasswordFile)
sb.WriteString(" }")

return sb.String()
}

func appendIfNotEmpty(sb *strings.Builder, fieldName, value string) {
if value != "" {
sb.WriteString(fmt.Sprintf(" %s: %s,", fieldName, value))
}
}

func (conf *EmailConfig) IsConfigured() bool {
Expand Down Expand Up @@ -75,20 +106,32 @@ func (conf *EmailConfig) GetPassword() (string, error) {
}

func (conf *EmailConfig) Validate() error {
if len(conf.From) == 0 {
return errors.New("'From' not defined")
return ValidateConfig(conf)
}

func EmailConfigStructLevelValidation(sl validator.StructLevel) {
config := sl.Current().Interface().(EmailConfig)

if config.SmtpPort == 0 && len(config.SmtpHost)+len(config.SmtpUsername)+len(config.SmtpUsernameFile)+len(config.SmtpPasswordFile)+len(config.SmtpPassword)+len(config.From)+len(config.FromFile)+len(config.To)+len(config.ToFile) == 0 {
return
}
if len(conf.To) == 0 {
return errors.New("'To' not defined")

if config.SmtpUsername == "" && config.SmtpUsernameFile == "" {
sl.ReportError(config.SmtpUsername, "SmtpUsername", "SmtpUsername", "usernameOrFileRequired", "")
sl.ReportError(config.SmtpUsernameFile, "SmtpUsernameFile", "SmtpUsernameFile", "usernameOrFileRequired", "")
}
if len(conf.SmtpHost) == 0 {
return errors.New("'SmtpHost' not defined")
if config.SmtpPassword == "" && config.SmtpPasswordFile == "" {
sl.ReportError(config.SmtpPassword, "SmtpPassword", "SmtpPassword", "passwordOrFileRequired", "")
sl.ReportError(config.SmtpPasswordFile, "SmtpPasswordFile", "SmtpPasswordFile", "passwordOrFileRequired", "")
}
if len(conf.SmtpUsername) == 0 {
return errors.New("'smtpUsername' not defined")

if config.From == "" && config.FromFile == "" {
sl.ReportError(config.From, "From", "From", "requiredWithoutFromFile", "")
sl.ReportError(config.FromFile, "FromFile", "FromFile", "requiredWithoutFrom", "")
}
if len(conf.SmtpPassword) == 0 {
return errors.New("'SmtpPassword' not defined")

if len(config.To) == 0 && config.ToFile == "" {
sl.ReportError(config.To, "To", "To", "requiredWithoutToFile", "")
sl.ReportError(config.ToFile, "ToFile", "ToFile", "requiredWithoutTo", "")
}
return nil
}
95 changes: 95 additions & 0 deletions internal/conf/notifications_email_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package conf

import "testing"

func TestEmailConfig_Validate(t *testing.T) {
type fields struct {
From string
FromFile string
To []string
ToFile string
SmtpHost string
SmtpPort int
SmtpUsername string
SmtpUsernameFile string
SmtpPassword string
SmtpPasswordFile string
}
tests := []struct {
name string
fields fields
wantErr bool
}{
{
name: "valid config, nothing defined",
fields: fields{},
wantErr: false,
},
{
name: "valid config, no files",
fields: fields{
From: "dyndns@yourdomain.tld",
To: []string{"you@yourdomain.tld"},
SmtpHost: "localhost",
SmtpPort: 25,
SmtpUsername: "email",
SmtpPassword: "secret",
},
wantErr: false,
},
{
name: "valid config, use files",
fields: fields{
FromFile: "from.txt",
ToFile: "to.txt",
SmtpHost: "localhost",
SmtpPort: 25,
SmtpUsernameFile: "user.txt",
SmtpPasswordFile: "secret.txt",
},
wantErr: false,
},
{
name: "valid config, mixed usage of files and actual values",
fields: fields{
FromFile: "from.txt",
To: []string{"mail@domain.tld"},
SmtpHost: "localhost",
SmtpPort: 25,
SmtpUsername: "user",
SmtpPasswordFile: "secret.txt",
},
wantErr: false,
},
{
name: "invalid config, missing from",
fields: fields{
ToFile: "to.txt",
SmtpHost: "localhost",
SmtpPort: 25,
SmtpUsernameFile: "user.txt",
SmtpPasswordFile: "secret.txt",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := &EmailConfig{
From: tt.fields.From,
FromFile: tt.fields.FromFile,
To: tt.fields.To,
ToFile: tt.fields.ToFile,
SmtpHost: tt.fields.SmtpHost,
SmtpPort: tt.fields.SmtpPort,
SmtpUsername: tt.fields.SmtpUsername,
SmtpUsernameFile: tt.fields.SmtpUsernameFile,
SmtpPassword: tt.fields.SmtpPassword,
SmtpPasswordFile: tt.fields.SmtpPasswordFile,
}
if err := conf.Validate(); (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
21 changes: 19 additions & 2 deletions internal/conf/pretty_printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ package conf
import (
"fmt"
"reflect"
"slices"
"strings"

"github.com/rs/zerolog/log"
)

var SensitiveFields = []string{"KeyPair"}
var SensitiveFields = []string{"KeyPair", "SmtpPassword"}

func PrintFields(data any, ignoredKeys ...string) {
v := reflect.ValueOf(data)
Expand All @@ -24,7 +25,7 @@ func PrintFields(data any, ignoredKeys ...string) {
continue
}

if sliceContains(ignoredKeys, field.Name) {
if slices.Contains(ignoredKeys, field.Name) {
log.Info().Msgf("%s=%s", field.Name, "*** (redacted)")
} else {
log.Info().Msgf("%s=%s", field.Name, fieldValueToString(field.Name, value))
Expand All @@ -45,9 +46,25 @@ func sliceContains(slice []string, val string) bool {

func fieldValueToString(nam string, value reflect.Value) string {
if value.CanInterface() {
if value.Kind() == reflect.Ptr {
// Handle the case where value is a pointer
if value.IsNil() {
return "<nil>"
}
value = value.Elem()
}

if stringer, ok := value.Interface().(fmt.Stringer); ok {
return stringer.String()
}

// Check if the address of the struct implements fmt.Stringer
if value.Kind() == reflect.Struct {
ptrValue := value.Addr()
if stringer, ok := ptrValue.Interface().(fmt.Stringer); ok {
return stringer.String()
}
}
}
return fmt.Sprintf("%v", value.Interface())
}
Expand Down
2 changes: 2 additions & 0 deletions internal/conf/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ func ValidateConfig[T any](c T) error {
if err := validate.RegisterValidation("broker", validateBrokers); err != nil {
log.Fatal().Err(err).Msg("could not build custom validation 'validateBrokers'")
}

validate.RegisterStructValidation(EmailConfigStructLevelValidation, EmailConfig{})
})

return validate.Struct(c)
Expand Down

0 comments on commit 92a43ee

Please sign in to comment.