github.com/letsencrypt/boulder@v0.20251208.0/test/integration/email_exporter_test.go (about)

     1  //go:build integration
     2  
     3  package integration
     4  
     5  import (
     6  	"bytes"
     7  	"crypto/ecdsa"
     8  	"crypto/elliptic"
     9  	"crypto/rand"
    10  	"encoding/json"
    11  	"fmt"
    12  	"net/http"
    13  	"net/url"
    14  	"os"
    15  	"slices"
    16  	"strings"
    17  	"testing"
    18  	"time"
    19  
    20  	"github.com/eggsampler/acme/v3"
    21  
    22  	"github.com/letsencrypt/boulder/test"
    23  )
    24  
    25  // randomDomain creates a random domain name for testing.
    26  func randomDomain(t *testing.T) string {
    27  	t.Helper()
    28  
    29  	var bytes [4]byte
    30  	_, err := rand.Read(bytes[:])
    31  	if err != nil {
    32  		test.AssertNotError(t, err, "Failed to generate random domain")
    33  	}
    34  	return fmt.Sprintf("%x.mail.com", bytes[:])
    35  }
    36  
    37  // getOAuthToken queries the pardot-test-srv for the current OAuth token.
    38  func getOAuthToken(t *testing.T) string {
    39  	t.Helper()
    40  
    41  	data, err := os.ReadFile("test/secrets/salesforce_client_id")
    42  	test.AssertNotError(t, err, "Failed to read Salesforce client ID")
    43  	clientId := string(data)
    44  
    45  	data, err = os.ReadFile("test/secrets/salesforce_client_secret")
    46  	test.AssertNotError(t, err, "Failed to read Salesforce client secret")
    47  	clientSecret := string(data)
    48  
    49  	httpClient := http.DefaultClient
    50  	resp, err := httpClient.PostForm("http://localhost:9601/services/oauth2/token", url.Values{
    51  		"grant_type":    {"client_credentials"},
    52  		"client_id":     {strings.TrimSpace(clientId)},
    53  		"client_secret": {strings.TrimSpace(clientSecret)},
    54  	})
    55  	test.AssertNotError(t, err, "Failed to fetch OAuth token")
    56  	test.AssertEquals(t, resp.StatusCode, http.StatusOK)
    57  	defer resp.Body.Close()
    58  
    59  	var response struct {
    60  		AccessToken string `json:"access_token"`
    61  	}
    62  	decoder := json.NewDecoder(resp.Body)
    63  	err = decoder.Decode(&response)
    64  	test.AssertNotError(t, err, "Failed to decode OAuth token")
    65  	return response.AccessToken
    66  }
    67  
    68  // getCreatedContacts queries the pardot-test-srv for the list of created
    69  // contacts.
    70  func getCreatedContacts(t *testing.T, token string) []string {
    71  	t.Helper()
    72  
    73  	httpClient := http.DefaultClient
    74  	req, err := http.NewRequest("GET", "http://localhost:9602/contacts", nil)
    75  	test.AssertNotError(t, err, "Failed to create request")
    76  	req.Header.Set("Authorization", "Bearer "+token)
    77  
    78  	resp, err := httpClient.Do(req)
    79  	test.AssertNotError(t, err, "Failed to query contacts")
    80  	test.AssertEquals(t, resp.StatusCode, http.StatusOK)
    81  	defer resp.Body.Close()
    82  
    83  	var got struct {
    84  		Contacts []string `json:"contacts"`
    85  	}
    86  	decoder := json.NewDecoder(resp.Body)
    87  	err = decoder.Decode(&got)
    88  	test.AssertNotError(t, err, "Failed to decode contacts")
    89  	return got.Contacts
    90  }
    91  
    92  // assertAllContactsReceived waits for the expected contacts to be received by
    93  // pardot-test-srv. Retries every 50ms for up to 2 seconds and fails if the
    94  // expected contacts are not received.
    95  func assertAllContactsReceived(t *testing.T, token string, expect []string) {
    96  	t.Helper()
    97  
    98  	for attempt := range 20 {
    99  		if attempt > 0 {
   100  			time.Sleep(50 * time.Millisecond)
   101  		}
   102  		got := getCreatedContacts(t, token)
   103  
   104  		allFound := true
   105  		for _, e := range expect {
   106  			if !slices.Contains(got, e) {
   107  				allFound = false
   108  				break
   109  			}
   110  		}
   111  		if allFound {
   112  			break
   113  		}
   114  		if attempt >= 19 {
   115  			t.Fatalf("Expected contacts=%v to be received by pardot-test-srv, got contacts=%v", expect, got)
   116  		}
   117  	}
   118  }
   119  
   120  // TestContactsSentForNewAccount tests that contacts are dispatched to
   121  // pardot-test-srv by the email-exporter when a new account is created.
   122  func TestContactsSentForNewAccount(t *testing.T) {
   123  	t.Parallel()
   124  
   125  	if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" {
   126  		t.Skip("Test requires WFE to be configured to use email-exporter")
   127  	}
   128  
   129  	token := getOAuthToken(t)
   130  	domain := randomDomain(t)
   131  
   132  	tests := []struct {
   133  		name           string
   134  		contacts       []string
   135  		expectContacts []string
   136  	}{
   137  		{
   138  			name:           "Single email",
   139  			contacts:       []string{"mailto:example@" + domain},
   140  			expectContacts: []string{"example@" + domain},
   141  		},
   142  		{
   143  			name:           "Multiple emails",
   144  			contacts:       []string{"mailto:example1@" + domain, "mailto:example2@" + domain},
   145  			expectContacts: []string{"example1@" + domain, "example2@" + domain},
   146  		},
   147  	}
   148  
   149  	for _, tt := range tests {
   150  		t.Run(tt.name, func(t *testing.T) {
   151  			t.Parallel()
   152  
   153  			c, err := acme.NewClient("http://boulder.service.consul:4001/directory")
   154  			if err != nil {
   155  				t.Fatalf("failed to connect to acme directory: %s", err)
   156  			}
   157  
   158  			acctKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
   159  			if err != nil {
   160  				t.Fatalf("failed to generate account key: %s", err)
   161  			}
   162  
   163  			_, err = c.NewAccount(acctKey, false, true, tt.contacts...)
   164  			test.AssertNotError(t, err, "Failed to create initial account with contacts")
   165  			assertAllContactsReceived(t, token, tt.expectContacts)
   166  		})
   167  	}
   168  }
   169  
   170  // getCreatedCases queries the pardot-test-srv for the list of created cases.
   171  // Fails the test on error.
   172  func getCreatedCases(t *testing.T, token string) []map[string]any {
   173  	t.Helper()
   174  
   175  	req, err := http.NewRequest("GET", "http://localhost:9601/cases", nil)
   176  	test.AssertNotError(t, err, "Failed to create cases request")
   177  	req.Header.Set("Authorization", "Bearer "+token)
   178  
   179  	resp, err := http.DefaultClient.Do(req)
   180  	test.AssertNotError(t, err, "Failed to query cases")
   181  	test.AssertEquals(t, resp.StatusCode, http.StatusOK)
   182  	defer resp.Body.Close()
   183  
   184  	var got struct {
   185  		Cases []map[string]any `json:"cases"`
   186  	}
   187  	err = json.NewDecoder(resp.Body).Decode(&got)
   188  	test.AssertNotError(t, err, "Failed to decode cases")
   189  	return got.Cases
   190  }
   191  
   192  // createCase sends a request to create a new case via pardot-test-srv and
   193  // returns the HTTP status code and response body. Fails the test on error.
   194  func createCase(t *testing.T, token string, payload map[string]any) (int, []byte) {
   195  	t.Helper()
   196  
   197  	b, err := json.Marshal(payload)
   198  	test.AssertNotError(t, err, "Failed to marshal case payload")
   199  
   200  	req, err := http.NewRequest(
   201  		"POST",
   202  		"http://localhost:9601/services/data/v64.0/sobjects/Case",
   203  		bytes.NewReader(b),
   204  	)
   205  	test.AssertNotError(t, err, "Failed to create case POST request")
   206  	req.Header.Set("Authorization", "Bearer "+token)
   207  	req.Header.Set("Content-Type", "application/json")
   208  
   209  	resp, err := http.DefaultClient.Do(req)
   210  	test.AssertNotError(t, err, "Failed to POST case")
   211  	defer resp.Body.Close()
   212  
   213  	var body bytes.Buffer
   214  	_, err = body.ReadFrom(resp.Body)
   215  	test.AssertNotError(t, err, "Failed to read case response body")
   216  
   217  	return resp.StatusCode, body.Bytes()
   218  }
   219  
   220  func TestCasesAPISuccess(t *testing.T) {
   221  	t.Parallel()
   222  
   223  	token := getOAuthToken(t)
   224  
   225  	status, _ := createCase(t, token, map[string]any{
   226  		"Subject":     "Integration Test Case",
   227  		"Description": "Created by integration test",
   228  		"Origin":      "Web",
   229  	})
   230  	test.AssertEquals(t, status, http.StatusCreated)
   231  
   232  	// Verify it was recorded by the fake server.
   233  	cases := getCreatedCases(t, token)
   234  	found := false
   235  	for _, c := range cases {
   236  		if c["Subject"] == "Integration Test Case" && c["Origin"] == "Web" {
   237  			found = true
   238  			break
   239  		}
   240  	}
   241  	if !found {
   242  		t.Fatalf("Expected created case to be present; got cases=%s", cases)
   243  	}
   244  }
   245  
   246  func TestCasesAPIMissingOrigin(t *testing.T) {
   247  	t.Parallel()
   248  
   249  	token := getOAuthToken(t)
   250  
   251  	// Missing Origin should be rejected by the fake server.
   252  	status, body := createCase(t, token, map[string]any{
   253  		"Subject":     "Missing Origin Case",
   254  		"Description": "Should fail",
   255  	})
   256  	test.AssertEquals(t, status, http.StatusBadRequest)
   257  	test.AssertContains(t, string(body), "Missing required field: Origin")
   258  }
   259  
   260  func TestCasesAPIUsingSFE(t *testing.T) {
   261  	t.Parallel()
   262  
   263  	if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" {
   264  		t.Skip("Test requires SFE to be configured to use email-exporter")
   265  	}
   266  
   267  	token := getOAuthToken(t)
   268  
   269  	body, err := json.Marshal(map[string]any{
   270  		"rateLimit": "NewOrdersPerAccount",
   271  		"fields": map[string]string{
   272  			"subscriberAgreement": "true",
   273  			"privacyPolicy":       "true",
   274  			"mailingList":         "false",
   275  			"fundraising":         "Yes, email me more information.",
   276  			"emailAddress":        "test@foo.bar",
   277  			"organization":        "Big Host Inc.",
   278  			"useCase":             strings.Repeat("x", 60),
   279  			"tier":                "1000",
   280  			"accountURI":          "https://acme-v02.api.letsencrypt.org/acme/acct/12345",
   281  		},
   282  	})
   283  	test.AssertNotError(t, err, "marshal override payload")
   284  
   285  	req, err := http.NewRequest(http.MethodPost, "http://localhost:4003/sfe/v1/overrides/submit-override-request", bytes.NewReader(body))
   286  	test.AssertNotError(t, err, "creating override request")
   287  	req.Header.Set("Content-Type", "application/json")
   288  
   289  	resp, err := http.DefaultClient.Do(req)
   290  	test.AssertNotError(t, err, "POSTing override request to SFE")
   291  	defer resp.Body.Close()
   292  
   293  	if resp.StatusCode != http.StatusCreated {
   294  		var buf bytes.Buffer
   295  		_, err = buf.ReadFrom(resp.Body)
   296  		test.AssertNotError(t, err, "reading SFE response body")
   297  		t.Errorf("unexpected SFE status=%d with body=%s", resp.StatusCode, buf.String())
   298  	}
   299  
   300  	timeout := 3 * time.Second
   301  	interval := 10 * time.Millisecond
   302  
   303  	ticker := time.NewTicker(interval)
   304  	defer ticker.Stop()
   305  
   306  	timer := time.NewTimer(timeout)
   307  	defer timer.Stop()
   308  
   309  	for {
   310  		select {
   311  		case <-ticker.C:
   312  			cases := getCreatedCases(t, token)
   313  			for _, c := range cases {
   314  				if c["Subject"] == "NewOrdersPerAccount rate limit override request for Big Host Inc." &&
   315  					c["Origin"] == "Web" &&
   316  					c["ContactEmail"] == "test@foo.bar" &&
   317  					c["Organization__c"] == "Big Host Inc." &&
   318  					c["Rate_Limit_Name__c"] == "NewOrdersPerAccount" &&
   319  					c["Rate_Limit_Tier__c"] == "1000" {
   320  					return
   321  				}
   322  			}
   323  		case <-timer.C:
   324  			t.Fatalf("expected Case never created within %s", timeout)
   325  		}
   326  	}
   327  }