Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions cmd/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"net/http"
"os"
"path"
"strings"
"testing"
"time"
)
Expand All @@ -37,6 +38,7 @@ func init() {
func Test_proxyUpstream(t *testing.T) {
type args struct {
rewrite bool
soap bool
options proxy.RecorderOptions
}
tests := []struct {
Expand All @@ -61,6 +63,16 @@ func Test_proxyUpstream(t *testing.T) {
},
},
},
{
name: "proxy SOAP 1.1 service with SOAPAction",
args: args{
rewrite: false,
soap: true,
options: proxy.RecorderOptions{
FlatResponseFileStructure: false,
},
},
},
}
for _, tt := range tests {
server, upstream, upstreamPort, err := startUpstream()
Expand All @@ -85,14 +97,26 @@ func Test_proxyUpstream(t *testing.T) {
t.Fatalf("proxy did not come up on port %d", port)
}

if err := sendRequestToProxy(port); err != nil {
t.Fatal(err)
if tt.args.soap {
if err := sendSoapRequestToProxy(port); err != nil {
t.Fatal(err)
}
} else {
if err := sendRequestToProxy(port); err != nil {
t.Fatal(err)
}
}

upstreamHostAndPort := fmt.Sprintf("localhost-%d", upstreamPort)
cfgFileName := upstreamHostAndPort + "-config.yaml"
var indexFileName string
if tt.args.options.FlatResponseFileStructure {
if tt.args.soap {
if tt.args.options.FlatResponseFileStructure {
indexFileName = upstreamHostAndPort + "-POST-index_http___example_com_service_GetUser.txt"
} else {
indexFileName = "POST-index_http___example_com_service_GetUser.txt"
}
} else if tt.args.options.FlatResponseFileStructure {
indexFileName = upstreamHostAndPort + "-GET-index.txt"
} else {
indexFileName = "GET-index.txt"
Expand Down Expand Up @@ -156,3 +180,40 @@ func sendRequestToProxy(port int) error {
}
return nil
}

func sendSoapRequestToProxy(port int) error {
client := http.Client{
Timeout: 2 * time.Second,
}
url := fmt.Sprintf("http://localhost:%d", port)

soapBody := `<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetUser xmlns="http://example.com/service">
<userId>123</userId>
</GetUser>
</soap:Body>
</soap:Envelope>`

req, err := http.NewRequest("POST", url, strings.NewReader(soapBody))
if err != nil {
return fmt.Errorf("failed to create SOAP request: %s", err)
}
req.Header.Set("Content-Type", "text/xml; charset=utf-8")
req.Header.Set("SOAPAction", "http://example.com/service/GetUser")

resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("SOAP request failed for proxy at %s: %s", url, err)
}
if _, err := io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("body read failed for proxy at %s: %s", url, err)
}
_ = resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("SOAP proxy at %s returned status %d, want 200", url, resp.StatusCode)
}
logger.Tracef("SOAP proxy up at %s", url)
return nil
}
1 change: 1 addition & 0 deletions internal/proxy/content.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ var textMediaTypes = []string{
"application/javascript",
"application/json",
"application/xml",
"application/soap\\+xml",
"application/x-www-form-urlencoded",
}

Expand Down
36 changes: 36 additions & 0 deletions internal/proxy/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,39 @@ import (
"strings"
)

// extractSoapAction returns the SOAP action for the request, looking first
// at the SOAPAction header (SOAP 1.1) and falling back to the `action`
// parameter on the Content-Type header (SOAP 1.2). The value is unquoted.
// An empty string is returned when no action is present.
func extractSoapAction(req *http.Request) string {
// SOAP 1.1: SOAPAction header
if soapAction := strings.Trim(req.Header.Get("SOAPAction"), "\""); soapAction != "" {
logger.Debugf("extracted SOAPAction from header: %q", soapAction)
return soapAction
}
// SOAP 1.2: action parameter on the Content-Type header
contentType := req.Header.Get("Content-Type")
if contentType == "" {
return ""
}
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
logger.Debugf("failed to parse Content-Type %q: %v", contentType, err)
return ""
}
if action := params["action"]; action != "" {
logger.Debugf("extracted SOAPAction from Content-Type: %q", action)
return action
}
return ""
}

// sanitiseSoapAction replaces characters that are unsafe in filenames.
func sanitiseSoapAction(action string) string {
r := strings.NewReplacer("/", "_", ":", "_", ".", "_")
return r.Replace(action)
}

// generateRespFileName returns a unique filename for the given response
func generateRespFileName(
upstreamHost string,
Expand Down Expand Up @@ -63,6 +96,9 @@ func generateRespFileName(
}
respFileName = req.Method + "-" + baseFileName
}
if soapAction := extractSoapAction(req); soapAction != "" {
respFileName = respFileName + "_" + sanitiseSoapAction(soapAction)
}

var suffix string
if path.Ext(baseFileName) == "" {
Expand Down
95 changes: 95 additions & 0 deletions internal/proxy/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,98 @@ func Test_generateRespFileName(t *testing.T) {
})
}
}

func TestExtractSoapAction(t *testing.T) {
tests := []struct {
name string
headers map[string]string
expected string
}{
{
name: "no soap headers",
headers: map[string]string{},
expected: "",
},
{
name: "SOAP 1.1 quoted SOAPAction header",
headers: map[string]string{
"SOAPAction": `"http://example.com/GetUser"`,
"Content-Type": "text/xml; charset=utf-8",
},
expected: "http://example.com/GetUser",
},
{
name: "SOAP 1.1 unquoted SOAPAction header",
headers: map[string]string{
"SOAPAction": "http://example.com/GetUser",
"Content-Type": "text/xml; charset=utf-8",
},
expected: "http://example.com/GetUser",
},
{
name: "SOAP 1.2 action parameter, quoted",
headers: map[string]string{
"Content-Type": `application/soap+xml;charset=UTF-8;action="http://example.com/GetUser"`,
},
expected: "http://example.com/GetUser",
},
{
// RFC 3902 requires the SOAP 1.2 action parameter to be a
// quoted string. An unquoted URL contains characters that
// are not valid MIME token chars, so mime.ParseMediaType
// correctly rejects the whole header.
name: "SOAP 1.2 action parameter, unquoted, is rejected",
headers: map[string]string{
"Content-Type": "application/soap+xml;charset=UTF-8;action=http://example.com/GetUser",
},
expected: "",
},
{
name: "SOAP 1.1 header takes precedence over Content-Type action",
headers: map[string]string{
"SOAPAction": `"http://example.com/GetUser"`,
"Content-Type": `application/soap+xml;action="http://example.com/Ignored"`,
},
expected: "http://example.com/GetUser",
},
{
name: "reaction parameter is not mistaken for action",
headers: map[string]string{
// `reaction=foo` must NOT be matched as an action.
"Content-Type": "application/soap+xml; reaction=foo",
},
expected: "",
},
{
name: "empty SOAPAction header falls back to Content-Type",
headers: map[string]string{
"SOAPAction": `""`,
"Content-Type": `application/soap+xml;action="http://example.com/Fallback"`,
},
expected: "http://example.com/Fallback",
},
{
name: "unparseable Content-Type returns empty",
headers: map[string]string{
"Content-Type": "not a valid media type",
},
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("POST", "http://example.com/service", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
for k, v := range tt.headers {
req.Header.Set(k, v)
}
got := extractSoapAction(req)
if got != tt.expected {
t.Errorf("extractSoapAction() = %q, want %q", got, tt.expected)
}
})
}
}
19 changes: 16 additions & 3 deletions internal/proxy/recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ func buildResource(
}
}
resource.RequestHeaders = &headers
} else if soapAction := extractSoapAction(&req); soapAction != "" {
headers := make(map[string]string)
if req.Header.Get("SOAPAction") != "" {
headers["SOAPAction"] = soapAction
} else if contentType := req.Header.Get("Content-Type"); contentType != "" {
headers["Content-Type"] = contentType
}
resource.RequestHeaders = &headers
}
if options.CaptureRequestBody && exchange.RequestBody != nil {
contentType := req.Header.Get("Content-Type")
Expand Down Expand Up @@ -236,10 +244,15 @@ func buildResource(
return resource, nil
}

// getRequestHash generates a hash for a request based on the HTTP method and the URL. It does
// not take into consideration request headers.
// getRequestHash generates a hash for a request based on the HTTP method,
// URL, and SOAP action (if present). This ensures that SOAP operations
// sharing the same endpoint URL are treated as distinct requests.
func getRequestHash(req *http.Request) string {
return stringutil.Sha1hashString(req.Method + req.URL.String())
key := req.Method + req.URL.String()
if soapAction := extractSoapAction(req); soapAction != "" {
key += soapAction
}
return stringutil.Sha1hashString(key)
}

func updateConfigFile(exchange HttpExchange, options impostermodel2.ConfigGenerationOptions, resources []impostermodel2.Resource, configFile string) error {
Expand Down
Loading