go.uber.org/yarpc@v1.72.1/transport/http/config_test.go (about) 1 // Copyright (c) 2022 Uber Technologies, Inc. 2 // 3 // Permission is hereby granted, free of charge, to any person obtaining a copy 4 // of this software and associated documentation files (the "Software"), to deal 5 // in the Software without restriction, including without limitation the rights 6 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 // copies of the Software, and to permit persons to whom the Software is 8 // furnished to do so, subject to the following conditions: 9 // 10 // The above copyright notice and this permission notice shall be included in 11 // all copies or substantial portions of the Software. 12 // 13 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 // THE SOFTWARE. 20 21 package http 22 23 import ( 24 "crypto/tls" 25 "errors" 26 "fmt" 27 "net/http" 28 "reflect" 29 "testing" 30 "time" 31 32 "github.com/stretchr/testify/assert" 33 "github.com/stretchr/testify/require" 34 yarpctls "go.uber.org/yarpc/api/transport/tls" 35 "go.uber.org/yarpc/yarpcconfig" 36 ) 37 38 type fakeOutboundTLSConfigProvider struct { 39 returnErr error 40 expectedSpiffeIDs []string 41 } 42 43 func (f fakeOutboundTLSConfigProvider) ClientTLSConfig(spiffeIDs []string) (*tls.Config, error) { 44 if f.returnErr != nil { 45 return nil, f.returnErr 46 } 47 if !reflect.DeepEqual(f.expectedSpiffeIDs, spiffeIDs) { 48 return nil, errors.New("spiffe IDs do not match") 49 } 50 return &tls.Config{}, nil 51 } 52 53 func TestTransportSpec(t *testing.T) { 54 // This test is a cross-product of the transport, inbound and outbound 55 // test assertions. 56 // 57 // Configuration, environment variables, and TransportSpec options are all 58 // combined. If any entry had a non-empty wantErrors, any test case with 59 // that entry is expected to fail. 60 // 61 // If the inbound and outbound tests state that they are both empty, the 62 // test case will be skipped because we don't build a transport if there 63 // is no inbound or outbound. 64 65 type attrs map[string]interface{} 66 67 type transportTest struct { 68 desc string // description 69 cfg attrs // transport.http section of the config 70 env map[string]string // environment variables 71 opts []Option // transport spec options 72 73 wantErrors []string 74 wantClient *wantHTTPClient 75 } 76 77 type wantInbound struct { 78 Address string 79 Mux *http.ServeMux 80 MuxPattern string 81 GrabHeaders map[string]struct{} 82 ShutdownTimeout time.Duration 83 TLSMode yarpctls.Mode 84 } 85 86 type inboundTest struct { 87 desc string // description 88 cfg attrs // inbounds.http section of the config 89 env map[string]string // environment variables 90 opts []Option // transport spec options 91 92 empty bool // whether this test case is empty 93 94 wantErrors []string 95 wantInbound *wantInbound 96 } 97 98 type wantOutbound struct { 99 URLTemplate string 100 Headers http.Header 101 TLSConfig bool 102 } 103 104 type outboundTest struct { 105 desc string // description 106 cfg attrs // outbounds section of the config 107 env map[string]string // environment variables 108 opts []Option // transport spec options 109 110 empty bool // whether this test case is empty 111 112 wantErrors []string 113 wantOutbounds map[string]wantOutbound 114 } 115 116 transportTests := []transportTest{ 117 { 118 desc: "no transport config", 119 wantClient: &wantHTTPClient{ 120 KeepAlive: 30 * time.Second, 121 MaxIdleConnsPerHost: 2, 122 ConnTimeout: defaultConnTimeout, 123 IdleConnTimeout: defaultIdleConnTimeout, 124 }, 125 }, 126 { 127 desc: "transport options", 128 opts: []Option{ 129 KeepAlive(5 * time.Second), 130 MaxIdleConnsPerHost(42), 131 }, 132 wantClient: &wantHTTPClient{ 133 KeepAlive: 5 * time.Second, 134 MaxIdleConnsPerHost: 42, 135 ConnTimeout: defaultConnTimeout, 136 IdleConnTimeout: defaultIdleConnTimeout, 137 }, 138 }, 139 { 140 desc: "explicit transport config", 141 cfg: attrs{ 142 "keepAlive": "5s", 143 "maxIdleConns": 1, 144 "maxIdleConnsPerHost": 2, 145 "idleConnTimeout": "5s", 146 "connTimeout": "1s", 147 "disableKeepAlives": true, 148 "disableCompression": true, 149 "responseHeaderTimeout": "1s", 150 }, 151 wantClient: &wantHTTPClient{ 152 KeepAlive: 5 * time.Second, 153 MaxIdleConns: 1, 154 MaxIdleConnsPerHost: 2, 155 IdleConnTimeout: 5 * time.Second, 156 ConnTimeout: 1 * time.Second, 157 DisableKeepAlives: true, 158 DisableCompression: true, 159 ResponseHeaderTimeout: 1 * time.Second, 160 }, 161 }, 162 } 163 164 serveMux := http.NewServeMux() 165 166 inboundTests := []inboundTest{ 167 {desc: "no inbound", empty: true}, 168 { 169 desc: "inbound without address", 170 cfg: attrs{}, 171 wantErrors: []string{"inbound address is required"}, 172 }, 173 { 174 desc: "simple inbound", 175 cfg: attrs{"address": ":8080"}, 176 wantInbound: &wantInbound{Address: ":8080", ShutdownTimeout: defaultShutdownTimeout}, 177 }, 178 { 179 desc: "inbound tls", 180 cfg: attrs{ 181 "address": ":8080", 182 "tls": attrs{ 183 "mode": "permissive", 184 }, 185 }, 186 wantInbound: &wantInbound{Address: ":8080", ShutdownTimeout: defaultShutdownTimeout, TLSMode: yarpctls.Permissive}, 187 }, 188 { 189 desc: "inbound tls mode overridden by inbound option", 190 cfg: attrs{ 191 "address": ":8080", 192 "tls": attrs{ 193 "mode": "enforced", 194 }, 195 }, 196 opts: []Option{InboundTLSMode(yarpctls.Permissive)}, 197 wantInbound: &wantInbound{Address: ":8080", ShutdownTimeout: defaultShutdownTimeout, TLSMode: yarpctls.Permissive}, 198 }, 199 { 200 desc: "simple inbound with grab headers", 201 cfg: attrs{"address": ":8080", "grabHeaders": []string{"x-foo", "x-bar"}}, 202 wantInbound: &wantInbound{ 203 Address: ":8080", 204 GrabHeaders: map[string]struct{}{"x-foo": {}, "x-bar": {}}, 205 ShutdownTimeout: defaultShutdownTimeout, 206 }, 207 }, 208 { 209 desc: "inbound interpolation", 210 cfg: attrs{"address": "${HOST:}:${PORT}"}, 211 env: map[string]string{"HOST": "127.0.0.1", "PORT": "80"}, 212 wantInbound: &wantInbound{Address: "127.0.0.1:80", ShutdownTimeout: defaultShutdownTimeout}, 213 }, 214 { 215 desc: "serve mux", 216 cfg: attrs{"address": ":8080"}, 217 opts: []Option{ 218 Mux("/yarpc", serveMux), 219 }, 220 wantInbound: &wantInbound{ 221 Address: ":8080", 222 Mux: serveMux, 223 MuxPattern: "/yarpc", 224 ShutdownTimeout: defaultShutdownTimeout, 225 }, 226 }, 227 { 228 desc: "shutdown timeout", 229 cfg: attrs{"address": ":8080", "shutdownTimeout": "1s"}, 230 wantInbound: &wantInbound{Address: ":8080", ShutdownTimeout: time.Second}, 231 }, 232 { 233 desc: "shutdown timeout 0", 234 cfg: attrs{"address": ":8080", "shutdownTimeout": "0s"}, 235 wantInbound: &wantInbound{Address: ":8080", ShutdownTimeout: 0}, 236 }, 237 { 238 desc: "shutdown timeout err", 239 cfg: attrs{"address": ":8080", "shutdownTimeout": "-1s"}, 240 wantErrors: []string{`shutdownTimeout must not be negative, got: "-1s"`}, 241 }, 242 } 243 244 outboundTests := []outboundTest{ 245 {desc: "no outbound", empty: true}, 246 { 247 desc: "simple outbound", 248 cfg: attrs{ 249 "myservice": attrs{ 250 "http": attrs{"url": "http://localhost:4040/yarpc"}, 251 }, 252 }, 253 wantOutbounds: map[string]wantOutbound{ 254 "myservice": { 255 URLTemplate: "http://localhost:4040/yarpc", 256 }, 257 }, 258 }, 259 { 260 desc: "outbound interpolation", 261 env: map[string]string{"ADDR": "127.0.0.1:80"}, 262 cfg: attrs{ 263 "myservice": attrs{ 264 "http": attrs{"url": "http://${ADDR}/yarpc"}, 265 }, 266 }, 267 wantOutbounds: map[string]wantOutbound{ 268 "myservice": { 269 URLTemplate: "http://127.0.0.1:80/yarpc", 270 }, 271 }, 272 }, 273 { 274 desc: "outbound url template option", 275 opts: []Option{ 276 URLTemplate("http://127.0.0.1:8080/yarpc"), 277 }, 278 cfg: attrs{ 279 "myservice": attrs{ 280 "http": attrs{"peer": "127.0.0.1:8888"}, 281 }, 282 }, 283 wantOutbounds: map[string]wantOutbound{ 284 "myservice": { 285 URLTemplate: "http://127.0.0.1:8080/yarpc", 286 }, 287 }, 288 }, 289 { 290 desc: "outbound url template option override", 291 opts: []Option{ 292 URLTemplate("http://127.0.0.1:8080/yarpc"), 293 }, 294 cfg: attrs{ 295 "myservice": attrs{ 296 "http": attrs{ 297 "url": "http://host/yarpc/v1", 298 "peer": "127.0.0.1:8888", 299 }, 300 }, 301 }, 302 wantOutbounds: map[string]wantOutbound{ 303 "myservice": { 304 URLTemplate: "http://host/yarpc/v1", 305 }, 306 }, 307 }, 308 { 309 desc: "outbound header options", 310 opts: []Option{ 311 AddHeader("X-Token", "token-1"), 312 AddHeader("X-Token-2", "token-2"), 313 AddHeader("X-Token", "token-3"), 314 }, 315 cfg: attrs{ 316 "myservice": attrs{ 317 "http": attrs{"url": "http://localhost/"}, 318 }, 319 }, 320 wantOutbounds: map[string]wantOutbound{ 321 "myservice": { 322 URLTemplate: "http://localhost/", 323 Headers: http.Header{ 324 "X-Token": {"token-1", "token-3"}, 325 "X-Token-2": {"token-2"}, 326 }, 327 }, 328 }, 329 }, 330 { 331 desc: "outbound header config", 332 opts: []Option{ 333 AddHeader("X-Token", "token-1"), 334 }, 335 cfg: attrs{ 336 "myservice": attrs{ 337 "http": attrs{ 338 "url": "http://localhost/", 339 "addHeaders": attrs{ 340 "x-token": "token-3", 341 "X-Token-2": "token-2", 342 }, 343 }, 344 }, 345 }, 346 wantOutbounds: map[string]wantOutbound{ 347 "myservice": { 348 URLTemplate: "http://localhost/", 349 Headers: http.Header{ 350 "X-Token": {"token-1", "token-3"}, 351 "X-Token-2": {"token-2"}, 352 }, 353 }, 354 }, 355 }, 356 { 357 desc: "outbound header config with peer", 358 cfg: attrs{ 359 "myservice": attrs{ 360 "http": attrs{ 361 "url": "http://localhost/yarpc", 362 "peer": "127.0.0.1:8080", 363 "addHeaders": attrs{"x-token": "token"}, 364 }, 365 }, 366 }, 367 wantOutbounds: map[string]wantOutbound{ 368 "myservice": { 369 URLTemplate: "http://localhost/yarpc", 370 Headers: http.Header{ 371 "X-Token": {"token"}, 372 }, 373 }, 374 }, 375 }, 376 { 377 desc: "outbound peer build error", 378 cfg: attrs{ 379 "myservice": attrs{ 380 "http": attrs{ 381 "least-pending": []string{ 382 "127.0.0.1:8080", 383 "127.0.0.1:8081", 384 "127.0.0.1:8082", 385 }, 386 }, 387 }, 388 }, 389 wantErrors: []string{ 390 "cannot configure peer chooser for HTTP outbound", 391 `failed to read attribute "least-pending"`, 392 }, 393 }, 394 { 395 desc: "unknown preset", 396 cfg: attrs{ 397 "myservice": attrs{ 398 "http": attrs{"with": "derp"}, 399 }, 400 }, 401 wantErrors: []string{ 402 `failed to configure unary outbound for "myservice":`, 403 "cannot configure peer chooser for HTTP outbound:", 404 `no recognized peer chooser preset "derp"`, 405 }, 406 }, 407 { 408 desc: "simple TLS outbound", 409 cfg: attrs{ 410 "myservice": attrs{ 411 TransportName: attrs{ 412 "url": "http://localhost/yarpc", 413 "tls": attrs{ 414 "mode": yarpctls.Enforced, 415 "spiffe-ids": []string{"spiffe-test-1"}, 416 }, 417 }, 418 }, 419 }, 420 opts: []Option{OutboundTLSConfigProvider(&fakeOutboundTLSConfigProvider{ 421 expectedSpiffeIDs: []string{"spiffe-test-1"}, 422 })}, 423 wantOutbounds: map[string]wantOutbound{ 424 "myservice": { 425 URLTemplate: "https://localhost/yarpc", 426 TLSConfig: true, 427 }, 428 }, 429 }, 430 { 431 desc: "TLS outbound without spiffe id", 432 cfg: attrs{ 433 "myservice": attrs{ 434 TransportName: attrs{ 435 "url": "http://localhost/yarpc", 436 "tls": attrs{ 437 "mode": yarpctls.Enforced, 438 }, 439 }, 440 }, 441 }, 442 opts: []Option{OutboundTLSConfigProvider(&fakeOutboundTLSConfigProvider{})}, 443 wantOutbounds: map[string]wantOutbound{ 444 "myservice": { 445 URLTemplate: "https://localhost/yarpc", 446 TLSConfig: true, 447 }, 448 }, 449 }, 450 { 451 desc: "fail TLS outbound with invalid tls mode", 452 cfg: attrs{ 453 "myservice": attrs{ 454 TransportName: attrs{ 455 "url": "http://localhost/yarpc", 456 "tls": attrs{ 457 "mode": yarpctls.Permissive, 458 }, 459 }, 460 }, 461 }, 462 opts: []Option{OutboundTLSConfigProvider(&fakeOutboundTLSConfigProvider{})}, 463 wantErrors: []string{"outbound does not support permissive TLS mode"}, 464 }, 465 { 466 desc: "fail TLS outbound when tls config provider returns error", 467 cfg: attrs{ 468 "myservice": attrs{ 469 TransportName: attrs{ 470 "url": "http://localhost/yarpc", 471 "tls": attrs{ 472 "mode": yarpctls.Enforced, 473 "spiffe-ids": []string{"test-spiffe"}, 474 }, 475 }, 476 }, 477 }, 478 opts: []Option{OutboundTLSConfigProvider(&fakeOutboundTLSConfigProvider{returnErr: errors.New("test error")})}, 479 wantErrors: []string{"test error"}, 480 }, 481 { 482 desc: "fail TLS outbound without outbound tls config provider", 483 cfg: attrs{ 484 "myservice": attrs{ 485 TransportName: attrs{ 486 "url": "http://localhost/yarpc", 487 "tls": attrs{ 488 "mode": yarpctls.Enforced, 489 "spiffe-ids": []string{"test-spiffe"}, 490 }, 491 }, 492 }, 493 }, 494 wantErrors: []string{"outbound TLS enforced but outbound TLS config provider is nil"}, 495 }, 496 } 497 498 runTest := func(t *testing.T, trans transportTest, inbound inboundTest, outbound outboundTest) { 499 env := make(map[string]string) 500 for k, v := range trans.env { 501 env[k] = v 502 } 503 for k, v := range inbound.env { 504 _, ok := env[k] 505 require.False(t, ok, 506 "invalid test: environment variable %q is defined multiple times", k) 507 env[k] = v 508 } 509 for k, v := range outbound.env { 510 _, ok := env[k] 511 require.False(t, ok, 512 "invalid test: environment variable %q is defined multiple times", k) 513 env[k] = v 514 } 515 configurator := yarpcconfig.New(yarpcconfig.InterpolationResolver(mapResolver(env))) 516 517 opts := append(append(trans.opts, inbound.opts...), outbound.opts...) 518 if trans.wantClient != nil { 519 opts = append(opts, useFakeBuildClient(t, trans.wantClient)) 520 } 521 err := configurator.RegisterTransport(TransportSpec(opts...)) 522 require.NoError(t, err, "failed to register transport spec") 523 524 cfgData := make(attrs) 525 if trans.cfg != nil { 526 cfgData["transports"] = attrs{"http": trans.cfg} 527 } 528 if inbound.cfg != nil { 529 cfgData["inbounds"] = attrs{"http": inbound.cfg} 530 } 531 if outbound.cfg != nil { 532 cfgData["outbounds"] = outbound.cfg 533 } 534 cfg, err := configurator.LoadConfig("foo", cfgData) 535 536 wantErrors := append(append(trans.wantErrors, inbound.wantErrors...), outbound.wantErrors...) 537 if len(wantErrors) > 0 { 538 require.Error(t, err, "expected failure while loading config %+v", cfgData) 539 for _, msg := range wantErrors { 540 assert.Contains(t, err.Error(), msg) 541 } 542 return 543 } 544 545 require.NoError(t, err, "expected success while loading config %+v", cfgData) 546 547 if want := inbound.wantInbound; want != nil { 548 assert.Len(t, cfg.Inbounds, 1, "expected exactly one inbound in %+v", cfgData) 549 ib, ok := cfg.Inbounds[0].(*Inbound) 550 if assert.True(t, ok, "expected *Inbound, got %T", cfg.Inbounds[0]) { 551 assert.Equal(t, want.Address, ib.addr, "inbound address should match") 552 assert.Equal(t, want.MuxPattern, ib.muxPattern, 553 "inbound mux pattern should match") 554 // == because we want it to be the same object 555 assert.True(t, want.Mux == ib.mux, "inbound mux should match") 556 // this has to be done because assert.Equal returns false if one map 557 // is nil and the other is empty 558 if len(want.GrabHeaders) > 0 { 559 assert.Equal(t, want.GrabHeaders, ib.grabHeaders, "inbound grab headers should match") 560 } else { 561 assert.Empty(t, ib.grabHeaders) 562 } 563 assert.Equal(t, want.ShutdownTimeout, ib.shutdownTimeout, "shutdownTimeout should match") 564 assert.Equal(t, "foo", ib.transport.serviceName, "service name must match") 565 assert.Equal(t, want.TLSMode, ib.tlsMode, "tlsMode should match") 566 } 567 } 568 569 for svc, want := range outbound.wantOutbounds { 570 ob, ok := cfg.Outbounds[svc].Unary.(*Outbound) 571 if assert.True(t, ok, "expected *Outbound for %q, got %T", svc, cfg.Outbounds[svc].Unary) { 572 // Verify that we install a oneway too 573 _, ok := cfg.Outbounds[svc].Oneway.(*Outbound) 574 assert.True(t, ok, "expected *Outbound for %q oneway, got %T", svc, cfg.Outbounds[svc].Oneway) 575 576 assert.Equal(t, want.URLTemplate, ob.urlTemplate.String(), "outbound URLTemplate should match") 577 assert.Equal(t, want.Headers, ob.headers, "outbound headers should match") 578 assert.Equal(t, svc, ob.destServiceName, "outbound destination service name must match") 579 assert.Equal(t, want.TLSConfig, ob.tlsConfig != nil, "unexpected outbound tls config") 580 } 581 582 } 583 } 584 585 for _, transTT := range transportTests { 586 for _, inboundTT := range inboundTests { 587 for _, outboundTT := range outboundTests { 588 // Special case: No inbounds or outbounds so we have nothing 589 // to test. 590 if inboundTT.empty && outboundTT.empty { 591 continue 592 } 593 594 desc := fmt.Sprintf("%v/%v/%v", transTT.desc, inboundTT.desc, outboundTT.desc) 595 t.Run(desc, func(t *testing.T) { 596 runTest(t, transTT, inboundTT, outboundTT) 597 }) 598 } 599 } 600 } 601 } 602 603 func mapResolver(m map[string]string) func(string) (string, bool) { 604 return func(k string) (v string, ok bool) { 605 if m != nil { 606 v, ok = m[k] 607 } 608 return 609 } 610 } 611 612 type wantHTTPClient struct { 613 KeepAlive time.Duration 614 MaxIdleConns int 615 MaxIdleConnsPerHost int 616 IdleConnTimeout time.Duration 617 DisableKeepAlives bool 618 DisableCompression bool 619 ResponseHeaderTimeout time.Duration 620 ConnTimeout time.Duration 621 } 622 623 // useFakeBuildClient verifies the configuration we use to build an HTTP 624 // client. 625 func useFakeBuildClient(t *testing.T, want *wantHTTPClient) TransportOption { 626 return buildClient(func(options *transportOptions) *http.Client { 627 assert.Equal(t, want.KeepAlive, options.keepAlive, "http.Client: KeepAlive should match") 628 assert.Equal(t, want.MaxIdleConns, options.maxIdleConns, "http.Client: MaxIdleConns should match") 629 assert.Equal(t, want.MaxIdleConnsPerHost, options.maxIdleConnsPerHost, "http.Client: MaxIdleConnsPerHost should match") 630 assert.Equal(t, want.IdleConnTimeout, options.idleConnTimeout, "http.Client: IdleConnTimeout should match") 631 assert.Equal(t, want.DisableKeepAlives, options.disableKeepAlives, "http.Client: DisableKeepAlives should match") 632 assert.Equal(t, want.DisableCompression, options.disableCompression, "http.Client: DisableCompression should match") 633 assert.Equal(t, want.ResponseHeaderTimeout, options.responseHeaderTimeout, "http.Client: ResponseHeaderTimeout should match") 634 assert.Equal(t, want.ConnTimeout, options.connTimeout, "http.Client: ConnTimeout should match") 635 return buildHTTPClient(options) 636 }) 637 }