github.com/avenga/couper@v1.12.2/handler/transport/backend_test.go (about)

     1  package transport_test
     2  
     3  import (
     4  	"bytes"
     5  	"compress/gzip"
     6  	"context"
     7  	"io"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"net/url"
    11  	"strings"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/google/go-cmp/cmp"
    16  	"github.com/hashicorp/hcl/v2/hclsyntax"
    17  	logrustest "github.com/sirupsen/logrus/hooks/test"
    18  	"github.com/zclconf/go-cty/cty"
    19  
    20  	"github.com/avenga/couper/config"
    21  	hclbody "github.com/avenga/couper/config/body"
    22  	"github.com/avenga/couper/config/request"
    23  	"github.com/avenga/couper/errors"
    24  	"github.com/avenga/couper/eval"
    25  	"github.com/avenga/couper/eval/buffer"
    26  	"github.com/avenga/couper/handler/transport"
    27  	"github.com/avenga/couper/handler/validation"
    28  	"github.com/avenga/couper/internal/seetie"
    29  	"github.com/avenga/couper/internal/test"
    30  )
    31  
    32  func TestBackend_RoundTrip_Timings(t *testing.T) {
    33  	origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
    34  		if req.Method == http.MethodHead {
    35  			time.Sleep(time.Second * 2) // > ttfb and overall timeout
    36  		}
    37  		rw.WriteHeader(http.StatusNoContent)
    38  	}))
    39  	defer origin.Close()
    40  
    41  	withTimingsFn := func(base *hclsyntax.Body, connect, ttfb, timeout string) *hclsyntax.Body {
    42  		content := &hclsyntax.Body{Attributes: hclsyntax.Attributes{
    43  			"connect_timeout": {Name: "connect_timeout", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal(connect)}},
    44  			"ttfb_timeout":    {Name: "ttfb_timeout", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal(ttfb)}},
    45  			"timeout":         {Name: "timeout", Expr: &hclsyntax.LiteralValueExpr{Val: cty.StringVal(timeout)}},
    46  		}}
    47  		return hclbody.MergeBodies(base, content, true)
    48  	}
    49  
    50  	tests := []struct {
    51  		name        string
    52  		context     *hclsyntax.Body
    53  		req         *http.Request
    54  		expectedErr string
    55  	}{
    56  		{"with zero timings", hclbody.NewHCLSyntaxBodyWithStringAttr("origin", origin.URL), httptest.NewRequest(http.MethodGet, "http://1.2.3.4/", nil), ""},
    57  		{"with overall timeout", withTimingsFn(hclbody.NewHCLSyntaxBodyWithStringAttr("origin", origin.URL), "1m", "30s", "500ms"), httptest.NewRequest(http.MethodHead, "http://1.2.3.5/", nil), "deadline exceeded"},
    58  		{"with connect timeout", withTimingsFn(hclbody.NewHCLSyntaxBodyWithStringAttr("origin", "http://blackhole.webpagetest.org"), "750ms", "500ms", "1m"), httptest.NewRequest(http.MethodGet, "http://1.2.3.6/", nil), "i/o timeout"},
    59  		{"with ttfb timeout", withTimingsFn(hclbody.NewHCLSyntaxBodyWithStringAttr("origin", origin.URL), "10s", "1s", "1m"), httptest.NewRequest(http.MethodHead, "http://1.2.3.7/", nil), "timeout awaiting response headers"},
    60  	}
    61  
    62  	logger, hook := logrustest.NewNullLogger()
    63  	log := logger.WithContext(context.Background())
    64  
    65  	for _, tt := range tests {
    66  		t.Run(tt.name, func(subT *testing.T) {
    67  			hook.Reset()
    68  
    69  			backend := transport.NewBackend(tt.context, &transport.Config{NoProxyFromEnv: true}, nil, log)
    70  
    71  			_, err := backend.RoundTrip(tt.req)
    72  			if err != nil && tt.expectedErr == "" {
    73  				subT.Error(err)
    74  				return
    75  			}
    76  
    77  			gerr, isErr := err.(errors.GoError)
    78  
    79  			if tt.expectedErr != "" &&
    80  				(err == nil || !isErr || !strings.HasSuffix(gerr.LogError(), tt.expectedErr)) {
    81  				subT.Errorf("Expected err %s, got: %#v", tt.expectedErr, err)
    82  			}
    83  		})
    84  	}
    85  }
    86  
    87  func TestBackend_Compression_Disabled(t *testing.T) {
    88  	helper := test.New(t)
    89  
    90  	origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
    91  		if req.Header.Get("Accept-Encoding") != "" {
    92  			t.Error("Unexpected Accept-Encoding header")
    93  		}
    94  		rw.WriteHeader(http.StatusNoContent)
    95  	}))
    96  	defer origin.Close()
    97  
    98  	logger, _ := logrustest.NewNullLogger()
    99  	log := logger.WithContext(context.Background())
   100  
   101  	hclBody := hclbody.NewHCLSyntaxBodyWithStringAttr("origin", origin.URL)
   102  	backend := transport.NewBackend(hclBody, &transport.Config{}, nil, log)
   103  
   104  	req := httptest.NewRequest(http.MethodOptions, "http://1.2.3.4/", nil)
   105  	res, err := backend.RoundTrip(req)
   106  	helper.Must(err)
   107  
   108  	if res.StatusCode != http.StatusNoContent {
   109  		t.Errorf("Expected 204, got: %d", res.StatusCode)
   110  	}
   111  }
   112  
   113  func TestBackend_Compression_ModifyAcceptEncoding(t *testing.T) {
   114  	helper := test.New(t)
   115  
   116  	origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
   117  		if ae := req.Header.Get("Accept-Encoding"); ae != "gzip" {
   118  			t.Errorf("Unexpected Accept-Encoding header: %s", ae)
   119  		}
   120  
   121  		var b bytes.Buffer
   122  		w := gzip.NewWriter(&b)
   123  		for i := 1; i < 1000; i++ {
   124  			w.Write([]byte("<html/>"))
   125  		}
   126  		w.Close()
   127  
   128  		rw.Header().Set("Content-Encoding", "gzip")
   129  		rw.Write(b.Bytes())
   130  	}))
   131  	defer origin.Close()
   132  
   133  	logger, _ := logrustest.NewNullLogger()
   134  	log := logger.WithContext(context.Background())
   135  
   136  	hclBody := hclbody.NewHCLSyntaxBodyWithStringAttr("origin", origin.URL)
   137  
   138  	backend := transport.NewBackend(hclBody, &transport.Config{
   139  		Origin: origin.URL,
   140  	}, nil, log)
   141  
   142  	req := httptest.NewRequest(http.MethodOptions, "http://1.2.3.4/", nil)
   143  	req = req.WithContext(context.WithValue(context.Background(), request.BufferOptions, buffer.Response))
   144  	req.Header.Set("Accept-Encoding", "br, gzip")
   145  	res, err := backend.RoundTrip(req)
   146  	helper.Must(err)
   147  
   148  	if res.ContentLength != 60 {
   149  		t.Errorf("Unexpected C/L: %d", res.ContentLength)
   150  	}
   151  
   152  	n, err := io.Copy(io.Discard, res.Body)
   153  	helper.Must(err)
   154  
   155  	if n != 6993 {
   156  		t.Errorf("Unexpected body length: %d, want: %d", n, 6993)
   157  	}
   158  }
   159  
   160  func TestBackend_RoundTrip_Validation(t *testing.T) {
   161  	helper := test.New(t)
   162  	origin := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
   163  		rw.Header().Set("Content-Type", "text/plain")
   164  		if req.URL.RawQuery == "404" {
   165  			rw.WriteHeader(http.StatusNotFound)
   166  		}
   167  		_, err := rw.Write([]byte("from upstream"))
   168  		helper.Must(err)
   169  	}))
   170  	defer origin.Close()
   171  
   172  	openAPIYAML := helper.NewOpenAPIConf("/pa/./th")
   173  
   174  	tests := []struct {
   175  		name               string
   176  		openapi            *config.OpenAPI
   177  		requestMethod      string
   178  		requestPath        string
   179  		expectedErr        string
   180  		expectedLogMessage string
   181  	}{
   182  		{
   183  			"valid request / valid response",
   184  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   185  			http.MethodGet,
   186  			"/pa/./th",
   187  			"",
   188  			"",
   189  		},
   190  		{
   191  			"valid path: trailing /",
   192  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   193  			http.MethodPost,
   194  			"/pa/./th/",
   195  			"backend error",
   196  			"",
   197  		},
   198  		{
   199  			"invalid path: no path resolution",
   200  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   201  			http.MethodGet,
   202  			"/pa/th",
   203  			"backend error",
   204  			"'GET /pa/th': no matching operation was found",
   205  		},
   206  		{
   207  			"invalid path: double /",
   208  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   209  			http.MethodGet,
   210  			"/pa/.//th",
   211  			"backend error",
   212  			"'GET /pa/.//th': no matching operation was found",
   213  		},
   214  		{ // gorilla/mux router has .UseEncodedPath(), see https://pkg.go.dev/github.com/gorilla/mux#Router.UseEncodedPath
   215  			"URL encoded request",
   216  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   217  			http.MethodGet,
   218  			"/pa%2f%2e%2fth",
   219  			"backend error",
   220  			"'GET /pa/./th': no matching operation was found",
   221  		},
   222  		{ // gorilla/mux router has .UseEncodedPath(), see https://pkg.go.dev/github.com/gorilla/mux#Router.UseEncodedPath
   223  			"URL encoded request, wrong method",
   224  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   225  			http.MethodPost,
   226  			"/pa%2f%2e%2fth",
   227  			"backend error",
   228  			"'POST /pa/./th': no matching operation was found",
   229  		},
   230  		{
   231  			"invalid request",
   232  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   233  			http.MethodPost,
   234  			"/pa/./th",
   235  			"backend error",
   236  			"'POST /pa/./th': method not allowed",
   237  		},
   238  		{
   239  			"invalid request, IgnoreRequestViolations",
   240  			&config.OpenAPI{File: "testdata/upstream.yaml", IgnoreRequestViolations: true, IgnoreResponseViolations: true},
   241  			http.MethodPost,
   242  			"/pa/./th",
   243  			"",
   244  			"'POST /pa/./th': method not allowed",
   245  		},
   246  		{
   247  			"invalid response",
   248  			&config.OpenAPI{File: "testdata/upstream.yaml"},
   249  			http.MethodGet,
   250  			"/pa/./th?404",
   251  			"backend error",
   252  			"status is not supported",
   253  		},
   254  		{
   255  			"invalid response, IgnoreResponseViolations",
   256  			&config.OpenAPI{File: "testdata/upstream.yaml", IgnoreResponseViolations: true},
   257  			http.MethodGet,
   258  			"/pa/./th?404",
   259  			"",
   260  			"status is not supported",
   261  		},
   262  	}
   263  
   264  	logger, hook := test.NewLogger()
   265  	log := logger.WithContext(context.Background())
   266  
   267  	for _, tt := range tests {
   268  		t.Run(tt.name, func(subT *testing.T) {
   269  			hook.Reset()
   270  
   271  			openapiValidatorOptions, err := validation.NewOpenAPIOptionsFromBytes(tt.openapi, openAPIYAML)
   272  			if err != nil {
   273  				subT.Fatal(err)
   274  			}
   275  			content := helper.NewInlineContext(`
   276  				origin = "` + origin.URL + `"
   277  			`)
   278  
   279  			backend := transport.NewBackend(content, &transport.Config{}, &transport.BackendOptions{
   280  				OpenAPI: openapiValidatorOptions,
   281  			}, log)
   282  
   283  			req := httptest.NewRequest(tt.requestMethod, "http://1.2.3.4"+tt.requestPath, nil)
   284  
   285  			_, err = backend.RoundTrip(req)
   286  			if err != nil && tt.expectedErr == "" {
   287  				subT.Fatal(err)
   288  			}
   289  
   290  			if tt.expectedErr != "" && (err == nil || err.Error() != tt.expectedErr) {
   291  				subT.Errorf("\nwant:\t%s\ngot:\t%v", tt.expectedErr, err)
   292  				subT.Log(hook.LastEntry().Message)
   293  			}
   294  
   295  			entry := hook.LastEntry()
   296  			if tt.expectedLogMessage != "" {
   297  				if data, ok := entry.Data["validation"]; ok {
   298  					var found bool
   299  
   300  					for _, errStr := range data.([]string) {
   301  						if errStr != tt.expectedLogMessage {
   302  							subT.Fatalf("\nwant:\t%s\ngot:\t%v", tt.expectedLogMessage, errStr)
   303  						} else {
   304  							found = true
   305  							break
   306  						}
   307  					}
   308  
   309  					if !found {
   310  						for _, errStr := range data.([]string) {
   311  							subT.Log(errStr)
   312  						}
   313  						subT.Errorf("expected matching validation error logs:\n\t%s\n\tgot: nothing", tt.expectedLogMessage)
   314  					}
   315  				}
   316  			}
   317  		})
   318  	}
   319  }
   320  
   321  func TestBackend_director(t *testing.T) {
   322  	helper := test.New(t)
   323  
   324  	log, _ := logrustest.NewNullLogger()
   325  	nullLog := log.WithContext(context.TODO())
   326  
   327  	bgCtx := context.Background()
   328  
   329  	tests := []struct {
   330  		name      string
   331  		inlineCtx string
   332  		path      string
   333  		ctx       context.Context
   334  		expReq    *http.Request
   335  	}{
   336  		{"proxy url settings", `origin = "http://1.2.3.4"`, "", bgCtx, &http.Request{URL: &url.URL{Scheme: "http", Host: "1.2.3.4"}, Host: "example.com"}},
   337  		{"proxy url settings w/hostname", `
   338  			origin = "http://1.2.3.4"
   339  			hostname =  "couper.io"
   340  		`, "", bgCtx, httptest.NewRequest("GET", "http://couper.io", nil)},
   341  		{"proxy url settings w/wildcard ctx", `
   342  			origin = "http://1.2.3.4"
   343  			hostname =  "couper.io"
   344  			path = "/**"
   345  		`, "/peter", context.WithValue(bgCtx, request.Wildcard, "/hans"), httptest.NewRequest("GET", "http://couper.io/hans", nil)},
   346  		{"proxy url settings w/wildcard ctx empty", `
   347  			origin = "http://1.2.3.4"
   348  			hostname =  "couper.io"
   349  			path = "/docs/**"
   350  		`, "", context.WithValue(bgCtx, request.Wildcard, ""), httptest.NewRequest("GET", "http://couper.io/docs", nil)},
   351  		{"proxy url settings w/wildcard ctx empty /w trailing path slash", `
   352  			origin = "http://1.2.3.4"
   353  			hostname =  "couper.io"
   354  			path = "/docs/**"
   355  		`, "/", context.WithValue(bgCtx, request.Wildcard, ""), httptest.NewRequest("GET", "http://couper.io/docs/", nil)},
   356  	}
   357  
   358  	for _, tt := range tests {
   359  		t.Run(tt.name, func(subT *testing.T) {
   360  			hclContext := helper.NewInlineContext(tt.inlineCtx)
   361  
   362  			backend := transport.NewBackend(hclbody.MergeBodies(hclContext,
   363  				hclbody.NewHCLSyntaxBodyWithStringAttr("timeout", "1s"),
   364  				true,
   365  			), &transport.Config{}, nil, nullLog)
   366  
   367  			req := httptest.NewRequest(http.MethodGet, "https://example.com"+tt.path, nil)
   368  			*req = *req.WithContext(tt.ctx)
   369  
   370  			beresp, _ := backend.RoundTrip(req) // implicit director()
   371  			// outreq gets set on error cases
   372  			outreq := beresp.Request
   373  
   374  			attr, _ := hclContext.JustAttributes()
   375  			hostnameExp, ok := attr["hostname"]
   376  
   377  			if !ok && tt.expReq.Host != outreq.Host {
   378  				subT.Errorf("expected same host value, want: %q, got: %q", outreq.Host, tt.expReq.Host)
   379  			} else if ok {
   380  				hostVal, _ := hostnameExp.Expr.Value(eval.NewDefaultContext().HCLContext())
   381  				hostname := seetie.ValueToString(hostVal)
   382  				if hostname != tt.expReq.Host {
   383  					subT.Errorf("expected a configured request host: %q, got: %q", hostname, tt.expReq.Host)
   384  				}
   385  			}
   386  
   387  			if outreq.URL.Path != tt.expReq.URL.Path {
   388  				subT.Errorf("expected path: %q, got: %q", tt.expReq.URL.Path, outreq.URL.Path)
   389  			}
   390  		})
   391  	}
   392  }
   393  
   394  func TestBackend_HealthCheck(t *testing.T) {
   395  	type testCase struct {
   396  		name        string
   397  		health      *config.Health
   398  		expectation config.HealthCheck
   399  	}
   400  
   401  	defaultExpectedStatus := map[int]bool{200: true, 204: true, 301: true}
   402  
   403  	toPtr := func(n uint) *uint { return &n }
   404  
   405  	for _, tc := range []testCase{
   406  		{
   407  			name:   "health check with default values",
   408  			health: &config.Health{},
   409  			expectation: config.HealthCheck{
   410  				FailureThreshold: 2,
   411  				Interval:         time.Second,
   412  				Timeout:          time.Second,
   413  				ExpectedStatus:   defaultExpectedStatus,
   414  				ExpectedText:     "",
   415  				RequestUIDFormat: "common",
   416  			},
   417  		},
   418  		{
   419  			name: "health check with configured values",
   420  			health: &config.Health{
   421  				FailureThreshold: toPtr(42),
   422  				Interval:         "1h",
   423  				Timeout:          "9m",
   424  				Path:             "/gsund??",
   425  				ExpectedStatus:   []int{418},
   426  				ExpectedText:     "roger roger",
   427  			},
   428  			expectation: config.HealthCheck{
   429  				FailureThreshold: 42,
   430  				Interval:         time.Hour,
   431  				Timeout:          9 * time.Minute,
   432  				ExpectedStatus:   map[int]bool{418: true},
   433  				ExpectedText:     "roger roger",
   434  				Request: &http.Request{URL: &url.URL{
   435  					Scheme:   "http",
   436  					Host:     "origin:8080",
   437  					Path:     "/gsund",
   438  					RawQuery: "?",
   439  				}},
   440  				RequestUIDFormat: "common",
   441  			},
   442  		},
   443  		{
   444  			name:   "uninitialised health check",
   445  			health: nil,
   446  			expectation: config.HealthCheck{
   447  				FailureThreshold: 2,
   448  				Interval:         time.Second,
   449  				Timeout:          time.Second,
   450  				ExpectedStatus:   defaultExpectedStatus,
   451  				ExpectedText:     "",
   452  				RequestUIDFormat: "common",
   453  			},
   454  		},
   455  		{
   456  			name: "timeout set indirectly by configured interval",
   457  			health: &config.Health{
   458  				Interval: "10s",
   459  			},
   460  			expectation: config.HealthCheck{
   461  				FailureThreshold: 2,
   462  				Interval:         10 * time.Second,
   463  				Timeout:          10 * time.Second,
   464  				ExpectedStatus:   defaultExpectedStatus,
   465  				ExpectedText:     "",
   466  				RequestUIDFormat: "common",
   467  			},
   468  		},
   469  		{
   470  			name: "timeout bounded by configured interval",
   471  			health: &config.Health{
   472  				Interval: "5s",
   473  				Timeout:  "10s",
   474  			},
   475  			expectation: config.HealthCheck{
   476  				FailureThreshold: 2,
   477  				Interval:         5 * time.Second,
   478  				Timeout:          5 * time.Second,
   479  				ExpectedStatus:   defaultExpectedStatus,
   480  				ExpectedText:     "",
   481  				RequestUIDFormat: "common",
   482  			},
   483  		},
   484  		{
   485  			name: "zero threshold",
   486  			health: &config.Health{
   487  				FailureThreshold: toPtr(0),
   488  			},
   489  			expectation: config.HealthCheck{
   490  				FailureThreshold: 0,
   491  				Interval:         time.Second,
   492  				Timeout:          time.Second,
   493  				ExpectedStatus:   defaultExpectedStatus,
   494  				ExpectedText:     "",
   495  				RequestUIDFormat: "common",
   496  			},
   497  		},
   498  	} {
   499  		t.Run(tc.name, func(subT *testing.T) {
   500  			h := test.New(subT)
   501  
   502  			health, err := config.
   503  				NewHealthCheck("http://origin:8080/foo", tc.health, &config.Couper{
   504  					Settings: config.NewDefaultSettings(),
   505  				})
   506  			h.Must(err)
   507  
   508  			if tc.expectation.Request != nil && tc.expectation.Request.URL != nil {
   509  				if *tc.expectation.Request.URL != *health.Request.URL {
   510  					t.Errorf("Unexpected health check URI:\n\tWant: %#v\n\tGot:  %#v", tc.expectation.Request.URL, health.Request.URL)
   511  				}
   512  				tc.expectation.Request = nil
   513  			}
   514  
   515  			health.Request = nil
   516  
   517  			if diff := cmp.Diff(tc.expectation, *health); diff != "" {
   518  				t.Errorf("Unexpected health options:\n\n%s", diff)
   519  			}
   520  		})
   521  	}
   522  }