/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"). You may not
 * use this file except in compliance with the License. A copy of the
 * License is located at
 *
 * http://aws.amazon.com/apache2.0/
 *
 * or in the "license" file accompanying this file. This file 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 handler

import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"path/filepath"
	"testing"

	"github.com/aws/amazon-ssm-agent/agent/mocks/log"
	"github.com/aws/amazon-ssm-agent/agent/plugins/downloadcontent/gitresource"
	gitcoremock "github.com/aws/amazon-ssm-agent/agent/plugins/downloadcontent/gitresource/privategit/handler/core/mock"
	bridgemock "github.com/aws/amazon-ssm-agent/agent/ssm/ssmparameterresolver/mock"
	gogit "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/transport"
	"github.com/go-git/go-git/v5/plumbing/transport/http"
	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"golang.org/x/crypto/ssh"
)

var logMock = log.NewMockLog()

func GeneratePrivateKey() string {
	var key, err = rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		fmt.Printf("Got an error while generating the private key: %v", err)
		return ""
	}

	return string(pem.EncodeToMemory(
		&pem.Block{
			Type:  "RSA PRIVATE KEY",
			Bytes: x509.MarshalPKCS1PrivateKey(key),
		},
	))
}

var privateSSHKey = GeneratePrivateKey()
var parameterStoreParameters = map[string]string{
	"{{ssm-secure:username}}":      "admin",
	"{{ssm-secure:password}}":      "pwd",
	"{{ssm-secure:privateSSHKey}}": privateSSHKey,
}

func PlainClone(path string, isBare bool, o *gogit.CloneOptions) (*gogit.Repository, error) {
	switch path {
	case "unknown-host":
		return nil, fmt.Errorf("ssh: handshake failed: knownhosts: key is unknown")
	case "error":
		return nil, fmt.Errorf("random err")
	default:
		return &gogit.Repository{
			Storer: nil,
		}, nil
	}
}

func getTestPublicKeysAuth(user, sshKey string, hostKeyCallback ssh.HostKeyCallback) *gitssh.PublicKeys {
	publicKeysAuthMethod, err := gitssh.NewPublicKeys(user, []byte(sshKey), "")

	if err != nil {
		fmt.Print(err.Error())
		return nil
	}

	publicKeysAuthMethod.HostKeyCallback = hostKeyCallback
	return publicKeysAuthMethod
}

func getString(obj interface{}) string {
	return fmt.Sprintf("%v", obj)
}

func TestNewGitHandlerURLParsing(t *testing.T) {
	tests := []struct {
		repository string
		url        *transport.Endpoint
		err        error
	}{
		{
			"",
			&transport.Endpoint{
				Protocol: "file",
				Path:     getAbsolutePath(""),
			},
			nil,
		},
		{
			"http://example.com",
			&transport.Endpoint{
				Protocol: "http",
				Host:     "example.com",
			},
			nil,
		},
		{
			"ssh://git@example.com",
			&transport.Endpoint{
				Protocol: "ssh",
				Host:     "example.com",
				User:     "git",
			},
			nil,
		},
		{
			"contributor@github.com:User/test-repository.git",
			&transport.Endpoint{
				Protocol: "ssh",
				Host:     "github.com",
				User:     "contributor",
				Port:     22,
				Path:     "User/test-repository.git",
			},
			nil,
		},
		{
			"http:// example.com",
			nil,
			errors.New("Invalid repository url format: parse \"http:// example.com\": invalid character \" \" in host name"),
		},
	}

	for _, test := range tests {
		handler, err := NewGitHandler(
			test.repository,
			GitAuthConfig{},
			gitresource.CheckoutOptions{},
			bridgemock.GetSsmParamResolverBridge(parameterStoreParameters),
		)

		if test.err != nil {
			assert.Error(t, err, getString(test))
			assert.EqualError(t, err, test.err.Error())
		} else {
			gitHandler := handler.(*gitHandler)

			assert.NoError(t, err, getString(test))
			assert.Equal(t, test.url, &gitHandler.parsedRepositoryURL)
		}
	}

}

func getAbsolutePath(path string) string {
	if result, err := filepath.Abs(path); err != nil {
		return ""
	} else {
		return result
	}
}

func TestNewGitHandler_isGitTypeRepositoryURL(t *testing.T) {
	tests := []struct {
		handler   gitHandler
		isGitType bool
	}{
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "https",
				},
			},
			false,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
			},
			false,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
			},
			true,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "bla",
				},
			},
			true,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "",
				},
			},
			true,
		},
	}

	for _, test := range tests {
		isGitType := test.handler.isGitTypeRepositoryURL()
		assert.Equal(t, test.isGitType, isGitType, test)
	}
}

func TestNewGitHandler_isHTTPTypeRepositoryURL(t *testing.T) {
	tests := []struct {
		handler    gitHandler
		isHTTPType bool
	}{
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "https",
				},
			},
			true,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
			},
			true,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "bla",
				},
			},
			false,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "",
				},
			},
			false,
		},
	}

	for _, test := range tests {
		isHTTPType := test.handler.isHTTPTypeRepositoryURL()
		assert.Equal(t, test.isHTTPType, isHTTPType, getString(test))
	}
}

func TestGitHandler_ValidateIsValid(t *testing.T) {
	handlers := []gitHandler{
		{
			repositoryURL: "https",
			parsedRepositoryURL: transport.Endpoint{
				Protocol: "https",
			},
		},
		{
			repositoryURL: "http",
			parsedRepositoryURL: transport.Endpoint{
				Protocol: "http",
			},
			authConfig: GitAuthConfig{
				Username: "test",
				Password: "123",
			},
		},
		{
			repositoryURL: "https",
			parsedRepositoryURL: transport.Endpoint{
				Protocol: "https",
			},
			authConfig: GitAuthConfig{
				Password: "123",
			},
		},
		{
			repositoryURL: "http",
			parsedRepositoryURL: transport.Endpoint{
				Protocol: "http",
			},
			authConfig: GitAuthConfig{
				Username: "123",
			},
		},
		{
			repositoryURL: "ssh",
			parsedRepositoryURL: transport.Endpoint{
				Protocol: "ssh",
			},
			authConfig: GitAuthConfig{
				PrivateSSHKey: "test",
			},
		},
	}

	for _, handler := range handlers {
		isValid, err := handler.Validate()
		assert.NoError(t, err, getString(handler))
		assert.True(t, isValid, getString(handler))
	}
}

func TestGitHandler_ValidateIsInvalid(t *testing.T) {

	gitUrlWithUserOrPwdError := errors.New("Neither Username nor Password must not be provided for " +
		"Git type repository URL. Private SSH key for authentication")

	httpUrlWithPrivateSSHKey := errors.New("Private SSH key must not be provided for HTTP type repository URL. " +
		"Username and Password is required for authentication")

	tests := []struct {
		handler gitHandler
		err     error
	}{
		{
			gitHandler{
				repositoryURL: "http",
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey: "--key--",
				},
			},
			httpUrlWithPrivateSSHKey,
		},
		{
			gitHandler{
				repositoryURL: "ssh",
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
				authConfig: GitAuthConfig{
					Username: "user",
				},
			},
			gitUrlWithUserOrPwdError,
		},
		{
			gitHandler{
				repositoryURL: "sjdhakjf",
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "abc",
				},
				authConfig: GitAuthConfig{
					Password: "123",
				},
			},
			gitUrlWithUserOrPwdError,
		},
		{
			gitHandler{
				repositoryURL: "jkfdgnd",
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "",
				},
				authConfig: GitAuthConfig{
					Username: "user",
					Password: "123",
				},
			},
			gitUrlWithUserOrPwdError,
		},
		{
			gitHandler{
				repositoryURL: "",
			},
			errors.New("Repository for Git resources type must be specified"),
		},
	}

	for _, test := range tests {
		isValid, err := test.handler.Validate()
		assert.False(t, isValid, getString(test))
		assert.Error(t, err, getString(test))
		assert.EqualError(t, err, test.err.Error(), getString(test))

	}
}

func TestGitHandler_GetAuthMethod(t *testing.T) {
	bridge := bridgemock.GetSsmParamResolverBridge(parameterStoreParameters)

	tests := []struct {
		handler    gitHandler
		authMethod transport.AuthMethod
		err        error
	}{
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				ssmParameterResolverBridge: bridge,
			},
			nil,
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					Username: "user",
					Password: "pwd",
				},
				ssmParameterResolverBridge: bridge,
			},
			&http.BasicAuth{},
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey: privateSSHKey,
				},
				ssmParameterResolverBridge: bridge,
			},
			&gitssh.PublicKeys{},
			nil,
		},
	}

	for _, test := range tests {
		authMethod, err := test.handler.GetAuthMethod(logMock)

		if test.err != nil {
			assert.Error(t, err, getString(test))
			assert.EqualError(t, test.err, err.Error(), getString(test))
			assert.Nil(t, authMethod, getString(test))
		} else {
			assert.NoError(t, err, getString(test))

			if test.authMethod == nil && test.authMethod == authMethod {
				assert.Nil(t, authMethod, getString(test))
			} else {
				assert.Equal(t, test.authMethod.Name(), authMethod.Name(), getString(test))
			}
		}
	}
}

func TestGitHandler_getAuthMethodPublicKey(t *testing.T) {
	bridge := bridgemock.GetSsmParamResolverBridge(parameterStoreParameters)

	tests := []struct {
		handler    gitHandler
		authMethod *gitssh.PublicKeys
		err        error
	}{
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
					User:     "admin",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey:       privateSSHKey,
					SkipHostKeyChecking: true,
				},
				ssmParameterResolverBridge: bridge,
			},
			getTestPublicKeysAuth("admin", privateSSHKey, ssh.InsecureIgnoreHostKey()),
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey: "{{ssm-secure:privateSSHKey}}",
				},
				ssmParameterResolverBridge: bridge,
			},
			getTestPublicKeysAuth("git", privateSSHKey, nil),
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey: "plain-string",
				},
				ssmParameterResolverBridge: bridge,
			},
			nil,
			errors.New("Cannot authenticate: ssh: no key found"),
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "ssh",
				},
				authConfig: GitAuthConfig{
					PrivateSSHKey: "{{ssm-secure:invalid-privateSSHKey-param}}",
				},
				ssmParameterResolverBridge: bridge,
			},
			nil,
			errors.New("parameter does not exist"),
		},
	}

	for _, test := range tests {
		authMethod, err := test.handler.getPublicKeyAuthMethod(logMock)

		if test.err != nil {
			assert.Error(t, err, getString(test))
			assert.EqualError(t, test.err, err.Error(), getString(test))
			assert.Nil(t, authMethod, getString(test))
		} else {
			publicKeyAuth := authMethod.(*gitssh.PublicKeys)

			assert.NoError(t, err, getString(test))
			assert.Equal(t, test.authMethod.Name(), publicKeyAuth.Name(), getString(test))
			assert.Equal(t, test.authMethod.User, publicKeyAuth.User, getString(test))
			assert.Equal(t, test.authMethod.Signer.PublicKey(), publicKeyAuth.Signer.PublicKey(), getString(test))
			assert.IsType(t, test.authMethod.HostKeyCallback, publicKeyAuth.HostKeyCallback, getString(test))
		}
	}
}

func TestGitHandler_getAuthMethodHTTPBasic(t *testing.T) {
	bridge := bridgemock.GetSsmParamResolverBridge(parameterStoreParameters)

	tests := []struct {
		handler    gitHandler
		authMethod *http.BasicAuth
		err        error
	}{
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					Username: "admin",
					Password: "pwd",
				},
				ssmParameterResolverBridge: bridge,
			},
			&http.BasicAuth{
				Username: "admin",
				Password: "pwd",
			},
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					Username: "{{ssm-secure:username}}",
					Password: "{{ssm-secure:password}}",
				},
				ssmParameterResolverBridge: bridge,
			},
			&http.BasicAuth{
				Username: "admin",
				Password: "pwd",
			},
			nil,
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					Username: "{{ssm-secure:invalid-username-param}}",
					Password: "{{ssm-secure:password}}",
				},
				ssmParameterResolverBridge: bridge,
			},
			nil,
			errors.New("parameter does not exist"),
		},
		{
			gitHandler{
				parsedRepositoryURL: transport.Endpoint{
					Protocol: "http",
				},
				authConfig: GitAuthConfig{
					Username: "{{ssm-secure:username}}",
					Password: "{{ssm-secure:invalid-password-param}}",
				},
				ssmParameterResolverBridge: bridge,
			},
			nil,
			errors.New("parameter does not exist"),
		},
	}

	for _, test := range tests {
		authMethod, err := test.handler.getHttpBasicAuthMethod(logMock)

		if test.err != nil {
			assert.Error(t, err, getString(test))
			assert.EqualError(t, test.err, err.Error(), getString(test))
			assert.Nil(t, authMethod, getString(test))
		} else {
			httpBasicAuth := authMethod.(*http.BasicAuth)

			assert.NoError(t, err, getString(test))
			assert.Equal(t, test.authMethod, httpBasicAuth, getString(test))
		}
	}
}

func TestGitHandler_CloneRepository(t *testing.T) {
	plainCloneMethod = PlainClone

	tests := []struct {
		handler          gitHandler
		usereuid         int
		homeVar          *string
		shouldUpdateHOME bool
		destPath         string
		err              error
	}{
		{
			handler: gitHandler{
				repositoryURL: "git@private-git-repo",
			},
			usereuid: 0,
			homeVar:  nil,
			destPath: "unknown-host",
			err: errors.New("Cannot clone repository git@private-git-repo: Unknown host key. Please add remote " +
				"host key known_hosts file or set SourceInfo 'skipHostKeyChecking' parameter to true in order to skip " +
				"host key validation"),
		},
		{
			handler: gitHandler{
				repositoryURL: "git@private-git-repo",
			},
			usereuid: 0,
			homeVar:  nil,
			destPath: "error",
			err:      errors.New("Cannot clone repository git@private-git-repo: random err"),
		},
		{
			handler: gitHandler{
				repositoryURL: "git@private-git-repo",
			},
			usereuid: 0,
			homeVar:  nil,
			destPath: "/tmp",
			err:      nil,
		},
	}

	for _, test := range tests {
		homeDirChecked := false
		homeDirSet := false
		homeDirUnset := false
		geteuid = func() int {
			return test.usereuid
		}

		getUserHomeDir = func() (string, error) {
			homeDirChecked = true
			if test.homeVar == nil {
				return "", fmt.Errorf("$HOME is not defined")
			}

			return *test.homeVar, nil
		}

		setEnv = func(key, value string) error {
			if key == "HOME" {
				homeDirSet = true
				assert.Equal(t, "/root", value, "Incorrect value set for root $HOME env variable")
			}

			return nil
		}

		unsetEnv = func(key string) error {
			if key == "HOME" {
				homeDirUnset = true
			}

			return nil
		}

		repository, err := test.handler.CloneRepository(logMock, nil, test.destPath)
		assert.True(t, homeDirChecked, "$HOME environment variable was not checked before calling git clone")
		if test.usereuid == 0 && test.homeVar == nil {
			assert.True(t, homeDirSet, "$HOME env variable was not set before calling git clone")
			assert.True(t, homeDirUnset, "$HOME env variable was not unset after calling git clone")
		}

		if test.err != nil {
			assert.Error(t, err, getString(test))
			assert.EqualError(t, test.err, err.Error(), getString(test))
			assert.Nil(t, repository, getString(test))
		} else {
			assert.NoError(t, err, getString(test))
			assert.NotNil(t, repository)
		}
	}

	plainCloneMethod = gogit.PlainClone
}

func TestGitHandler_performCheckout_FailedWorktreeRetrieval(t *testing.T) {
	handler := gitHandler{
		getOptions: gitresource.CheckoutOptions{
			Branch: "master",
		},
	}

	expectedError := fmt.Errorf("error")
	gitRepoMock := new(gitcoremock.GitRepositoryMock)
	gitRepoMock.On("Worktree").Return(new(gitcoremock.GitWorktreeMock), expectedError)

	err := handler.PerformCheckout(gitRepoMock)
	assert.Error(t, err)
	assert.Equal(t, fmt.Sprintf("Cannot retrieve repository's working tree: %v", expectedError.Error()), err.Error())
	gitRepoMock.AssertExpectations(t)
}

func TestGitHandler_performCheckout_FailedCheckoutOperation(t *testing.T) {
	handler := gitHandler{
		getOptions: gitresource.CheckoutOptions{
			CommitID: "commit123",
		},
	}

	expectedErr := fmt.Errorf("try again")
	gitRepoMock := new(gitcoremock.GitRepositoryMock)
	gitWorktreeMock := new(gitcoremock.GitWorktreeMock)

	gitRepoMock.On("Worktree").Return(gitWorktreeMock, nil)
	gitWorktreeMock.On("Checkout", mock.Anything).Return(expectedErr)

	err := handler.PerformCheckout(gitRepoMock)
	assert.Error(t, err)
	assert.EqualError(t, err, fmt.Sprintf("Cannot checkout: %v", expectedErr.Error()))
	gitWorktreeMock.AssertExpectations(t)
}

func TestGitHandler_performCheckout_CommitID(t *testing.T) {
	handler := gitHandler{
		getOptions: gitresource.CheckoutOptions{
			CommitID: "commit123",
		},
	}

	gitRepoMock := new(gitcoremock.GitRepositoryMock)
	gitWorktreeMock := new(gitcoremock.GitWorktreeMock)

	gitRepoMock.On("Worktree").Return(gitWorktreeMock, nil)
	gitWorktreeMock.On("Checkout", &gogit.CheckoutOptions{
		Hash: plumbing.NewHash("commit123"),
	}).Return(nil)

	err := handler.PerformCheckout(gitRepoMock)
	assert.NoError(t, err)
	gitWorktreeMock.AssertExpectations(t)
}

func TestGitHandler_performCheckout_Branch(t *testing.T) {
	handler := gitHandler{
		getOptions: gitresource.CheckoutOptions{
			Branch: "master",
		},
	}

	gitRepoMock := new(gitcoremock.GitRepositoryMock)
	gitWorktreeMock := new(gitcoremock.GitWorktreeMock)

	gitRepoMock.On("Worktree").Return(gitWorktreeMock, nil)
	gitWorktreeMock.On("Checkout", &gogit.CheckoutOptions{
		Branch: "master",
	}).Return(nil)

	err := handler.PerformCheckout(gitRepoMock)
	assert.NoError(t, err)
	gitWorktreeMock.AssertExpectations(t)
}
