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 }