github.com/letsencrypt/boulder@v0.20251208.0/email/exporter_test.go (about)

     1  package email
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"slices"
     7  	"sync"
     8  	"testing"
     9  	"time"
    10  
    11  	emailpb "github.com/letsencrypt/boulder/email/proto"
    12  	blog "github.com/letsencrypt/boulder/log"
    13  	"github.com/letsencrypt/boulder/metrics"
    14  	"github.com/letsencrypt/boulder/test"
    15  
    16  	"github.com/prometheus/client_golang/prometheus"
    17  )
    18  
    19  var ctx = context.Background()
    20  
    21  var _ SalesforceClient = (*mockSalesforceClientImpl)(nil)
    22  
    23  // mockSalesforceClientImpl is a mock implementation of PardotClient.
    24  type mockSalesforceClientImpl struct {
    25  	SalesforceClient
    26  
    27  	sync.Mutex
    28  	CreatedContacts []string
    29  	CreatedCases    []Case
    30  }
    31  
    32  // newMockSalesforceClientImpl returns a mockSalesforceClientImpl, which implements
    33  // the PardotClient interface. It returns the underlying concrete type, so callers
    34  // have access to its struct members and helper methods.
    35  func newMockSalesforceClientImpl() *mockSalesforceClientImpl {
    36  	return &mockSalesforceClientImpl{}
    37  }
    38  
    39  // SendContact adds an email to CreatedContacts.
    40  func (m *mockSalesforceClientImpl) SendContact(email string) error {
    41  	m.Lock()
    42  	defer m.Unlock()
    43  	m.CreatedContacts = append(m.CreatedContacts, email)
    44  	return nil
    45  }
    46  
    47  func (m *mockSalesforceClientImpl) getCreatedContacts() []string {
    48  	m.Lock()
    49  	defer m.Unlock()
    50  
    51  	// Return a copy to avoid race conditions.
    52  	return slices.Clone(m.CreatedContacts)
    53  }
    54  
    55  func (m *mockSalesforceClientImpl) SendCase(payload Case) error {
    56  	m.Lock()
    57  	defer m.Unlock()
    58  	m.CreatedCases = append(m.CreatedCases, payload)
    59  	return nil
    60  }
    61  
    62  func (m *mockSalesforceClientImpl) getCreatedCases() []Case {
    63  	m.Lock()
    64  	defer m.Unlock()
    65  
    66  	// Return a copy to avoid race conditions.
    67  	return slices.Clone(m.CreatedCases)
    68  }
    69  
    70  // setup creates a new ExporterImpl, a mockSalesForceClientImpl, and the start and
    71  // cleanup functions for the ExporterImpl. Call start() to begin processing the
    72  // ExporterImpl queue and cleanup() to drain and shutdown. If start() is called,
    73  // cleanup() must be called.
    74  func setup() (*ExporterImpl, *mockSalesforceClientImpl, func(), func()) {
    75  	clientImpl := newMockSalesforceClientImpl()
    76  	exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
    77  	daemonCtx, cancel := context.WithCancel(context.Background())
    78  	return exporter, clientImpl,
    79  		func() { exporter.Start(daemonCtx) },
    80  		func() {
    81  			cancel()
    82  			exporter.Drain()
    83  		}
    84  }
    85  
    86  func TestSendContacts(t *testing.T) {
    87  	t.Parallel()
    88  
    89  	exporter, clientImpl, start, cleanup := setup()
    90  	start()
    91  	defer cleanup()
    92  
    93  	wantContacts := []string{"test@example.com", "user@example.com"}
    94  	_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
    95  		Emails: wantContacts,
    96  	})
    97  	test.AssertNotError(t, err, "Error creating contacts")
    98  
    99  	var gotContacts []string
   100  	for range 100 {
   101  		gotContacts = clientImpl.getCreatedContacts()
   102  		if len(gotContacts) == 2 {
   103  			break
   104  		}
   105  		time.Sleep(5 * time.Millisecond)
   106  	}
   107  	test.AssertSliceContains(t, gotContacts, wantContacts[0])
   108  	test.AssertSliceContains(t, gotContacts, wantContacts[1])
   109  
   110  	// Check that the error counter was not incremented.
   111  	test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 0)
   112  }
   113  
   114  func TestSendContactsQueueFull(t *testing.T) {
   115  	t.Parallel()
   116  
   117  	exporter, _, start, cleanup := setup()
   118  	start()
   119  	defer cleanup()
   120  
   121  	var err error
   122  	for range contactsQueueCap * 2 {
   123  		_, err = exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
   124  			Emails: []string{"test@example.com"},
   125  		})
   126  		if err != nil {
   127  			break
   128  		}
   129  	}
   130  	test.AssertErrorIs(t, err, ErrQueueFull)
   131  }
   132  
   133  func TestSendContactsQueueDrains(t *testing.T) {
   134  	t.Parallel()
   135  
   136  	exporter, clientImpl, start, cleanup := setup()
   137  	start()
   138  
   139  	var emails []string
   140  	for i := range 100 {
   141  		emails = append(emails, fmt.Sprintf("test@%d.example.com", i))
   142  	}
   143  
   144  	_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
   145  		Emails: emails,
   146  	})
   147  	test.AssertNotError(t, err, "Error creating contacts")
   148  
   149  	// Drain the queue.
   150  	cleanup()
   151  
   152  	test.AssertEquals(t, 100, len(clientImpl.getCreatedContacts()))
   153  }
   154  
   155  type mockAlwaysFailClient struct {
   156  	mockSalesforceClientImpl
   157  }
   158  
   159  func (m *mockAlwaysFailClient) SendContact(email string) error {
   160  	return fmt.Errorf("simulated failure")
   161  }
   162  
   163  func TestSendContactsErrorMetrics(t *testing.T) {
   164  	t.Parallel()
   165  
   166  	mockClient := &mockAlwaysFailClient{}
   167  	exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
   168  
   169  	daemonCtx, cancel := context.WithCancel(context.Background())
   170  	exporter.Start(daemonCtx)
   171  
   172  	_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
   173  		Emails: []string{"test@example.com"},
   174  	})
   175  	test.AssertNotError(t, err, "Error creating contacts")
   176  
   177  	// Drain the queue.
   178  	cancel()
   179  	exporter.Drain()
   180  
   181  	// Check that the error counter was incremented.
   182  	test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 1)
   183  }
   184  
   185  func TestSendContactDeduplication(t *testing.T) {
   186  	t.Parallel()
   187  
   188  	cache := NewHashedEmailCache(1000, metrics.NoopRegisterer)
   189  	clientImpl := newMockSalesforceClientImpl()
   190  	exporter := NewExporterImpl(clientImpl, cache, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
   191  
   192  	daemonCtx, cancel := context.WithCancel(context.Background())
   193  	exporter.Start(daemonCtx)
   194  
   195  	_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
   196  		Emails: []string{"duplicate@example.com", "duplicate@example.com"},
   197  	})
   198  	test.AssertNotError(t, err, "Error enqueuing contacts")
   199  
   200  	// Drain the queue.
   201  	cancel()
   202  	exporter.Drain()
   203  
   204  	contacts := clientImpl.getCreatedContacts()
   205  	test.AssertEquals(t, 1, len(contacts))
   206  	test.AssertEquals(t, "duplicate@example.com", contacts[0])
   207  
   208  	// Only one successful send should be recorded.
   209  	test.AssertMetricWithLabelsEquals(t, exporter.emailsHandledCounter, prometheus.Labels{}, 1)
   210  
   211  	if !cache.Seen("duplicate@example.com") {
   212  		t.Errorf("duplicate@example.com should have been cached after send")
   213  	}
   214  }
   215  
   216  func TestSendContactErrorRemovesFromCache(t *testing.T) {
   217  	t.Parallel()
   218  
   219  	cache := NewHashedEmailCache(1000, metrics.NoopRegisterer)
   220  	fc := &mockAlwaysFailClient{}
   221  
   222  	exporter := NewExporterImpl(fc, cache, 1000000, 1, metrics.NoopRegisterer, blog.NewMock())
   223  
   224  	daemonCtx, cancel := context.WithCancel(context.Background())
   225  	exporter.Start(daemonCtx)
   226  
   227  	_, err := exporter.SendContacts(ctx, &emailpb.SendContactsRequest{
   228  		Emails: []string{"error@example.com"},
   229  	})
   230  	test.AssertNotError(t, err, "enqueue failed")
   231  
   232  	// Drain the queue.
   233  	cancel()
   234  	exporter.Drain()
   235  
   236  	// The email should have been evicted from the cache after send encountered
   237  	// an error.
   238  	if cache.Seen("error@example.com") {
   239  		t.Errorf("error@example.com should have been evicted from cache after send errors")
   240  	}
   241  
   242  	// Check that the error counter was incremented.
   243  	test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 1)
   244  }
   245  
   246  func TestSendCase(t *testing.T) {
   247  	t.Parallel()
   248  
   249  	clientImpl := newMockSalesforceClientImpl()
   250  	exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
   251  
   252  	_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{
   253  		Origin:       "Web",
   254  		Subject:      "Some Override",
   255  		Description:  "Please review",
   256  		ContactEmail: "foo@example.com",
   257  	})
   258  	test.AssertNotError(t, err, "SendCase should succeed")
   259  
   260  	got := clientImpl.getCreatedCases()
   261  	if len(got) != 1 {
   262  		t.Fatalf("expected 1 case, got %d", len(got))
   263  	}
   264  	test.AssertEquals(t, got[0].Origin, "Web")
   265  	test.AssertEquals(t, got[0].Subject, "Some Override")
   266  	test.AssertEquals(t, got[0].Description, "Please review")
   267  	test.AssertEquals(t, got[0].ContactEmail, "foo@example.com")
   268  	test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 0)
   269  }
   270  
   271  type mockAlwaysFailCaseClient struct {
   272  	mockSalesforceClientImpl
   273  }
   274  
   275  func (m *mockAlwaysFailCaseClient) SendCase(payload Case) error {
   276  	return fmt.Errorf("oops, lol")
   277  }
   278  
   279  func TestSendCaseClientErrorIncrementsMetric(t *testing.T) {
   280  	t.Parallel()
   281  
   282  	mockClient := &mockAlwaysFailCaseClient{}
   283  	exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
   284  
   285  	_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{
   286  		Origin:       "Web",
   287  		Subject:      "Some Override",
   288  		Description:  "Please review",
   289  		ContactEmail: "foo@bar.baz",
   290  	})
   291  	test.AssertError(t, err, "SendCase should return error on client failure")
   292  	test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 1)
   293  }
   294  
   295  func TestSendCaseMissingOriginValidation(t *testing.T) {
   296  	t.Parallel()
   297  
   298  	clientImpl := newMockSalesforceClientImpl()
   299  	exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
   300  
   301  	_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{Subject: "No origin in this one, d00d"})
   302  	test.AssertError(t, err, "SendCase should fail validation when Origin is missing")
   303  
   304  	got := clientImpl.getCreatedCases()
   305  	if len(got) != 0 {
   306  		t.Errorf("expected 0 cases due to validation error, got %d", len(got))
   307  	}
   308  	test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 0)
   309  }