Skip to content

Commit

Permalink
test/pardot-test-srv: Initial implementation of mock API server
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Feb 12, 2025
1 parent c4f836a commit 7289351
Show file tree
Hide file tree
Showing 6 changed files with 369 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ VERSION ?= 1.0.0
EPOCH ?= 1
MAINTAINER ?= "Community"

CMDS = admin boulder ceremony ct-test-srv
CMDS = admin boulder ceremony ct-test-srv pardot-test-srv
CMD_BINS = $(addprefix bin/, $(CMDS) )
OBJECTS = $(CMD_BINS)

Expand Down
7 changes: 7 additions & 0 deletions test/config-next/pardot-test-srv.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"oauthPort": 9601,
"pardotPort": 9602,
"expectedClientId": "test-client-id",
"expectedClientSecret": "you-shall-not-pass",
"developmentMode" : true
}
7 changes: 7 additions & 0 deletions test/config/pardot-test-srv.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"oauthPort": 9601,
"pardotPort": 9602,
"expectedClientId": "test-client-id",
"expectedClientSecret": "you-shall-not-pass",
"developmentMode" : true
}
96 changes: 96 additions & 0 deletions test/pardot-test-srv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# pardot-test-srv

`pardot-test-srv` is a lightweight mock server for integration testing with the Salesforce Pardot API and OAuth authentication.

## Features

- Simulates Salesforce OAuth2 authentication with configurable credentials.
- Issues randomly generated Bearer tokens for API authentication.
- Provides a mock Pardot API that validates Bearer tokens and requires a business unit header.
- Exposes an endpoint to query submitted emails by business unit (in development mode).
- Allows forced Bearer token expiration for testing authentication flows (in development mode).

## Usage

Run `pardot-test-srv` with a configuration file:
```sh
go run test/partdot-test-srv/main.go <config.json>
```

### Example Configuration (`config.json`)

```json
{
"oAuthPort": 8080,
"pardotPort": 9090,
"expectedClientID": "my-client-id",
"expectedClientSecret": "my-client-secret",
"developmentMode": false
}
```

## API Endpoints

### OAuth Token Request

**Endpoint:** `POST /services/oauth2/token`
**Parameters (Form Data):**
- `client_id`
- `client_secret`

**Response:**
```json
{
"access_token": "randomly-generated-token",
"token_type": "Bearer",
"expires_in": "3600"
}
```

### Create Prospect

**Endpoint:** `POST /api/v5/objects/prospects`
**Headers:**
- `Authorization: Bearer <token>`
- `Pardot-Business-Unit-Id: <business_unit>`

**Payload Example:**
```json
{
"email": "[email protected]"
}
```

**Response:**
```json
{
"status": "success"
}
```

### Query Submitted Prospects (Development Mode Only)

**Endpoint:** `GET /query_prospects`
**Query Parameter:**
- `pardot_business_unit_id=<business_unit>`

**Response:**
```json
{
"prospects": [
"[email protected]",
"[email protected]"
]
}
```

### Force Token Expiration (Development Mode Only)

**Endpoint:** `GET /expire_token`

**Response:**
```json
{
"status": "token expired"
}
```
257 changes: 257 additions & 0 deletions test/pardot-test-srv/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"maps"
"math/rand/v2"
"net/http"
"os"
"slices"
"sync"
"time"

"github.com/letsencrypt/boulder/cmd"
)

type config struct {
// OAuthPort is the port on which the OAuth server will listen.
OAuthPort int

// PardotPort is the port on which the Pardot server will listen.
PardotPort int

// ExpectedClientID is the client ID that the server expects to receive in
// requests to the /services/oauth2/token endpoint.
ExpectedClientID string

// ExpectedClientSecret is the client secret that the server expects to
// receive in requests to the /services/oauth2/token endpoint.
ExpectedClientSecret string

// DevelopmentMode is a flag that indicates whether the server is running in
// development mode. In development mode, the server will:
// - provide an endpoint to expire the current token,
// - store prospects in memory, and
// - provide an endpoint to query the stored prospects.
//
// Only set this flag to true if you are running the server for testing
// (e.g. within docker-compose) or local development purposes.
DevelopmentMode bool
}

type token struct {
sync.Mutex

// active is the currently active token. If this field is empty, it means
// that the token has been manually expired.
active string
}

type prospectsByBusinessUnitId map[string]map[string]struct{}

type prospects struct {
sync.RWMutex

// byBusinessUnitId is a map from business unit ID to a unique set of
// prospects. Prospects are only stored in memory if the server is running
// in development mode.
byBusinessUnitId prospectsByBusinessUnitId
}

type testServer struct {
expectedClientID string
expectedClientSecret string
token token
prospects prospects
developmentMode bool
}

// generateToken generates a new random token.
func generateToken() string {
bytes := make([]byte, 32)
for i := range bytes {
bytes[i] = byte(rand.IntN(256))
}
return fmt.Sprintf("%x", bytes)
}

func (ts *testServer) getTokenHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}

clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")

if clientID != ts.expectedClientID || clientSecret != ts.expectedClientSecret {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}

ts.token.Lock()
defer ts.token.Unlock()
if ts.token.active == "" {
ts.token.active = generateToken()
}

response := map[string]string{
"access_token": ts.token.active,
"token_type": "Bearer",
"expires_in": "3600",
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

func (ts *testServer) expireTokenHandler(w http.ResponseWriter, r *http.Request) {
ts.token.Lock()
ts.token.active = ""
ts.token.Unlock()

w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(map[string]string{"status": "token expired"})
if err != nil {
log.Printf("Failed to encode expire token response: %v", err)
}
}

func (ts *testServer) createProspectsHandler(w http.ResponseWriter, r *http.Request) {
ts.token.Lock()
validToken := ts.token.active
ts.token.Unlock()

token := r.Header.Get("Authorization")
businessUnitId := r.Header.Get("Pardot-Business-Unit-Id")

if token != "Bearer "+validToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}

body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}

type prospectData struct {
Email string `json:"email"`
}

var prospect prospectData
err = json.Unmarshal(body, &prospect)
if err != nil {
http.Error(w, "Failed to parse request body", http.StatusBadRequest)
return
}

if prospect.Email == "" {
http.Error(w, "Missing 'email' field in request body", http.StatusBadRequest)
return
}

if ts.developmentMode {
ts.prospects.Lock()
ts.prospects.byBusinessUnitId[businessUnitId][prospect.Email] = struct{}{}
ts.prospects.Unlock()
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]string{"status": "success"})
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}

func (ts *testServer) queryProspectsHandler(w http.ResponseWriter, r *http.Request) {
buid := r.URL.Query().Get("pardot_business_unit_id")
if buid == "" {
http.Error(w, "Missing 'pardot_business_unit_id' parameter", http.StatusBadRequest)
return
}

ts.prospects.RLock()
prospectsForBuid, exists := ts.prospects.byBusinessUnitId[buid]
ts.prospects.RUnlock()

var requested []string
if exists {
for p := range maps.Keys(prospectsForBuid) {
requested = append(requested, p)
}

}
slices.Sort(requested)

w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(map[string]interface{}{"prospects": requested})
if err != nil {
log.Printf("Failed to encode prospects query response: %v", err)
}
}

func main() {
configFile := flag.String("config", "", "Path to configuration file")
flag.Parse()

if *configFile == "" {
flag.Usage()
os.Exit(1)
}

file, err := os.Open(*configFile)
cmd.FailOnError(err, "Failed to open configuration file")
defer file.Close()
decoder := json.NewDecoder(file)
var c config
err = decoder.Decode(&c)
cmd.FailOnError(err, "Failed to decode configuration file")

ts := &testServer{
expectedClientID: c.ExpectedClientID,
expectedClientSecret: c.ExpectedClientSecret,
prospects: prospects{
byBusinessUnitId: make(prospectsByBusinessUnitId),
},
token: token{
active: generateToken(),
},
developmentMode: c.DevelopmentMode,
}

// Oauth API
oauthMux := http.NewServeMux()
oauthMux.HandleFunc("/services/oauth2/token", ts.getTokenHandler)
if c.DevelopmentMode {
oauthMux.HandleFunc("/expire_token", ts.expireTokenHandler)
}
oauthServer := &http.Server{
Addr: fmt.Sprintf(":%d", c.OAuthPort),
Handler: oauthMux,
ReadTimeout: 30 * time.Second,
}
log.Printf("pardot-test-srv oauth server running on port %d", c.OAuthPort)
go cmd.FailOnError(oauthServer.ListenAndServe(), "Failed to start OAuth server")

// Pardot API
pardotMux := http.NewServeMux()
pardotMux.HandleFunc("/api/v5/objects/prospects", ts.createProspectsHandler)
if c.DevelopmentMode {
pardotMux.HandleFunc("/query_prospects", ts.queryProspectsHandler)
}
pardotServer := &http.Server{
Addr: fmt.Sprintf(":%d", c.PardotPort),
Handler: pardotMux,
ReadTimeout: 30 * time.Second,
}
log.Printf("pardot-test-srv pardot server running on port %d", c.PardotPort)
cmd.FailOnError(pardotServer.ListenAndServe(), "Failed to start Pardot server")
}
2 changes: 1 addition & 1 deletion tools/make-assets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ TARGET="${BUILD}/opt/boulder"
COMMIT_ID="$(git rev-parse --short=8 HEAD)"

mkdir -p "${TARGET}/bin"
for NAME in admin boulder ceremony ct-test-srv ; do
for NAME in admin boulder ceremony ct-test-srv pardot-test-srv ; do
cp -a "bin/${NAME}" "${TARGET}/bin/"
done

Expand Down

0 comments on commit 7289351

Please sign in to comment.