// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

package agent

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"os"
	"strings"
	"testing"
	"time"

	log "github.com/hashicorp/go-hclog"
	"github.com/hashicorp/vault/api"
	credAppRole "github.com/hashicorp/vault/builtin/credential/approle"
	"github.com/hashicorp/vault/command/agentproxyshared/auth"
	agentapprole "github.com/hashicorp/vault/command/agentproxyshared/auth/approle"
	"github.com/hashicorp/vault/command/agentproxyshared/sink"
	"github.com/hashicorp/vault/command/agentproxyshared/sink/file"
	vaulthttp "github.com/hashicorp/vault/http"
	"github.com/hashicorp/vault/sdk/helper/logging"
	"github.com/hashicorp/vault/sdk/logical"
	"github.com/hashicorp/vault/vault"
)

func TestAppRoleEndToEnd(t *testing.T) {
	t.Parallel()

	testCases := []struct {
		removeSecretIDFile bool
		bindSecretID       bool
		secretIDLess       bool
		expectToken        bool
	}{
		// default behaviour => token expected
		{false, true, false, true},
		{true, true, false, true},

		//bindSecretID=false, wrong secret provided => token expected
		//(vault ignores the supplied secret_id if bind_secret_id=false)
		{false, false, false, true},
		{true, false, false, true},

		// bindSecretID=false, secret not provided => token expected
		{false, false, true, true},
		{true, false, true, true},

		// bindSecretID=true, secret not provided => token not expected
		{false, true, true, false},
		{true, true, true, false},
	}

	for _, tc := range testCases {
		secretFileAction := "preserve"
		if tc.removeSecretIDFile {
			secretFileAction = "remove"
		}
		tc := tc // capture range variable
		t.Run(fmt.Sprintf("%s_secret_id_file bindSecretID=%v secretIDLess=%v expectToken=%v", secretFileAction, tc.bindSecretID, tc.secretIDLess, tc.expectToken), func(t *testing.T) {
			t.Parallel()
			testAppRoleEndToEnd(t, tc.removeSecretIDFile, tc.bindSecretID, tc.secretIDLess, tc.expectToken)
		})
	}
}

func testAppRoleEndToEnd(t *testing.T, removeSecretIDFile bool, bindSecretID bool, secretIDLess bool, expectToken bool) {
	var err error
	logger := logging.NewVaultLogger(log.Trace)
	coreConfig := &vault.CoreConfig{
		DisableMlock: true,
		DisableCache: true,
		Logger:       log.NewNullLogger(),
		CredentialBackends: map[string]logical.Factory{
			"approle": credAppRole.Factory,
		},
	}

	cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
		HandlerFunc: vaulthttp.Handler,
	})

	cluster.Start()
	defer cluster.Cleanup()

	cores := cluster.Cores

	vault.TestWaitActive(t, cores[0].Core)

	client := cores[0].Client

	err = client.Sys().EnableAuthWithOptions("approle", &api.EnableAuthOptions{
		Type: "approle",
	})
	if err != nil {
		t.Fatal(err)
	}

	_, err = client.Logical().Write("auth/approle/role/test1", addConstraints(!bindSecretID, map[string]interface{}{
		"bind_secret_id": bindSecretID,
		"token_ttl":      "6s",
		"token_max_ttl":  "10s",
	}))

	logger.Trace("vault configured with", "bind_secret_id", bindSecretID)

	if err != nil {
		t.Fatal(err)
	}

	secret := ""
	secretID1 := ""
	secretID2 := ""
	if bindSecretID {
		resp, err := client.Logical().Write("auth/approle/role/test1/secret-id", nil)
		if err != nil {
			t.Fatal(err)
		}
		secretID1 = resp.Data["secret_id"].(string)
	} else {
		logger.Trace("skipped write to auth/approle/role/test1/secret-id")
	}
	resp, err := client.Logical().Read("auth/approle/role/test1/role-id")
	if err != nil {
		t.Fatal(err)
	}
	roleID1 := resp.Data["role_id"].(string)

	_, err = client.Logical().Write("auth/approle/role/test2", addConstraints(!bindSecretID, map[string]interface{}{
		"bind_secret_id": bindSecretID,
		"token_ttl":      "6s",
		"token_max_ttl":  "10s",
	}))
	if err != nil {
		t.Fatal(err)
	}
	if bindSecretID {
		resp, err = client.Logical().Write("auth/approle/role/test2/secret-id", nil)
		if err != nil {
			t.Fatal(err)
		}
		secretID2 = resp.Data["secret_id"].(string)
	} else {
		logger.Trace("skipped write to auth/approle/role/test2/secret-id")
	}
	resp, err = client.Logical().Read("auth/approle/role/test2/role-id")
	if err != nil {
		t.Fatal(err)
	}
	roleID2 := resp.Data["role_id"].(string)

	rolef, err := ioutil.TempFile("", "auth.role-id.test.")
	if err != nil {
		t.Fatal(err)
	}
	role := rolef.Name()
	rolef.Close() // WriteFile doesn't need it open
	defer os.Remove(role)
	t.Logf("input role_id_file_path: %s", role)
	if bindSecretID {
		secretf, err := ioutil.TempFile("", "auth.secret-id.test.")
		if err != nil {
			t.Fatal(err)
		}
		secret = secretf.Name()
		secretf.Close()
		defer os.Remove(secret)
		t.Logf("input secret_id_file_path: %s", secret)
	} else {
		logger.Trace("skipped writing tempfile auth.secret-id.test.")
	}
	// We close these right away because we're just basically testing
	// permissions and finding a usable file name
	ouf, err := ioutil.TempFile("", "auth.tokensink.test.")
	if err != nil {
		t.Fatal(err)
	}
	out := ouf.Name()
	ouf.Close()
	os.Remove(out)
	t.Logf("output: %s", out)

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

	secretFromAgent := secret
	if secretIDLess {
		secretFromAgent = ""
	}
	if !bindSecretID && !secretIDLess {
		logger.Trace("agent is providing an invalid secret that should be ignored")
		secretf, err := ioutil.TempFile("", "auth.secret-id.test.")
		if err != nil {
			t.Fatal(err)
		}
		secretFromAgent = secretf.Name()
		secretf.Close()
		defer os.Remove(secretFromAgent)
		// if the token is empty, auth.approle would fail reporting the error
		if err := ioutil.WriteFile(secretFromAgent, []byte("wrong-secret"), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote secret_id_file_path with wrong-secret", "path", secretFromAgent)
		}
	}
	conf := map[string]interface{}{
		"role_id_file_path":   role,
		"secret_id_file_path": secretFromAgent,
	}
	logger.Trace("agent configured with", "conf", conf)
	if !removeSecretIDFile {
		conf["remove_secret_id_file_after_reading"] = removeSecretIDFile
	}
	am, err := agentapprole.NewApproleAuthMethod(&auth.AuthConfig{
		Logger:    logger.Named("auth.approle"),
		MountPath: "auth/approle",
		Config:    conf,
	})
	if err != nil {
		t.Fatal(err)
	}
	ahConfig := &auth.AuthHandlerConfig{
		Logger: logger.Named("auth.handler"),
		Client: client,
	}
	ah := auth.NewAuthHandler(ahConfig)
	errCh := make(chan error)
	go func() {
		errCh <- ah.Run(ctx, am)
	}()
	defer func() {
		select {
		case <-ctx.Done():
		case err := <-errCh:
			if err != nil {
				t.Fatal(err)
			}
		}
	}()

	config := &sink.SinkConfig{
		Logger: logger.Named("sink.file"),
		Config: map[string]interface{}{
			"path": out,
		},
	}
	fs, err := file.NewFileSink(config)
	if err != nil {
		t.Fatal(err)
	}
	config.Sink = fs

	ss := sink.NewSinkServer(&sink.SinkServerConfig{
		Logger: logger.Named("sink.server"),
		Client: client,
	})

	go func() {
		errCh <- ss.Run(ctx, ah.OutputCh, []*sink.SinkConfig{config})
	}()
	defer func() {
		select {
		case <-ctx.Done():
		case err := <-errCh:
			if err != nil {
				t.Fatal(err)
			}
		}
	}()

	// This has to be after the other defers so it happens first. It allows
	// successful test runs to immediately cancel all of the runner goroutines
	// and unblock any of the blocking defer calls by the runner's DoneCh that
	// comes before this and avoid successful tests from taking the entire
	// timeout duration.
	defer cancel()

	// Check that no sink file exists
	_, err = os.Lstat(out)
	if err == nil {
		t.Fatal("expected err")
	}
	if !os.IsNotExist(err) {
		t.Fatal("expected notexist err")
	}

	if err := ioutil.WriteFile(role, []byte(roleID1), 0o600); err != nil {
		t.Fatal(err)
	} else {
		logger.Trace("wrote test role 1", "path", role)
	}

	if bindSecretID {
		if err := ioutil.WriteFile(secret, []byte(secretID1), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote test secret 1", "path", secret)
		}
	} else {
		logger.Trace("skipped writing test secret 1")
	}

	checkToken := func() string {
		timeout := time.Now().Add(10 * time.Second)
		for {
			if time.Now().After(timeout) {
				if expectToken {
					t.Fatal("did not find a written token after timeout")
				}
				return ""
			}
			val, err := ioutil.ReadFile(out)
			if err == nil {
				os.Remove(out)
				if len(val) == 0 {
					t.Fatal("written token was empty")
				}
				if !secretIDLess {
					_, err = os.Stat(secretFromAgent)
					switch {
					case removeSecretIDFile && err == nil:
						t.Fatal("secret file exists but was supposed to be removed")
					case !removeSecretIDFile && err != nil:
						t.Fatal("secret ID file does not exist but was not supposed to be removed")
					}
				}
				client.SetToken(string(val))
				secret, err := client.Auth().Token().LookupSelf()
				if err != nil {
					t.Fatal(err)
				}
				return secret.Data["entity_id"].(string)
			}
			time.Sleep(250 * time.Millisecond)
		}
	}
	origEntity := checkToken()
	if !expectToken && origEntity != "" {
		t.Fatal("did not expect a token to be written: " + origEntity)
	}
	if !expectToken && origEntity == "" {
		logger.Trace("skipping entities comparison as we are not expecting tokens to be written")
		return
	}

	// Make sure it gets renewed
	timeout := time.Now().Add(4 * time.Second)
	for {
		if time.Now().After(timeout) {
			break
		}
		secret, err := client.Auth().Token().LookupSelf()
		if err != nil {
			t.Fatal(err)
		}
		ttl, err := secret.Data["ttl"].(json.Number).Int64()
		if err != nil {
			t.Fatal(err)
		}
		if ttl > 6 {
			t.Fatalf("unexpected ttl: %v", secret.Data["ttl"])
		}
	}

	// Write new values
	if err := ioutil.WriteFile(role, []byte(roleID2), 0o600); err != nil {
		t.Fatal(err)
	} else {
		logger.Trace("wrote test role 2", "path", role)
	}

	if bindSecretID {
		if err := ioutil.WriteFile(secret, []byte(secretID2), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote test secret 2", "path", secret)
		}
	} else {
		logger.Trace("skipped writing test secret 2")
	}

	newEntity := checkToken()
	if newEntity == origEntity {
		t.Fatal("found same entity")
	}

	timeout = time.Now().Add(4 * time.Second)
	for {
		if time.Now().After(timeout) {
			break
		}
		secret, err := client.Auth().Token().LookupSelf()
		if err != nil {
			t.Fatal(err)
		}
		ttl, err := secret.Data["ttl"].(json.Number).Int64()
		if err != nil {
			t.Fatal(err)
		}
		if ttl > 6 {
			t.Fatalf("unexpected ttl: %v", secret.Data["ttl"])
		}
	}
}

// TestAppRoleLongRoleName tests that the creation of an approle is a maximum of 4096 bytes
// Prior to VAULT-8518 being fixed, you were unable to delete an approle value longer than 1024 bytes
// due to a restriction put into place by PR #14746, to prevent unbounded HMAC creation.
func TestAppRoleLongRoleName(t *testing.T) {
	approleName := strings.Repeat("a", 5000)

	coreConfig := &vault.CoreConfig{
		DisableMlock: true,
		DisableCache: true,
		Logger:       log.NewNullLogger(),
		CredentialBackends: map[string]logical.Factory{
			"approle": credAppRole.Factory,
		},
	}

	cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
		HandlerFunc: vaulthttp.Handler,
	})

	cluster.Start()
	defer cluster.Cleanup()

	cores := cluster.Cores

	vault.TestWaitActive(t, cores[0].Core)

	client := cores[0].Client

	err := client.Sys().EnableAuthWithOptions("approle", &api.EnableAuthOptions{
		Type: "approle",
	})
	if err != nil {
		t.Fatal(err)
	}

	_, err = client.Logical().Write(fmt.Sprintf("auth/approle/role/%s", approleName), map[string]interface{}{
		"token_ttl":     "6s",
		"token_max_ttl": "10s",
	})
	if err != nil {
		if !strings.Contains(err.Error(), "role_name is longer than maximum") {
			t.Fatal(err)
		}
	}
}

func TestAppRoleWithWrapping(t *testing.T) {
	testCases := []struct {
		bindSecretID bool
		secretIDLess bool
		expectToken  bool
	}{
		// default behaviour => token expected
		{true, false, true},

		//bindSecretID=false, wrong secret provided, wrapping_path provided => token not expected
		//(wrapping token is not valid or does not exist)
		{false, false, false},

		// bindSecretID=false, no secret provided, wrapping_path provided but ignored => token expected
		{false, true, true},
	}
	for _, tc := range testCases {
		t.Run(fmt.Sprintf("bindSecretID=%v secretIDLess=%v expectToken=%v", tc.bindSecretID, tc.secretIDLess, tc.expectToken), func(t *testing.T) {
			testAppRoleWithWrapping(t, tc.bindSecretID, tc.secretIDLess, tc.expectToken)
		})
	}
}

func testAppRoleWithWrapping(t *testing.T, bindSecretID bool, secretIDLess bool, expectToken bool) {
	var err error
	logger := logging.NewVaultLogger(log.Trace)
	coreConfig := &vault.CoreConfig{
		DisableMlock: true,
		DisableCache: true,
		Logger:       log.NewNullLogger(),
		CredentialBackends: map[string]logical.Factory{
			"approle": credAppRole.Factory,
		},
	}

	cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{
		HandlerFunc: vaulthttp.Handler,
	})

	cluster.Start()
	defer cluster.Cleanup()

	cores := cluster.Cores

	vault.TestWaitActive(t, cores[0].Core)

	client := cores[0].Client
	origToken := client.Token()

	err = client.Sys().EnableAuthWithOptions("approle", &api.EnableAuthOptions{
		Type: "approle",
	})
	if err != nil {
		t.Fatal(err)
	}

	_, err = client.Logical().Write("auth/approle/role/test1", addConstraints(!bindSecretID, map[string]interface{}{
		"bind_secret_id": bindSecretID,
		"token_ttl":      "6s",
		"token_max_ttl":  "10s",
	}))
	if err != nil {
		t.Fatal(err)
	}

	client.SetWrappingLookupFunc(func(operation, path string) string {
		if path == "auth/approle/role/test1/secret-id" {
			return "10s"
		}
		return ""
	})

	secret := ""
	secretID1 := ""
	if bindSecretID {
		resp, err := client.Logical().Write("auth/approle/role/test1/secret-id", nil)
		if err != nil {
			t.Fatal(err)
		}
		secretID1 = resp.WrapInfo.Token
	} else {
		logger.Trace("skipped write to auth/approle/role/test1/secret-id")
	}
	resp, err := client.Logical().Read("auth/approle/role/test1/role-id")
	if err != nil {
		t.Fatal(err)
	}
	roleID1 := resp.Data["role_id"].(string)

	rolef, err := ioutil.TempFile("", "auth.role-id.test.")
	if err != nil {
		t.Fatal(err)
	}
	role := rolef.Name()
	rolef.Close() // WriteFile doesn't need it open
	defer os.Remove(role)
	t.Logf("input role_id_file_path: %s", role)

	if bindSecretID {
		secretf, err := ioutil.TempFile("", "auth.secret-id.test.")
		if err != nil {
			t.Fatal(err)
		}
		secret = secretf.Name()
		secretf.Close()
		defer os.Remove(secret)
		t.Logf("input secret_id_file_path: %s", secret)
	} else {
		logger.Trace("skipped writing tempfile auth.secret-id.test.")
	}

	// We close these right away because we're just basically testing
	// permissions and finding a usable file name
	ouf, err := ioutil.TempFile("", "auth.tokensink.test.")
	if err != nil {
		t.Fatal(err)
	}
	out := ouf.Name()
	ouf.Close()
	os.Remove(out)
	t.Logf("output: %s", out)

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

	secretFromAgent := secret
	if secretIDLess {
		secretFromAgent = ""
	}
	if !bindSecretID && !secretIDLess {
		logger.Trace("agent is providing an invalid secret that should be ignored")
		secretf, err := ioutil.TempFile("", "auth.secret-id.test.")
		if err != nil {
			t.Fatal(err)
		}
		secretFromAgent = secretf.Name()
		secretf.Close()
		defer os.Remove(secretFromAgent)
		// if the token is empty, auth.approle would fail reporting the error
		if err := ioutil.WriteFile(secretFromAgent, []byte("wrong-secret"), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote secret_id_file_path with wrong-secret", "path", secretFromAgent)
		}
	}
	conf := map[string]interface{}{
		"role_id_file_path":                   role,
		"secret_id_file_path":                 secretFromAgent,
		"secret_id_response_wrapping_path":    "auth/approle/role/test1/secret-id",
		"remove_secret_id_file_after_reading": true,
	}
	logger.Trace("agent configured with", "conf", conf)

	am, err := agentapprole.NewApproleAuthMethod(&auth.AuthConfig{
		Logger:    logger.Named("auth.approle"),
		MountPath: "auth/approle",
		Config:    conf,
	})
	if err != nil {
		t.Fatal(err)
	}
	ahConfig := &auth.AuthHandlerConfig{
		Logger: logger.Named("auth.handler"),
		Client: client,
	}
	ah := auth.NewAuthHandler(ahConfig)
	errCh := make(chan error)
	go func() {
		errCh <- ah.Run(ctx, am)
	}()
	defer func() {
		select {
		case <-ctx.Done():
		case err := <-errCh:
			if err != nil {
				t.Fatal(err)
			}
		}
	}()

	config := &sink.SinkConfig{
		Logger: logger.Named("sink.file"),
		Config: map[string]interface{}{
			"path": out,
		},
	}
	fs, err := file.NewFileSink(config)
	if err != nil {
		t.Fatal(err)
	}
	config.Sink = fs

	ss := sink.NewSinkServer(&sink.SinkServerConfig{
		Logger: logger.Named("sink.server"),
		Client: client,
	})
	go func() {
		errCh <- ss.Run(ctx, ah.OutputCh, []*sink.SinkConfig{config})
	}()

	defer func() {
		select {
		case <-ctx.Done():
		case err := <-errCh:
			if err != nil {
				t.Fatal(err)
			}
		}
	}()

	// This has to be after the other defers so it happens first. It allows
	// successful test runs to immediately cancel all of the runner goroutines
	// and unblock any of the blocking defer calls by the runner's DoneCh that
	// comes before this and avoid successful tests from taking the entire
	// timeout duration.
	defer cancel()

	// Check that no sink file exists
	_, err = os.Lstat(out)
	if err == nil {
		t.Fatal("expected err")
	}
	if !os.IsNotExist(err) {
		t.Fatal("expected notexist err")
	}

	if err := ioutil.WriteFile(role, []byte(roleID1), 0o600); err != nil {
		t.Fatal(err)
	} else {
		logger.Trace("wrote test role 1", "path", role)
	}

	if bindSecretID {
		logger.Trace("WRITING TO auth.secret-id.test.", "secret", secret, "secretID1", secretID1)

		if err := ioutil.WriteFile(secret, []byte(secretID1), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote test secret 1", "path", secret)
		}
	} else {
		logger.Trace("skipped writing test secret 1")
	}

	checkToken := func() string {
		timeout := time.Now().Add(10 * time.Second)
		for {
			if time.Now().After(timeout) {
				if expectToken {
					t.Fatal("did not find a written token after timeout")
				}
				return ""
			}
			val, err := ioutil.ReadFile(out)
			if err == nil {
				os.Remove(out)
				if len(val) == 0 {
					t.Fatal("written token was empty")
				}
				if !secretIDLess {
					if _, err := os.Stat(secret); err == nil {
						t.Fatal("secret ID file does not exist but was not supposed to be removed")
					}
				}

				client.SetToken(string(val))
				secret, err := client.Auth().Token().LookupSelf()
				if err != nil {
					t.Fatal(err)
				}
				return secret.Data["entity_id"].(string)
			}
			time.Sleep(250 * time.Millisecond)
		}
	}
	origEntity := checkToken()
	logger.Trace("cheking token", "origEntity", origEntity)

	if !expectToken && origEntity != "" {
		t.Fatal("did not expect a token to be written: " + origEntity)
	}
	if !expectToken && origEntity == "" {
		logger.Trace("skipping entities comparison as we are not expecting tokens to be written")
		return
	}

	// Make sure it gets renewed
	timeout := time.Now().Add(4 * time.Second)
	for {
		if time.Now().After(timeout) {
			break
		}
		secret, err := client.Auth().Token().LookupSelf()
		if err != nil {
			t.Fatal(err)
		}
		ttl, err := secret.Data["ttl"].(json.Number).Int64()
		if err != nil {
			t.Fatal(err)
		}
		if ttl > 6 {
			t.Fatalf("unexpected ttl: %v", secret.Data["ttl"])
		}
	}

	// Write new values
	client.SetToken(origToken)
	logger.Trace("origToken set into client", "origToken", origToken)

	if bindSecretID {
		resp, err = client.Logical().Write("auth/approle/role/test1/secret-id", nil)
		if err != nil {
			t.Fatal(err)
		}
		secretID2 := resp.WrapInfo.Token
		if err := ioutil.WriteFile(secret, []byte(secretID2), 0o600); err != nil {
			t.Fatal(err)
		} else {
			logger.Trace("wrote test secret 2", "path", secret)
		}
	} else {
		logger.Trace("skipped writing test secret 2")
	}

	newEntity := checkToken()
	if newEntity != origEntity {
		t.Fatal("did not find same entity")
	}

	timeout = time.Now().Add(4 * time.Second)
	for {
		if time.Now().After(timeout) {
			break
		}
		secret, err := client.Auth().Token().LookupSelf()
		if err != nil {
			t.Fatal(err)
		}
		ttl, err := secret.Data["ttl"].(json.Number).Int64()
		if err != nil {
			t.Fatal(err)
		}
		if ttl > 6 {
			t.Fatalf("unexpected ttl: %v", secret.Data["ttl"])
		}
	}
}

func addConstraints(add bool, cfg map[string]interface{}) map[string]interface{} {
	if add {
		// extraConstraints to add when bind_secret_id=false (otherwise Vault would fail with: "at least one constraint should be enabled on the role")
		extraConstraints := map[string]interface{}{
			"secret_id_bound_cidrs": "127.0.0.1/32",
			"token_bound_cidrs":     "127.0.0.1/32",
		}
		for k, v := range extraConstraints {
			cfg[k] = v
		}
	}
	return cfg
}
