// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package impersonator

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/require"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime/serializer"
	"k8s.io/apiserver/pkg/authentication/authenticator"
	"k8s.io/apiserver/pkg/authentication/user"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/clientcmd/api"

	authenticationv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
	"go.pinniped.dev/generated/latest/apis/concierge/login"
	conciergescheme "go.pinniped.dev/internal/concierge/scheme"
	"go.pinniped.dev/internal/controller/authenticator/authncache"
	"go.pinniped.dev/internal/mocks/mocktokenauthenticator"
	"go.pinniped.dev/internal/testutil"
	"go.pinniped.dev/internal/testutil/impersonationtoken"
	"go.pinniped.dev/internal/testutil/testlogger"
)

func TestImpersonator(t *testing.T) {
	const (
		defaultAPIGroup = "pinniped.dev"
		customAPIGroup  = "walrus.tld"

		testUser = "test-user"
	)

	testGroups := []string{"test-group-1", "test-group-2"}
	testExtra := map[string][]string{
		"extra-1": {"some", "extra", "stuff"},
		"extra-2": {"some", "more", "extra", "stuff"},
	}

	validURL, _ := url.Parse("http://pinniped.dev/blah")
	newRequest := func(h http.Header) *http.Request {
		r, err := http.NewRequestWithContext(context.Background(), http.MethodGet, validURL.String(), nil)
		require.NoError(t, err)
		r.Header = h
		return r
	}

	goodAuthenticator := corev1.TypedLocalObjectReference{
		Name:     "authenticator-one",
		APIGroup: stringPtr(authenticationv1alpha1.GroupName),
	}
	badAuthenticator := corev1.TypedLocalObjectReference{
		Name:     "",
		APIGroup: stringPtr(authenticationv1alpha1.GroupName),
	}

	tests := []struct {
		name                            string
		apiGroupOverride                string
		getKubeconfig                   func() (*rest.Config, error)
		wantCreationErr                 string
		request                         *http.Request
		wantHTTPBody                    string
		wantHTTPStatus                  int
		wantLogs                        []string
		wantKubeAPIServerRequestHeaders http.Header
		wantKubeAPIServerStatusCode     int
		expectMockToken                 func(*testing.T, *mocktokenauthenticator.MockTokenMockRecorder)
	}{
		{
			name: "fail to get in-cluster config",
			getKubeconfig: func() (*rest.Config, error) {
				return nil, fmt.Errorf("some kubernetes error")
			},
			wantCreationErr: "could not get in-cluster config: some kubernetes error",
		},
		{
			name: "invalid kubeconfig host",
			getKubeconfig: func() (*rest.Config, error) {
				return &rest.Config{Host: ":"}, nil
			},
			wantCreationErr: "could not parse host URL from in-cluster config: parse \":\": missing protocol scheme",
		},
		{
			name: "invalid transport config",
			getKubeconfig: func() (*rest.Config, error) {
				return &rest.Config{
					Host:         "pinniped.dev/blah",
					ExecProvider: &api.ExecConfig{},
					AuthProvider: &api.AuthProviderConfig{},
				}, nil
			},
			wantCreationErr: "could not get in-cluster transport config: execProvider and authProvider cannot be used in combination",
		},
		{
			name: "fail to get transport from config",
			getKubeconfig: func() (*rest.Config, error) {
				return &rest.Config{
					Host:            "pinniped.dev/blah",
					BearerToken:     "test-bearer-token",
					Transport:       http.DefaultTransport,
					TLSClientConfig: rest.TLSClientConfig{Insecure: true},
				}, nil
			},
			wantCreationErr: "could not get in-cluster transport: using a custom transport with TLS certificate options or the insecure flag is not allowed",
		},
		{
			name:           "Impersonate-User header already in request",
			request:        newRequest(map[string][]string{"Impersonate-User": {"some-user"}}),
			wantHTTPBody:   "impersonation header already exists\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"impersonation header already exists\" \"error\"=\"\\\"Impersonate-User\\\" header already exists\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "Impersonate-Group header already in request",
			request:        newRequest(map[string][]string{"Impersonate-Group": {"some-group"}}),
			wantHTTPBody:   "impersonation header already exists\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"impersonation header already exists\" \"error\"=\"\\\"Impersonate-Group\\\" header already exists\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "Impersonate-Extra header already in request",
			request:        newRequest(map[string][]string{"Impersonate-Extra-something": {"something"}}),
			wantHTTPBody:   "impersonation header already exists\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"impersonation header already exists\" \"error\"=\"\\\"Impersonate-Extra-something\\\" header already exists\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name: "Impersonate-* header already in request",
			request: newRequest(map[string][]string{
				"Impersonate-Something": {"some-newfangled-impersonate-header"},
			}),
			wantHTTPBody:   "impersonation header already exists\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"impersonation header already exists\" \"error\"=\"\\\"Impersonate-Something\\\" header already exists\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "missing authorization header",
			request:        newRequest(map[string][]string{}),
			wantHTTPBody:   "invalid token encoding\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"token authenticator did not find token\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "authorization header missing bearer prefix",
			request:        newRequest(map[string][]string{"Authorization": {impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
			wantHTTPBody:   "invalid token encoding\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"token authenticator did not find token\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "token is not base64 encoded",
			request:        newRequest(map[string][]string{"Authorization": {"Bearer !!!"}}),
			wantHTTPBody:   "invalid token encoding\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"invalid base64 in encoded bearer token: illegal base64 data at input byte 0\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "base64 encoded token is not valid json",
			request:        newRequest(map[string][]string{"Authorization": {"Bearer aGVsbG8gd29ybGQK"}}), // aGVsbG8gd29ybGQK is "hello world" base64 encoded
			wantHTTPBody:   "invalid token encoding\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"invalid object encoded in bearer token: couldn't get version/kind; json parse error: invalid character 'h' looking for beginning of value\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:             "base64 encoded token is encoded with default api group but we are expecting custom api group",
			apiGroupOverride: customAPIGroup,
			request:          newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
			wantHTTPBody:     "invalid token encoding\n",
			wantHTTPStatus:   http.StatusBadRequest,
			wantLogs:         []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"invalid object encoded in bearer token: no kind \\\"TokenCredentialRequest\\\" is registered for version \\\"login.concierge.pinniped.dev/v1alpha1\\\" in scheme \\\"pkg/runtime/scheme.go:100\\\"\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "base64 encoded token is encoded with custom api group but we are expecting default api group",
			request:        newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, customAPIGroup)}}),
			wantHTTPBody:   "invalid token encoding\n",
			wantHTTPStatus: http.StatusBadRequest,
			wantLogs:       []string{"\"msg\"=\"invalid token encoding\" \"error\"=\"invalid object encoded in bearer token: no kind \\\"TokenCredentialRequest\\\" is registered for version \\\"login.concierge.walrus.tld/v1alpha1\\\" in scheme \\\"pkg/runtime/scheme.go:100\\\"\" \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:           "token could not be authenticated",
			request:        newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "", &badAuthenticator, defaultAPIGroup)}}),
			wantHTTPBody:   "invalid token\n",
			wantHTTPStatus: http.StatusUnauthorized,
			wantLogs:       []string{"\"msg\"=\"received invalid token\" \"error\"=\"no such authenticator\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		{
			name:    "token authenticates as nil",
			request: newRequest(map[string][]string{"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)}}),
			expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
				recorder.AuthenticateToken(gomock.Any(), "test-token").Return(nil, false, nil)
			},
			wantHTTPBody:   "not authenticated\n",
			wantHTTPStatus: http.StatusUnauthorized,
			wantLogs:       []string{"\"level\"=0 \"msg\"=\"received token that did not authenticate\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\""},
		},
		// happy path
		{
			name: "token validates",
			request: newRequest(map[string][]string{
				"Authorization":   {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)},
				"User-Agent":      {"test-user-agent"},
				"Accept":          {"some-accepted-format"},
				"Accept-Encoding": {"some-accepted-encoding"},
				"Connection":      {"Upgrade"}, // the value "Upgrade" is handled in a special way by `httputil.NewSingleHostReverseProxy`
				"Upgrade":         {"some-upgrade"},
				"Content-Type":    {"some-type"},
				"Content-Length":  {"some-length"},
				"Other-Header":    {"test-header-value-1"}, // this header will be passed through
			}),
			expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
				userInfo := user.DefaultInfo{
					Name:   testUser,
					Groups: testGroups,
					UID:    "test-uid",
					Extra:  testExtra,
				}
				response := &authenticator.Response{User: &userInfo}
				recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil)
			},
			wantKubeAPIServerRequestHeaders: map[string][]string{
				"Authorization":             {"Bearer some-service-account-token"},
				"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
				"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
				"Impersonate-Group":         {"test-group-1", "test-group-2"},
				"Impersonate-User":          {"test-user"},
				"User-Agent":                {"test-user-agent"},
				"Accept":                    {"some-accepted-format"},
				"Accept-Encoding":           {"some-accepted-encoding"},
				"Connection":                {"Upgrade"},
				"Upgrade":                   {"some-upgrade"},
				"Content-Type":              {"some-type"},
				"Other-Header":              {"test-header-value-1"},
			},
			wantHTTPBody:   "successful proxied response",
			wantHTTPStatus: http.StatusOK,
			wantLogs:       []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""},
		},
		{
			name: "token validates and the kube API request returns an error",
			request: newRequest(map[string][]string{
				"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, defaultAPIGroup)},
				"User-Agent":    {"test-user-agent"},
			}),
			expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
				userInfo := user.DefaultInfo{
					Name:   testUser,
					Groups: testGroups,
					UID:    "test-uid",
					Extra:  testExtra,
				}
				response := &authenticator.Response{User: &userInfo}
				recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil)
			},
			wantKubeAPIServerStatusCode: http.StatusNotFound,
			wantKubeAPIServerRequestHeaders: map[string][]string{
				"Accept-Encoding":           {"gzip"}, // because the rest client used in this test does not disable compression
				"Authorization":             {"Bearer some-service-account-token"},
				"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
				"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
				"Impersonate-Group":         {"test-group-1", "test-group-2"},
				"Impersonate-User":          {"test-user"},
				"User-Agent":                {"test-user-agent"},
			},
			wantHTTPStatus: http.StatusNotFound,
			wantLogs:       []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""},
		},
		{
			name:             "token validates with custom api group",
			apiGroupOverride: customAPIGroup,
			request: newRequest(map[string][]string{
				"Authorization": {"Bearer " + impersonationtoken.Make(t, "test-token", &goodAuthenticator, customAPIGroup)},
				"Other-Header":  {"test-header-value-1"},
				"User-Agent":    {"test-user-agent"},
			}),
			expectMockToken: func(t *testing.T, recorder *mocktokenauthenticator.MockTokenMockRecorder) {
				userInfo := user.DefaultInfo{
					Name:   testUser,
					Groups: testGroups,
					UID:    "test-uid",
					Extra:  testExtra,
				}
				response := &authenticator.Response{User: &userInfo}
				recorder.AuthenticateToken(gomock.Any(), "test-token").Return(response, true, nil)
			},
			wantKubeAPIServerRequestHeaders: map[string][]string{
				"Accept-Encoding":           {"gzip"}, // because the rest client used in this test does not disable compression
				"Authorization":             {"Bearer some-service-account-token"},
				"Impersonate-Extra-Extra-1": {"some", "extra", "stuff"},
				"Impersonate-Extra-Extra-2": {"some", "more", "extra", "stuff"},
				"Impersonate-Group":         {"test-group-1", "test-group-2"},
				"Impersonate-User":          {"test-user"},
				"User-Agent":                {"test-user-agent"},
				"Other-Header":              {"test-header-value-1"},
			},
			wantHTTPBody:   "successful proxied response",
			wantHTTPStatus: http.StatusOK,
			wantLogs:       []string{"\"level\"=0 \"msg\"=\"proxying authenticated request\" \"authenticator\"={\"apiGroup\":\"authentication.concierge.pinniped.dev\",\"kind\":\"\",\"name\":\"authenticator-one\"} \"method\"=\"GET\" \"url\"=\"http://pinniped.dev/blah\" \"userID\"=\"test-uid\""},
		},
	}

	for _, tt := range tests {
		tt := tt
		testLog := testlogger.New(t)
		t.Run(tt.name, func(t *testing.T) {
			defer func() {
				if t.Failed() {
					for i, line := range testLog.Lines() {
						t.Logf("testLog line %d: %q", i+1, line)
					}
				}
			}()

			if tt.wantKubeAPIServerStatusCode == 0 {
				tt.wantKubeAPIServerStatusCode = http.StatusOK
			}

			serverWasCalled := false
			serverSawHeaders := http.Header{}
			testServerCA, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) {
				serverWasCalled = true
				serverSawHeaders = r.Header
				if tt.wantKubeAPIServerStatusCode != http.StatusOK {
					w.WriteHeader(tt.wantKubeAPIServerStatusCode)
				} else {
					_, _ = w.Write([]byte("successful proxied response"))
				}
			})
			testServerKubeconfig := rest.Config{
				Host:            testServerURL,
				BearerToken:     "some-service-account-token",
				TLSClientConfig: rest.TLSClientConfig{CAData: []byte(testServerCA)},
			}
			if tt.getKubeconfig == nil {
				tt.getKubeconfig = func() (*rest.Config, error) {
					return &testServerKubeconfig, nil
				}
			}

			// stole this from cache_test, hopefully it is sufficient
			cacheWithMockAuthenticator := authncache.New()
			ctrl := gomock.NewController(t)
			defer ctrl.Finish()
			key := authncache.Key{Name: "authenticator-one", APIGroup: *goodAuthenticator.APIGroup}
			mockToken := mocktokenauthenticator.NewMockToken(ctrl)
			cacheWithMockAuthenticator.Store(key, mockToken)

			if tt.expectMockToken != nil {
				tt.expectMockToken(t, mockToken.EXPECT())
			}

			apiGroup := defaultAPIGroup
			if tt.apiGroupOverride != "" {
				apiGroup = tt.apiGroupOverride
			}

			proxy, err := newInternal(cacheWithMockAuthenticator, makeDecoder(t, apiGroup), testLog, tt.getKubeconfig)
			if tt.wantCreationErr != "" {
				require.EqualError(t, err, tt.wantCreationErr)
				return
			}
			require.NoError(t, err)
			require.NotNil(t, proxy)
			w := httptest.NewRecorder()
			requestBeforeServe := tt.request.Clone(tt.request.Context())
			proxy.ServeHTTP(w, tt.request)
			require.Equal(t, requestBeforeServe, tt.request, "ServeHTTP() mutated the request, and it should not per http.Handler docs")
			if tt.wantHTTPStatus != 0 {
				require.Equalf(t, tt.wantHTTPStatus, w.Code, "fyi, response body was %q", w.Body.String())
			}
			if tt.wantHTTPBody != "" {
				require.Equal(t, tt.wantHTTPBody, w.Body.String())
			}
			if tt.wantLogs != nil {
				require.Equal(t, tt.wantLogs, testLog.Lines())
			}

			if tt.wantHTTPStatus == http.StatusOK || tt.wantKubeAPIServerStatusCode != http.StatusOK {
				require.True(t, serverWasCalled, "Should have proxied the request to the Kube API server, but didn't")
				require.Equal(t, tt.wantKubeAPIServerRequestHeaders, serverSawHeaders)
			} else {
				require.False(t, serverWasCalled, "Should not have proxied the request to the Kube API server, but did")
			}
		})
	}
}

func stringPtr(s string) *string { return &s }

func makeDecoder(t *testing.T, apiGroupSuffix string) runtime.Decoder {
	t.Helper()

	scheme, loginGV, _ := conciergescheme.New(apiGroupSuffix)
	codecs := serializer.NewCodecFactory(scheme)
	respInfo, ok := runtime.SerializerInfoForMediaType(codecs.SupportedMediaTypes(), runtime.ContentTypeJSON)
	require.True(t, ok, "couldn't find serializer info for media type")

	return codecs.DecoderToVersion(respInfo.Serializer, schema.GroupVersion{
		Group:   loginGV.Group,
		Version: login.SchemeGroupVersion.Version,
	})
}
