diff --git a/conformance/tests/httproute-request-redirect.go b/conformance/tests/httproute-request-redirect.go new file mode 100644 index 0000000000..2453659a0d --- /dev/null +++ b/conformance/tests/httproute-request-redirect.go @@ -0,0 +1,92 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/roundtripper" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteRequestRedirect) +} + +var HTTPRouteRequestRedirect = suite.ConformanceTest{ + ShortName: "HTTPRouteRequestRedirect", + Description: "An HTTPRoute with hostname and statusCode redirect filters", + Manifests: []string{"tests/httproute-request-redirect.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "request-redirect", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + testCases := []http.ExpectedResponse{{ + Request: http.Request{ + Path: "/hostname-redirect", + UnfollowRedirect: true, + }, + Response: http.Response{ + StatusCode: 302, + }, + RedirectRequest: &roundtripper.RedirectRequest{ + Hostname: "example.org", + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, { + Request: http.Request{ + Path: "/status-code-301", + UnfollowRedirect: true, + }, + Response: http.Response{ + StatusCode: 301, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, { + Request: http.Request{ + Path: "/host-and-status", + UnfollowRedirect: true, + }, + Response: http.Response{ + StatusCode: 301, + }, + RedirectRequest: &roundtripper.RedirectRequest{ + Hostname: "example.org", + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, + } + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + tc := testCases[i] + t.Run(tc.GetTestCaseName(i), func(t *testing.T) { + t.Parallel() + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, tc) + }) + } + }, +} diff --git a/conformance/tests/httproute-request-redirect.yaml b/conformance/tests/httproute-request-redirect.yaml new file mode 100644 index 0000000000..fc5ad0830c --- /dev/null +++ b/conformance/tests/httproute-request-redirect.yaml @@ -0,0 +1,40 @@ +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: HTTPRoute +metadata: + name: request-redirect + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /hostname-redirect + filters: + - type: RequestRedirect + requestRedirect: + hostname: example.org + backendRefs: + - name: infra-backend-v1 + port: 8080 + - matches: + - path: + type: PathPrefix + value: /status-code-301 + filters: + - type: RequestRedirect + requestRedirect: + statusCode: 301 + - matches: + - path: + type: PathPrefix + value: /host-and-status + filters: + - type: RequestRedirect + requestRedirect: + statusCode: 301 + hostname: example.org + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/conformance/utils/http/http.go b/conformance/utils/http/http.go index 1e08504a66..a1409e79f6 100644 --- a/conformance/utils/http/http.go +++ b/conformance/utils/http/http.go @@ -38,6 +38,8 @@ type ExpectedResponse struct { // expected to match Request. ExpectedRequest *ExpectedRequest + RedirectRequest *roundtripper.RedirectRequest + // BackendSetResponseHeaders is a set of headers // the echoserver should set in its response. BackendSetResponseHeaders map[string]string @@ -57,10 +59,11 @@ type ExpectedResponse struct { // that echoserver received the expected request. Note that multiple header // values can be provided, as a comma-separated value. type Request struct { - Host string - Method string - Path string - Headers map[string]string + Host string + Method string + Path string + Headers map[string]string + UnfollowRedirect bool } // ExpectedRequest defines expected properties of a request that reaches a backend. @@ -106,11 +109,12 @@ func MakeRequestAndExpectEventuallyConsistentResponse(t *testing.T, r roundtripp path, query, _ := strings.Cut(expected.Request.Path, "?") req := roundtripper.Request{ - Method: expected.Request.Method, - Host: expected.Request.Host, - URL: url.URL{Scheme: "http", Host: gwAddr, Path: path, RawQuery: query}, - Protocol: "HTTP", - Headers: map[string][]string{}, + Method: expected.Request.Method, + Host: expected.Request.Host, + URL: url.URL{Scheme: "http", Host: gwAddr, Path: path, RawQuery: query}, + Protocol: "HTTP", + Headers: map[string][]string{}, + UnfollowRedirect: expected.Request.UnfollowRedirect, } if expected.Request.Headers != nil { @@ -278,6 +282,13 @@ func CompareRequest(cReq *roundtripper.CapturedRequest, cRes *roundtripper.Captu if !strings.HasPrefix(cReq.Pod, expected.Backend) { return fmt.Errorf("expected pod name to start with %s, got %s", expected.Backend, cReq.Pod) } + } else if roundtripper.IsRedirect(cRes.StatusCode) { + if expected.RedirectRequest == nil { + return nil + } + if expected.RedirectRequest.Hostname != cRes.RedirectRequest.Hostname { + return fmt.Errorf("expected redirected hostname to be %s, got %s", expected.RedirectRequest.Hostname, cRes.RedirectRequest.Hostname) + } } return nil } diff --git a/conformance/utils/roundtripper/roundtripper.go b/conformance/utils/roundtripper/roundtripper.go index 40052f6405..a0b37371b8 100644 --- a/conformance/utils/roundtripper/roundtripper.go +++ b/conformance/utils/roundtripper/roundtripper.go @@ -37,11 +37,12 @@ type RoundTripper interface { // Request is the primary input for making a request. type Request struct { - URL url.URL - Host string - Protocol string - Method string - Headers map[string][]string + URL url.URL + Host string + Protocol string + Method string + Headers map[string][]string + UnfollowRedirect bool } // CapturedRequest contains request metadata captured from an echoserver @@ -57,12 +58,21 @@ type CapturedRequest struct { Pod string `json:"pod"` } +// RedirectRequest contains a follow up request metadata captured from a redirect +// response. +type RedirectRequest struct { + Scheme string + Hostname string + Port string +} + // CapturedResponse contains response metadata. type CapturedResponse struct { - StatusCode int - ContentLength int64 - Protocol string - Headers map[string][]string + StatusCode int + ContentLength int64 + Protocol string + Headers map[string][]string + RedirectRequest *RedirectRequest } // DefaultRoundTripper is the default implementation of a RoundTripper. It will @@ -80,6 +90,12 @@ func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedReques cReq := &CapturedRequest{} client := http.DefaultClient + if request.UnfollowRedirect { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + } + method := "GET" if request.Method != "" { method = request.Method @@ -144,9 +160,37 @@ func (d *DefaultRoundTripper) CaptureRoundTrip(request Request) (*CapturedReques Headers: resp.Header, } + if IsRedirect(resp.StatusCode) { + redirectURL, err := resp.Location() + if err != nil { + return nil, nil, err + } + cRes.RedirectRequest = &RedirectRequest{ + Scheme: redirectURL.Scheme, + Hostname: redirectURL.Hostname(), + Port: redirectURL.Port(), + } + } + return cReq, cRes, nil } +// IsRedirect returns true if a given status code is a redirect code. +func IsRedirect(statusCode int) bool { + switch statusCode { + case http.StatusMultipleChoices, + http.StatusMovedPermanently, + http.StatusFound, + http.StatusSeeOther, + http.StatusNotModified, + http.StatusUseProxy, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect: + return true + } + return false +} + var startLineRegex = regexp.MustCompile(`(?m)^`) func formatDump(data []byte, prefix string) string {