// +build go1.8

/*
Copyright 2016 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 net

import (
	"bufio"
	"bytes"
	"crypto/tls"
	"fmt"
	"io"
	"io/ioutil"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"reflect"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"k8s.io/apimachinery/pkg/util/wait"
)

func TestGetClientIP(t *testing.T) {
	ipString := "10.0.0.1"
	ip := net.ParseIP(ipString)
	invalidIPString := "invalidIPString"
	testCases := []struct {
		Request    http.Request
		ExpectedIP net.IP
	}{
		{
			Request: http.Request{},
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Real-Ip": {ipString},
				},
			},
			ExpectedIP: ip,
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Real-Ip": {invalidIPString},
				},
			},
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Forwarded-For": {ipString},
				},
			},
			ExpectedIP: ip,
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Forwarded-For": {invalidIPString},
				},
			},
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Forwarded-For": {invalidIPString + "," + ipString},
				},
			},
			ExpectedIP: ip,
		},
		{
			Request: http.Request{
				// RemoteAddr is in the form host:port
				RemoteAddr: ipString + ":1234",
			},
			ExpectedIP: ip,
		},
		{
			Request: http.Request{
				RemoteAddr: invalidIPString,
			},
		},
		{
			Request: http.Request{
				Header: map[string][]string{
					"X-Forwarded-For": {invalidIPString},
				},
				// RemoteAddr is in the form host:port
				RemoteAddr: ipString,
			},
			ExpectedIP: ip,
		},
	}

	for i, test := range testCases {
		if a, e := GetClientIP(&test.Request), test.ExpectedIP; reflect.DeepEqual(e, a) != true {
			t.Fatalf("test case %d failed. expected: %v, actual: %v", i, e, a)
		}
	}
}

func TestAppendForwardedForHeader(t *testing.T) {
	testCases := []struct {
		addr, forwarded, expected string
	}{
		{"1.2.3.4:8000", "", "1.2.3.4"},
		{"1.2.3.4:8000", "8.8.8.8", "8.8.8.8, 1.2.3.4"},
		{"1.2.3.4:8000", "8.8.8.8, 1.2.3.4", "8.8.8.8, 1.2.3.4, 1.2.3.4"},
		{"1.2.3.4:8000", "foo,bar", "foo,bar, 1.2.3.4"},
	}
	for i, test := range testCases {
		req := &http.Request{
			RemoteAddr: test.addr,
			Header:     make(http.Header),
		}
		if test.forwarded != "" {
			req.Header.Set("X-Forwarded-For", test.forwarded)
		}

		AppendForwardedForHeader(req)
		actual := req.Header.Get("X-Forwarded-For")
		if actual != test.expected {
			t.Errorf("[%d] Expected %q, Got %q", i, test.expected, actual)
		}
	}
}

func TestProxierWithNoProxyCIDR(t *testing.T) {
	testCases := []struct {
		name    string
		noProxy string
		url     string

		expectedDelegated bool
	}{
		{
			name:              "no env",
			url:               "https://192.168.143.1/api",
			expectedDelegated: true,
		},
		{
			name:              "no cidr",
			noProxy:           "192.168.63.1",
			url:               "https://192.168.143.1/api",
			expectedDelegated: true,
		},
		{
			name:              "hostname",
			noProxy:           "192.168.63.0/24,192.168.143.0/24",
			url:               "https://my-hostname/api",
			expectedDelegated: true,
		},
		{
			name:              "match second cidr",
			noProxy:           "192.168.63.0/24,192.168.143.0/24",
			url:               "https://192.168.143.1/api",
			expectedDelegated: false,
		},
		{
			name:              "match second cidr with host:port",
			noProxy:           "192.168.63.0/24,192.168.143.0/24",
			url:               "https://192.168.143.1:8443/api",
			expectedDelegated: false,
		},
		{
			name:              "IPv6 cidr",
			noProxy:           "2001:db8::/48",
			url:               "https://[2001:db8::1]/api",
			expectedDelegated: false,
		},
		{
			name:              "IPv6+port cidr",
			noProxy:           "2001:db8::/48",
			url:               "https://[2001:db8::1]:8443/api",
			expectedDelegated: false,
		},
		{
			name:              "IPv6, not matching cidr",
			noProxy:           "2001:db8::/48",
			url:               "https://[2001:db8:1::1]/api",
			expectedDelegated: true,
		},
		{
			name:              "IPv6+port, not matching cidr",
			noProxy:           "2001:db8::/48",
			url:               "https://[2001:db8:1::1]:8443/api",
			expectedDelegated: true,
		},
	}

	for _, test := range testCases {
		os.Setenv("NO_PROXY", test.noProxy)
		actualDelegated := false
		proxyFunc := NewProxierWithNoProxyCIDR(func(req *http.Request) (*url.URL, error) {
			actualDelegated = true
			return nil, nil
		})

		req, err := http.NewRequest("GET", test.url, nil)
		if err != nil {
			t.Errorf("%s: unexpected err: %v", test.name, err)
			continue
		}
		if _, err := proxyFunc(req); err != nil {
			t.Errorf("%s: unexpected err: %v", test.name, err)
			continue
		}

		if test.expectedDelegated != actualDelegated {
			t.Errorf("%s: expected %v, got %v", test.name, test.expectedDelegated, actualDelegated)
			continue
		}
	}
}

type fakeTLSClientConfigHolder struct {
	called bool
}

func (f *fakeTLSClientConfigHolder) TLSClientConfig() *tls.Config {
	f.called = true
	return nil
}
func (f *fakeTLSClientConfigHolder) RoundTrip(*http.Request) (*http.Response, error) {
	return nil, nil
}

func TestTLSClientConfigHolder(t *testing.T) {
	rt := &fakeTLSClientConfigHolder{}
	TLSClientConfig(rt)

	if !rt.called {
		t.Errorf("didn't find tls config")
	}
}

func TestJoinPreservingTrailingSlash(t *testing.T) {
	tests := []struct {
		a    string
		b    string
		want string
	}{
		// All empty
		{"", "", ""},

		// Empty a
		{"", "/", "/"},
		{"", "foo", "foo"},
		{"", "/foo", "/foo"},
		{"", "/foo/", "/foo/"},

		// Empty b
		{"/", "", "/"},
		{"foo", "", "foo"},
		{"/foo", "", "/foo"},
		{"/foo/", "", "/foo/"},

		// Both populated
		{"/", "/", "/"},
		{"foo", "foo", "foo/foo"},
		{"/foo", "/foo", "/foo/foo"},
		{"/foo/", "/foo/", "/foo/foo/"},
	}
	for _, tt := range tests {
		name := fmt.Sprintf("%q+%q=%q", tt.a, tt.b, tt.want)
		t.Run(name, func(t *testing.T) {
			if got := JoinPreservingTrailingSlash(tt.a, tt.b); got != tt.want {
				t.Errorf("JoinPreservingTrailingSlash() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestConnectWithRedirects(t *testing.T) {
	tests := []struct {
		desc              string
		redirects         []string
		method            string // initial request method, empty == GET
		expectError       bool
		expectedRedirects int
		newPort           bool // special case different port test
	}{{
		desc:              "relative redirects allowed",
		redirects:         []string{"/ok"},
		expectedRedirects: 1,
	}, {
		desc:              "redirects to the same host are allowed",
		redirects:         []string{"http://HOST/ok"}, // HOST replaced with server address in test
		expectedRedirects: 1,
	}, {
		desc:              "POST redirects to GET",
		method:            http.MethodPost,
		redirects:         []string{"/ok"},
		expectedRedirects: 1,
	}, {
		desc:              "PUT redirects to GET",
		method:            http.MethodPut,
		redirects:         []string{"/ok"},
		expectedRedirects: 1,
	}, {
		desc:              "DELETE redirects to GET",
		method:            http.MethodDelete,
		redirects:         []string{"/ok"},
		expectedRedirects: 1,
	}, {
		desc:              "9 redirects are allowed",
		redirects:         []string{"/1", "/2", "/3", "/4", "/5", "/6", "/7", "/8", "/9"},
		expectedRedirects: 9,
	}, {
		desc:        "10 redirects are forbidden",
		redirects:   []string{"/1", "/2", "/3", "/4", "/5", "/6", "/7", "/8", "/9", "/10"},
		expectError: true,
	}, {
		desc:        "redirect to different host are prevented",
		redirects:   []string{"http://example.com/foo"},
		expectError: true,
	}, {
		desc:        "multiple redirect to different host forbidden",
		redirects:   []string{"/1", "/2", "/3", "http://example.com/foo"},
		expectError: true,
	}, {
		desc:              "redirect to different port is allowed",
		redirects:         []string{"http://HOST/foo"},
		expectedRedirects: 1,
		newPort:           true,
	}}

	const resultString = "Test output"
	for _, test := range tests {
		t.Run(test.desc, func(t *testing.T) {
			redirectCount := 0
			s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
				// Verify redirect request.
				if redirectCount > 0 {
					expectedURL, err := url.Parse(test.redirects[redirectCount-1])
					require.NoError(t, err, "test URL error")
					assert.Equal(t, req.URL.Path, expectedURL.Path, "unknown redirect path")
					assert.Equal(t, http.MethodGet, req.Method, "redirects must always be GET")
				}
				if redirectCount < len(test.redirects) {
					http.Redirect(w, req, test.redirects[redirectCount], http.StatusFound)
					redirectCount++
				} else if redirectCount == len(test.redirects) {
					w.Write([]byte(resultString))
				} else {
					t.Errorf("unexpected number of redirects %d to %s", redirectCount, req.URL.String())
				}
			}))
			defer s.Close()

			u, err := url.Parse(s.URL)
			require.NoError(t, err, "Error parsing server URL")
			host := u.Host

			// Special case new-port test with a secondary server.
			if test.newPort {
				s2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
					w.Write([]byte(resultString))
				}))
				defer s2.Close()
				u2, err := url.Parse(s2.URL)
				require.NoError(t, err, "Error parsing secondary server URL")

				// Sanity check: secondary server uses same hostname, different port.
				require.Equal(t, u.Hostname(), u2.Hostname(), "sanity check: same hostname")
				require.NotEqual(t, u.Port(), u2.Port(), "sanity check: different port")

				// Redirect to the secondary server.
				host = u2.Host

			}

			// Update redirect URLs with actual host.
			for i := range test.redirects {
				test.redirects[i] = strings.Replace(test.redirects[i], "HOST", host, 1)
			}

			method := test.method
			if method == "" {
				method = http.MethodGet
			}

			netdialer := &net.Dialer{
				Timeout:   wait.ForeverTestTimeout,
				KeepAlive: wait.ForeverTestTimeout,
			}
			dialer := DialerFunc(func(req *http.Request) (net.Conn, error) {
				conn, err := netdialer.Dial("tcp", req.URL.Host)
				if err != nil {
					return conn, err
				}
				if err = req.Write(conn); err != nil {
					require.NoError(t, conn.Close())
					return nil, fmt.Errorf("error sending request: %v", err)
				}
				return conn, err
			})
			conn, rawResponse, err := ConnectWithRedirects(method, u, http.Header{} /*body*/, nil, dialer, true)
			if test.expectError {
				require.Error(t, err, "expected request error")
				return
			}

			require.NoError(t, err, "unexpected request error")
			assert.NoError(t, conn.Close(), "error closing connection")

			resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(rawResponse)), nil)
			require.NoError(t, err, "unexpected request error")

			result, err := ioutil.ReadAll(resp.Body)
			assert.NoError(t, err)
			require.NoError(t, resp.Body.Close())
			if test.expectedRedirects < len(test.redirects) {
				// Expect the last redirect to be returned.
				assert.Equal(t, http.StatusFound, resp.StatusCode, "Final response is not a redirect")
				assert.Equal(t, test.redirects[len(test.redirects)-1], resp.Header.Get("Location"))
				assert.NotEqual(t, resultString, string(result), "wrong content")
			} else {
				assert.Equal(t, resultString, string(result), "stream content does not match")
			}
		})
	}
}

func TestAllowsHTTP2(t *testing.T) {
	testcases := []struct {
		Name         string
		Transport    *http.Transport
		ExpectAllows bool
	}{
		{
			Name:         "empty",
			Transport:    &http.Transport{},
			ExpectAllows: true,
		},
		{
			Name:         "empty tlsconfig",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{}},
			ExpectAllows: true,
		},
		{
			Name:         "zero-length NextProtos",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{NextProtos: []string{}}},
			ExpectAllows: true,
		},
		{
			Name:         "includes h2 in NextProtos after",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{NextProtos: []string{"http/1.1", "h2"}}},
			ExpectAllows: true,
		},
		{
			Name:         "includes h2 in NextProtos before",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{NextProtos: []string{"h2", "http/1.1"}}},
			ExpectAllows: true,
		},
		{
			Name:         "includes h2 in NextProtos between",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{NextProtos: []string{"http/1.1", "h2", "h3"}}},
			ExpectAllows: true,
		},
		{
			Name:         "excludes h2 in NextProtos",
			Transport:    &http.Transport{TLSClientConfig: &tls.Config{NextProtos: []string{"http/1.1"}}},
			ExpectAllows: false,
		},
	}

	for _, tc := range testcases {
		t.Run(tc.Name, func(t *testing.T) {
			allows := allowsHTTP2(tc.Transport)
			if allows != tc.ExpectAllows {
				t.Errorf("expected %v, got %v", tc.ExpectAllows, allows)
			}
		})
	}
}

func TestSourceIPs(t *testing.T) {
	tests := []struct {
		name         string
		realIP       string
		forwardedFor string
		remoteAddr   string
		expected     []string
	}{{
		name:     "no headers, missing remoteAddr",
		expected: []string{},
	}, {
		name:       "no headers, just remoteAddr host:port",
		remoteAddr: "1.2.3.4:555",
		expected:   []string{"1.2.3.4"},
	}, {
		name:       "no headers, just remoteAddr host",
		remoteAddr: "1.2.3.4",
		expected:   []string{"1.2.3.4"},
	}, {
		name:         "empty forwarded-for chain",
		forwardedFor: " ",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"1.2.3.4"},
	}, {
		name:         "invalid forwarded-for chain",
		forwardedFor: "garbage garbage values!",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"1.2.3.4"},
	}, {
		name:         "partially invalid forwarded-for chain",
		forwardedFor: "garbage garbage values!,4.5.6.7",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"4.5.6.7", "1.2.3.4"},
	}, {
		name:         "valid forwarded-for chain",
		forwardedFor: "120.120.120.126,2.2.2.2,4.5.6.7",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"120.120.120.126", "2.2.2.2", "4.5.6.7", "1.2.3.4"},
	}, {
		name:         "valid forwarded-for chain with redundant remoteAddr",
		forwardedFor: "2.2.2.2,1.2.3.4",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"2.2.2.2", "1.2.3.4"},
	}, {
		name:       "invalid Real-Ip",
		realIP:     "garbage, just garbage!",
		remoteAddr: "1.2.3.4",
		expected:   []string{"1.2.3.4"},
	}, {
		name:         "invalid Real-Ip with forwarded-for",
		realIP:       "garbage, just garbage!",
		forwardedFor: "2.2.2.2",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"2.2.2.2", "1.2.3.4"},
	}, {
		name:       "valid Real-Ip",
		realIP:     "2.2.2.2",
		remoteAddr: "1.2.3.4",
		expected:   []string{"2.2.2.2", "1.2.3.4"},
	}, {
		name:       "redundant Real-Ip",
		realIP:     "1.2.3.4",
		remoteAddr: "1.2.3.4",
		expected:   []string{"1.2.3.4"},
	}, {
		name:         "valid Real-Ip with forwarded-for",
		realIP:       "2.2.2.2",
		forwardedFor: "120.120.120.126,4.5.6.7",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"120.120.120.126", "4.5.6.7", "2.2.2.2", "1.2.3.4"},
	}, {
		name:         "redundant Real-Ip with forwarded-for",
		realIP:       "2.2.2.2",
		forwardedFor: "120.120.120.126,2.2.2.2,4.5.6.7",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"120.120.120.126", "2.2.2.2", "4.5.6.7", "1.2.3.4"},
	}, {
		name:         "full redundancy",
		realIP:       "1.2.3.4",
		forwardedFor: "1.2.3.4",
		remoteAddr:   "1.2.3.4",
		expected:     []string{"1.2.3.4"},
	}, {
		name:         "full ipv6",
		realIP:       "abcd:ef01:2345:6789:abcd:ef01:2345:6789",
		forwardedFor: "aaaa:bbbb:cccc:dddd:eeee:ffff:0:1111,0:1111:2222:3333:4444:5555:6666:7777",
		remoteAddr:   "aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa",
		expected: []string{
			"aaaa:bbbb:cccc:dddd:eeee:ffff:0:1111",
			"0:1111:2222:3333:4444:5555:6666:7777",
			"abcd:ef01:2345:6789:abcd:ef01:2345:6789",
			"aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa:aaaa",
		},
	}, {
		name:         "mixed ipv4 ipv6",
		forwardedFor: "aaaa:bbbb:cccc:dddd:eeee:ffff:0:1111,1.2.3.4",
		remoteAddr:   "0:0:0:0:0:ffff:102:304", // ipv6 equivalent to 1.2.3.4
		expected: []string{
			"aaaa:bbbb:cccc:dddd:eeee:ffff:0:1111",
			"1.2.3.4",
		},
	}}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			req, _ := http.NewRequest("GET", "https://cluster.k8s.io/apis/foobars/v1/foo/bar", nil)
			req.RemoteAddr = test.remoteAddr
			if test.forwardedFor != "" {
				req.Header.Set("X-Forwarded-For", test.forwardedFor)
			}
			if test.realIP != "" {
				req.Header.Set("X-Real-Ip", test.realIP)
			}

			actualIPs := SourceIPs(req)
			actual := make([]string, len(actualIPs))
			for i, ip := range actualIPs {
				actual[i] = ip.String()
			}

			assert.Equal(t, test.expected, actual)
		})
	}
}

func TestParseWarningHeader(t *testing.T) {
	tests := []struct {
		name string

		header string

		wantResult    WarningHeader
		wantRemainder string
		wantErr       string
	}{
		// invalid cases
		{
			name:    "empty",
			header:  ``,
			wantErr: "fewer than 3 segments",
		},
		{
			name:    "bad code",
			header:  `A B`,
			wantErr: "fewer than 3 segments",
		},
		{
			name:    "short code",
			header:  `1 - "text"`,
			wantErr: "not 3 digits",
		},
		{
			name:    "bad code",
			header:  `A - "text"`,
			wantErr: "not 3 digits",
		},
		{
			name:    "invalid date quoting",
			header:  `  299 - "text\"\\\a\b\c"  "Tue, 15 Nov 1994 08:12:31 GMT `,
			wantErr: "unterminated date segment",
		},
		{
			name:    "invalid post-date",
			header:  `  299 - "text\"\\\a\b\c"  "Tue, 15 Nov 1994 08:12:31 GMT" other`,
			wantErr: "unexpected token after warn-date",
		},
		{
			name:    "agent control character",
			header:  "  299 agent\u0000name \"text\"",
			wantErr: "invalid agent",
		},
		{
			name:    "agent non-utf8 character",
			header:  "  299 agent\xc5name \"text\"",
			wantErr: "invalid agent",
		},
		{
			name:    "text control character",
			header:  "  299 - \"text\u0000\"content",
			wantErr: "invalid text",
		},
		{
			name:    "text non-utf8 character",
			header:  "  299 - \"text\xc5\"content",
			wantErr: "invalid text",
		},

		// valid cases
		{
			name:       "ok",
			header:     `299 - "text"`,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text`},
		},
		{
			name:       "ok",
			header:     `299 - "text\"\\\a\b\c"`,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
		},
		// big code
		{
			name:       "big code",
			header:     `321 - "text"`,
			wantResult: WarningHeader{Code: 321, Agent: "-", Text: "text"},
		},
		// RFC 2047 decoding
		{
			name:       "ok, rfc 2047, iso-8859-1, q",
			header:     `299 - "=?iso-8859-1?q?this=20is=20some=20text?="`,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `this is some text`},
		},
		{
			name:       "ok, rfc 2047, utf-8, b",
			header:     `299 - "=?UTF-8?B?VGhpcyBpcyBhIGhvcnNleTog8J+Qjg==?= And =?UTF-8?B?VGhpcyBpcyBhIGhvcnNleTog8J+Qjg==?="`,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `This is a horsey: ðŸ�Ž And This is a horsey: ðŸ�Ž`},
		},
		{
			name:       "ok, rfc 2047, utf-8, q",
			header:     `299 - "=?UTF-8?Q?This is a \"horsey\": =F0=9F=90=8E?="`,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `This is a "horsey": ðŸ�Ž`},
		},
		{
			name:       "ok, rfc 2047, unknown charset",
			header:     `299 - "=?UTF-9?Q?This is a horsey: =F0=9F=90=8E?="`,
			wantResult: WarningHeader{Code: 299, Agent: "-", Text: `=?UTF-9?Q?This is a horsey: =F0=9F=90=8E?=`},
		},
		{
			name:       "ok with spaces",
			header:     `  299 - "text\"\\\a\b\c"  `,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
		},
		{
			name:       "ok with date",
			header:     `  299 - "text\"\\\a\b\c"  "Tue, 15 Nov 1994 08:12:31 GMT" `,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
		},
		{
			name:       "ok with date and comma",
			header:     `  299 - "text\"\\\a\b\c"  "Tue, 15 Nov 1994 08:12:31 GMT" , `,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
		},
		{
			name:       "ok with comma",
			header:     `  299 - "text\"\\\a\b\c"  , `,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
		},
		{
			name:          "ok with date and comma and remainder",
			header:        `  299 - "text\"\\\a\b\c"  "Tue, 15 Nov 1994 08:12:31 GMT" , remainder `,
			wantResult:    WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
			wantRemainder: "remainder",
		},
		{
			name:          "ok with comma and remainder",
			header:        `  299 - "text\"\\\a\b\c"  ,remainder text,second remainder`,
			wantResult:    WarningHeader{Code: 299, Agent: `-`, Text: `text"\abc`},
			wantRemainder: "remainder text,second remainder",
		},
		{
			name:       "ok with utf-8 content directly in warn-text",
			header:     ` 299 - "Test of IÃ±tÃ«rnÃ¢tiÃ´nÃ lizÃ¦tiÃ¸n,ðŸ’�ðŸ�¹ðŸŒ‡â›”" `,
			wantResult: WarningHeader{Code: 299, Agent: `-`, Text: `Test of IÃ±tÃ«rnÃ¢tiÃ´nÃ lizÃ¦tiÃ¸n,ðŸ’�ðŸ�¹ðŸŒ‡â›”`},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			gotResult, gotRemainder, err := ParseWarningHeader(tt.header)
			switch {
			case err == nil && len(tt.wantErr) > 0:
				t.Errorf("ParseWarningHeader() no error, expected error %q", tt.wantErr)
				return
			case err != nil && len(tt.wantErr) == 0:
				t.Errorf("ParseWarningHeader() error %q, expected no error", err)
				return
			case err != nil && len(tt.wantErr) > 0 && !strings.Contains(err.Error(), tt.wantErr):
				t.Errorf("ParseWarningHeader() error %q, expected error %q", err, tt.wantErr)
				return
			}
			if err != nil {
				return
			}
			if !reflect.DeepEqual(gotResult, tt.wantResult) {
				t.Errorf("ParseWarningHeader() gotResult = %#v, want %#v", gotResult, tt.wantResult)
			}
			if gotRemainder != tt.wantRemainder {
				t.Errorf("ParseWarningHeader() gotRemainder = %v, want %v", gotRemainder, tt.wantRemainder)
			}
		})
	}
}

func TestNewWarningHeader(t *testing.T) {
	tests := []struct {
		name string

		code  int
		agent string
		text  string

		want    string
		wantErr string
	}{
		// invalid cases
		{
			name:    "code too low",
			code:    -1,
			agent:   `-`,
			text:    `example warning`,
			wantErr: "between 0 and 999",
		},
		{
			name:    "code too high",
			code:    1000,
			agent:   `-`,
			text:    `example warning`,
			wantErr: "between 0 and 999",
		},
		{
			name:    "agent with space",
			code:    299,
			agent:   `test agent`,
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "agent with newline",
			code:    299,
			agent:   "test\nagent",
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "agent with backslash",
			code:    299,
			agent:   `test\agent`,
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "agent with quote",
			code:    299,
			agent:   `test"agent"`,
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "agent with control character",
			code:    299,
			agent:   "test\u0000agent",
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "agent with non-UTF8",
			code:    299,
			agent:   "test\xc5agent",
			text:    `example warning`,
			wantErr: `agent must be valid`,
		},
		{
			name:    "text with newline",
			code:    299,
			agent:   `-`,
			text:    "Test of new\nline",
			wantErr: "text must be valid",
		},
		{
			name:    "text with control character",
			code:    299,
			agent:   `-`,
			text:    "Test of control\u0000character",
			wantErr: "text must be valid",
		},
		{
			name:    "text with non-UTF8",
			code:    299,
			agent:   `-`,
			text:    "Test of control\xc5character",
			wantErr: "text must be valid",
		},

		{
			name:  "valid empty text",
			code:  299,
			agent: `-`,
			text:  ``,
			want:  `299 - ""`,
		},
		{
			name:  "valid empty agent",
			code:  299,
			agent: ``,
			text:  `example warning`,
			want:  `299 - "example warning"`,
		},
		{
			name:  "valid low code",
			code:  1,
			agent: `-`,
			text:  `example warning`,
			want:  `001 - "example warning"`,
		},
		{
			name:  "valid high code",
			code:  999,
			agent: `-`,
			text:  `example warning`,
			want:  `999 - "example warning"`,
		},
		{
			name:  "valid utf-8",
			code:  299,
			agent: `-`,
			text:  `Test of "IÃ±tÃ«rnÃ¢tiÃ´nÃ lizÃ¦tiÃ¸n,ðŸ’�ðŸ�¹ðŸŒ‡â›”"`,
			want:  `299 - "Test of \"IÃ±tÃ«rnÃ¢tiÃ´nÃ lizÃ¦tiÃ¸n,ðŸ’�ðŸ�¹ðŸŒ‡â›”\""`,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NewWarningHeader(tt.code, tt.agent, tt.text)

			switch {
			case err == nil && len(tt.wantErr) > 0:
				t.Fatalf("ParseWarningHeader() no error, expected error %q", tt.wantErr)
			case err != nil && len(tt.wantErr) == 0:
				t.Fatalf("ParseWarningHeader() error %q, expected no error", err)
			case err != nil && len(tt.wantErr) > 0 && !strings.Contains(err.Error(), tt.wantErr):
				t.Fatalf("ParseWarningHeader() error %q, expected error %q", err, tt.wantErr)
			}
			if err != nil {
				return
			}

			if got != tt.want {
				t.Fatalf("NewWarningHeader() = %v, want %v", got, tt.want)
			}

			roundTrip, remaining, err := ParseWarningHeader(got)
			if err != nil {
				t.Fatalf("error roundtripping: %v", err)
			}
			if len(remaining) > 0 {
				t.Fatalf("unexpected remainder roundtripping: %s", remaining)
			}
			agent := tt.agent
			if len(agent) == 0 {
				agent = "-"
			}
			expect := WarningHeader{Code: tt.code, Agent: agent, Text: tt.text}
			if roundTrip != expect {
				t.Fatalf("after round trip, want:\n%#v\ngot\n%#v", expect, roundTrip)
			}
		})
	}
}

func TestParseWarningHeaders(t *testing.T) {
	tests := []struct {
		name string

		headers []string

		want     []WarningHeader
		wantErrs []string
	}{
		{
			name:     "empty",
			headers:  []string{},
			want:     nil,
			wantErrs: []string{},
		},
		{
			name: "multi-header with error",
			headers: []string{
				`299 - "warning 1.1",299 - "warning 1.2"`,
				`299 - "warning 2", 299 - "warning unquoted`,
				` 299 - "warning 3.1" ,  299 - "warning 3.2" `,
			},
			want: []WarningHeader{
				{Code: 299, Agent: "-", Text: "warning 1.1"},
				{Code: 299, Agent: "-", Text: "warning 1.2"},
				{Code: 299, Agent: "-", Text: "warning 2"},
				{Code: 299, Agent: "-", Text: "warning 3.1"},
				{Code: 299, Agent: "-", Text: "warning 3.2"},
			},
			wantErrs: []string{"invalid warning header: invalid quoted string: missing closing quote"},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, gotErrs := ParseWarningHeaders(tt.headers)

			switch {
			case len(gotErrs) != len(tt.wantErrs):
				t.Fatalf("ParseWarningHeader() got %v, expected %v", gotErrs, tt.wantErrs)
			case len(gotErrs) == len(tt.wantErrs) && len(gotErrs) > 0:
				gotErrStrings := []string{}
				for _, err := range gotErrs {
					gotErrStrings = append(gotErrStrings, err.Error())
				}
				if !reflect.DeepEqual(gotErrStrings, tt.wantErrs) {
					t.Fatalf("ParseWarningHeader() got %v, expected %v", gotErrs, tt.wantErrs)
				}
			}
			if len(gotErrs) > 0 {
				return
			}

			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("ParseWarningHeaders() got %#v, want %#v", got, tt.want)
			}
		})
	}
}

func TestIsProbableEOF(t *testing.T) {
	tests := []struct {
		name     string
		err      error
		expected bool
	}{
		{
			name:     "with no error",
			expected: false,
		},
		{
			name:     "with EOF error",
			err:      io.EOF,
			expected: true,
		},
		{
			name:     "with unexpected EOF error",
			err:      io.ErrUnexpectedEOF,
			expected: true,
		},
		{
			name:     "with broken connection error",
			err:      fmt.Errorf("http: can't write HTTP request on broken connection"),
			expected: true,
		},
		{
			name:     "with server sent GOAWAY error",
			err:      fmt.Errorf("error foo - http2: server sent GOAWAY and closed the connection - error bar"),
			expected: true,
		},
		{
			name:     "with connection reset by peer error",
			err:      fmt.Errorf("error foo - connection reset by peer - error bar"),
			expected: true,
		},
		{
			name:     "with use of closed network connection error",
			err:      fmt.Errorf("error foo - Use of closed network connection - error bar"),
			expected: true,
		},
		{
			name: "with url error",
			err: &url.Error{
				Err: io.ErrUnexpectedEOF,
			},
			expected: true,
		},
		{
			name:     "with unrecognized error",
			err:      fmt.Errorf("error foo"),
			expected: false,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			actual := IsProbableEOF(test.err)
			assert.Equal(t, test.expected, actual)
		})
	}
}

func setEnv(key, value string) func() {
	originalValue := os.Getenv(key)
	os.Setenv(key, value)
	return func() {
		os.Setenv(key, originalValue)
	}
}

func TestReadIdleTimeoutSeconds(t *testing.T) {
	reset := setEnv("HTTP2_READ_IDLE_TIMEOUT_SECONDS", "60")
	if e, a := 60, readIdleTimeoutSeconds(); e != a {
		t.Errorf("expected %d, got %d", e, a)
	}
	reset()

	reset = setEnv("HTTP2_READ_IDLE_TIMEOUT_SECONDS", "illegal value")
	if e, a := 30, readIdleTimeoutSeconds(); e != a {
		t.Errorf("expected %d, got %d", e, a)
	}
	reset()
}

func TestPingTimeoutSeconds(t *testing.T) {
	reset := setEnv("HTTP2_PING_TIMEOUT_SECONDS", "60")
	if e, a := 60, pingTimeoutSeconds(); e != a {
		t.Errorf("expected %d, got %d", e, a)
	}
	reset()

	reset = setEnv("HTTP2_PING_TIMEOUT_SECONDS", "illegal value")
	if e, a := 15, pingTimeoutSeconds(); e != a {
		t.Errorf("expected %d, got %d", e, a)
	}
	reset()
}

func Benchmark_ParseQuotedString(b *testing.B) {
	str := `"The quick brown" fox jumps over the lazy dog`
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		quoted, remainder, err := parseQuotedString(str)
		if err != nil {
			b.Errorf("Unexpected error %s", err)
		}
		if quoted != "The quick brown" {
			b.Errorf("Unexpected quoted string %s", quoted)
		}
		if remainder != "fox jumps over the lazy dog" {
			b.Errorf("Unexpected remainder string %s", quoted)
		}
	}
}
