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 }