241 lines
7.5 KiB
Go
241 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
|
|
"github.com/aws/aws-sdk-go-v2/service/sesv2"
|
|
sestypes "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
|
|
"github.com/aws/smithy-go"
|
|
smithymiddleware "github.com/aws/smithy-go/middleware"
|
|
|
|
"wucher/internal/config"
|
|
"wucher/internal/queue"
|
|
)
|
|
|
|
type mockSESAPI struct {
|
|
input *sesv2.SendEmailInput
|
|
output *sesv2.SendEmailOutput
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (m *mockSESAPI) SendEmail(_ context.Context, params *sesv2.SendEmailInput, _ ...func(*sesv2.Options)) (*sesv2.SendEmailOutput, error) {
|
|
m.calls++
|
|
m.input = params
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
if m.output != nil {
|
|
return m.output, nil
|
|
}
|
|
return &sesv2.SendEmailOutput{}, nil
|
|
}
|
|
|
|
func TestSESSenderSendSimple(t *testing.T) {
|
|
metadata := smithymiddleware.Metadata{}
|
|
awsmiddleware.SetRequestIDMetadata(&metadata, "req-123")
|
|
api := &mockSESAPI{
|
|
output: &sesv2.SendEmailOutput{
|
|
MessageId: strPtr("ses-msg-1"),
|
|
ResultMetadata: metadata,
|
|
},
|
|
}
|
|
sender := NewSESSenderWithAPI(config.SESConfig{
|
|
Region: "ap-southeast-1",
|
|
From: "Wucher <no-reply@example.com>",
|
|
ConfigurationSetName: "auth-events",
|
|
}, api)
|
|
|
|
result, err := sender.Send(context.Background(), queue.EmailJob{
|
|
MessageType: "password_reset",
|
|
To: "user@example.com",
|
|
CC: []string{"cc@example.com"},
|
|
BCC: []string{"bcc@example.com"},
|
|
ReplyTo: []string{"reply@example.com"},
|
|
Subject: "Reset Password",
|
|
TextBody: "plain body",
|
|
HTMLBody: "<p>html body</p>",
|
|
Tags: map[string]string{
|
|
"message_type": "password_reset",
|
|
"template": "auth_password_reset",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if result.Provider != "ses" || result.ProviderMessageID != "ses-msg-1" || result.RequestID != "req-123" {
|
|
t.Fatalf("unexpected result: %+v", result)
|
|
}
|
|
if api.calls != 1 || api.input == nil {
|
|
t.Fatalf("expected SES SendEmail to be called once")
|
|
}
|
|
if got := *api.input.ConfigurationSetName; got != "auth-events" {
|
|
t.Fatalf("unexpected configuration set: %q", got)
|
|
}
|
|
if got := *api.input.FromEmailAddress; got != "\"Wucher\" <no-reply@example.com>" {
|
|
t.Fatalf("unexpected from address: %q", got)
|
|
}
|
|
if got := api.input.Destination.ToAddresses; len(got) != 1 || got[0] != "user@example.com" {
|
|
t.Fatalf("unexpected to addresses: %#v", got)
|
|
}
|
|
if got := api.input.Destination.CcAddresses; len(got) != 1 || got[0] != "cc@example.com" {
|
|
t.Fatalf("unexpected cc addresses: %#v", got)
|
|
}
|
|
if got := api.input.Destination.BccAddresses; len(got) != 1 || got[0] != "bcc@example.com" {
|
|
t.Fatalf("unexpected bcc addresses: %#v", got)
|
|
}
|
|
if got := api.input.ReplyToAddresses; len(got) != 1 || got[0] != "reply@example.com" {
|
|
t.Fatalf("unexpected reply-to addresses: %#v", got)
|
|
}
|
|
if api.input.Content == nil || api.input.Content.Simple == nil {
|
|
t.Fatalf("expected simple SES content")
|
|
}
|
|
if got := *api.input.Content.Simple.Subject.Data; got != "Reset Password" {
|
|
t.Fatalf("unexpected subject: %q", got)
|
|
}
|
|
if got := *api.input.Content.Simple.Body.Text.Data; got != "plain body" {
|
|
t.Fatalf("unexpected text body: %q", got)
|
|
}
|
|
if got := *api.input.Content.Simple.Body.Html.Data; got != "<p>html body</p>" {
|
|
t.Fatalf("unexpected html body: %q", got)
|
|
}
|
|
if len(api.input.EmailTags) != 2 {
|
|
t.Fatalf("expected tags to be forwarded, got %#v", api.input.EmailTags)
|
|
}
|
|
}
|
|
|
|
func TestSESSenderSendTemplate(t *testing.T) {
|
|
api := &mockSESAPI{}
|
|
sender := NewSESSenderWithAPI(config.SESConfig{
|
|
Region: "ap-southeast-1",
|
|
From: "no-reply@example.com",
|
|
}, api)
|
|
|
|
_, err := sender.Send(context.Background(), queue.EmailJob{
|
|
MessageType: "invite_set_password",
|
|
To: "user@example.com",
|
|
TemplateName: "auth.invite_set_password",
|
|
TemplateData: map[string]any{"name": "John"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if api.input == nil || api.input.Content == nil || api.input.Content.Template == nil {
|
|
t.Fatalf("expected templated SES content")
|
|
}
|
|
if got := *api.input.Content.Template.TemplateName; got != "auth.invite_set_password" {
|
|
t.Fatalf("unexpected template name: %q", got)
|
|
}
|
|
if got := *api.input.Content.Template.TemplateData; got != "{\"name\":\"John\"}" {
|
|
t.Fatalf("unexpected template data: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestSESSenderSendInlineAttachmentUsesRawMIME(t *testing.T) {
|
|
api := &mockSESAPI{}
|
|
sender := NewSESSenderWithAPI(config.SESConfig{
|
|
Region: "ap-southeast-1",
|
|
From: "no-reply@example.com",
|
|
}, api)
|
|
|
|
_, err := sender.Send(context.Background(), queue.EmailJob{
|
|
MessageType: "security_pin_reset",
|
|
To: "user@example.com",
|
|
Subject: "Reset Security PIN",
|
|
TextBody: "plain body",
|
|
HTMLBody: `<img src="cid:branding-email-logo"><p>html body</p>`,
|
|
Attachments: []queue.EmailAttachment{{
|
|
Filename: "email-logo.png",
|
|
ContentType: "image/png",
|
|
ContentID: "branding-email-logo",
|
|
Content: []byte("png"),
|
|
Inline: true,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
if api.input == nil || api.input.Content == nil || api.input.Content.Raw == nil {
|
|
t.Fatalf("expected raw SES content")
|
|
}
|
|
|
|
raw := string(api.input.Content.Raw.Data)
|
|
if !strings.Contains(raw, `Content-ID: <branding-email-logo>`) {
|
|
t.Fatalf("expected inline content id in raw MIME: %q", raw)
|
|
}
|
|
if !strings.Contains(raw, `Content-Disposition: inline; filename="email-logo.png"`) {
|
|
t.Fatalf("expected inline content disposition in raw MIME: %q", raw)
|
|
}
|
|
if !strings.Contains(raw, `cid:branding-email-logo`) {
|
|
t.Fatalf("expected html body cid reference in raw MIME: %q", raw)
|
|
}
|
|
}
|
|
|
|
func TestSESSenderSendValidationError(t *testing.T) {
|
|
sender := NewSESSenderWithAPI(config.SESConfig{
|
|
Region: "ap-southeast-1",
|
|
From: "no-reply@example.com",
|
|
}, &mockSESAPI{})
|
|
|
|
if _, err := sender.Send(context.Background(), queue.EmailJob{
|
|
To: "not-an-email",
|
|
Subject: "Hello",
|
|
TextBody: "Body",
|
|
}); err == nil {
|
|
t.Fatalf("expected invalid recipient error")
|
|
}
|
|
|
|
invalidCfgSender := NewSESSenderWithAPI(config.SESConfig{}, &mockSESAPI{})
|
|
if _, err := invalidCfgSender.Send(context.Background(), queue.EmailJob{
|
|
To: "user@example.com",
|
|
Subject: "Hello",
|
|
TextBody: "Body",
|
|
}); err == nil {
|
|
t.Fatalf("expected missing SES config error")
|
|
}
|
|
}
|
|
|
|
func TestClassifyDeliveryError(t *testing.T) {
|
|
t.Run("throttling is transient", func(t *testing.T) {
|
|
err := classifyDeliveryError(sesSenderAPIError{code: "TooManyRequestsException", fault: smithy.FaultClient})
|
|
if !queue.IsTransient(err) {
|
|
t.Fatalf("expected transient classification, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("message rejected is permanent", func(t *testing.T) {
|
|
err := classifyDeliveryError(sesSenderAPIError{code: "MessageRejected", fault: smithy.FaultClient})
|
|
if !queue.IsPermanent(err) {
|
|
t.Fatalf("expected permanent classification, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("validation error is permanent", func(t *testing.T) {
|
|
err := classifyDeliveryError(errors.New("invalid email job: recipient is required"))
|
|
if !queue.IsPermanent(err) {
|
|
t.Fatalf("expected permanent classification, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
type sesSenderAPIError struct {
|
|
code string
|
|
fault smithy.ErrorFault
|
|
}
|
|
|
|
func (e sesSenderAPIError) Error() string { return e.code }
|
|
func (e sesSenderAPIError) ErrorCode() string { return e.code }
|
|
func (e sesSenderAPIError) ErrorMessage() string { return e.code }
|
|
func (e sesSenderAPIError) ErrorFault() smithy.ErrorFault { return e.fault }
|
|
|
|
func strPtr(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
var _ sesAPI = (*mockSESAPI)(nil)
|
|
var _ = sestypes.MessageTag{}
|