-
-
Notifications
You must be signed in to change notification settings - Fork 614
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
test/pardot-test-srv: Initial implementation of mock API server
- Loading branch information
1 parent
c4f836a
commit 7289351
Showing
6 changed files
with
369 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters