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

package autoconf

import (
	"context"
	"crypto/x509"
	"crypto/x509/pkix"
	"fmt"
	"net"
	"net/url"
	"testing"
	"time"

	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"

	"github.com/hashicorp/consul/agent/cache"
	cachetype "github.com/hashicorp/consul/agent/cache-types"
	"github.com/hashicorp/consul/agent/config"
	"github.com/hashicorp/consul/agent/connect"
	"github.com/hashicorp/consul/agent/leafcert"
	"github.com/hashicorp/consul/agent/metadata"
	"github.com/hashicorp/consul/agent/structs"
	"github.com/hashicorp/consul/lib/retry"
	"github.com/hashicorp/consul/sdk/testutil"
)

func TestAutoEncrypt_generateCSR(t *testing.T) {
	type testCase struct {
		conf *config.RuntimeConfig

		// to validate the csr
		expectedSubject  pkix.Name
		expectedSigAlg   x509.SignatureAlgorithm
		expectedPubAlg   x509.PublicKeyAlgorithm
		expectedDNSNames []string
		expectedIPs      []net.IP
		expectedURIs     []*url.URL
	}

	cases := map[string]testCase{
		"ip-sans": {
			conf: &config.RuntimeConfig{
				Datacenter:       "dc1",
				NodeName:         "test-node",
				AutoEncryptTLS:   true,
				AutoEncryptIPSAN: []net.IP{net.IPv4(198, 18, 0, 1), net.IPv4(198, 18, 0, 2)},
			},
			expectedSubject:  pkix.Name{},
			expectedSigAlg:   x509.ECDSAWithSHA256,
			expectedPubAlg:   x509.ECDSA,
			expectedDNSNames: defaultDNSSANs,
			expectedIPs: append(defaultIPSANs,
				net.IP{198, 18, 0, 1},
				net.IP{198, 18, 0, 2},
			),
			expectedURIs: []*url.URL{
				{
					Scheme: "spiffe",
					Host:   unknownTrustDomain,
					Path:   "/agent/client/dc/dc1/id/test-node",
				},
			},
		},
		"dns-sans": {
			conf: &config.RuntimeConfig{
				Datacenter:        "dc1",
				NodeName:          "test-node",
				AutoEncryptTLS:    true,
				AutoEncryptDNSSAN: []string{"foo.local", "bar.local"},
			},
			expectedSubject:  pkix.Name{},
			expectedSigAlg:   x509.ECDSAWithSHA256,
			expectedPubAlg:   x509.ECDSA,
			expectedDNSNames: append(defaultDNSSANs, "foo.local", "bar.local"),
			expectedIPs:      defaultIPSANs,
			expectedURIs: []*url.URL{
				{
					Scheme: "spiffe",
					Host:   unknownTrustDomain,
					Path:   "/agent/client/dc/dc1/id/test-node",
				},
			},
		},
	}

	for name, tcase := range cases {
		t.Run(name, func(t *testing.T) {
			ac := AutoConfig{config: tcase.conf}

			csr, _, err := ac.generateCSR()
			require.NoError(t, err)

			request, err := connect.ParseCSR(csr)
			require.NoError(t, err)
			require.NotNil(t, request)

			require.Equal(t, tcase.expectedSubject, request.Subject)
			require.Equal(t, tcase.expectedSigAlg, request.SignatureAlgorithm)
			require.Equal(t, tcase.expectedPubAlg, request.PublicKeyAlgorithm)
			require.Equal(t, tcase.expectedDNSNames, request.DNSNames)
			require.Equal(t, tcase.expectedIPs, request.IPAddresses)
			require.Equal(t, tcase.expectedURIs, request.URIs)
		})
	}
}

func TestAutoEncrypt_hosts(t *testing.T) {
	type testCase struct {
		serverProvider ServerProvider
		config         *config.RuntimeConfig

		hosts []string
		err   string
	}

	providerNone := newMockServerProvider(t)
	providerNone.On("FindLANServer").Return(nil).Times(0)

	providerWithServer := newMockServerProvider(t)
	providerWithServer.On("FindLANServer").Return(&metadata.Server{Addr: &net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 1234}}).Times(0)

	cases := map[string]testCase{
		"router-override": {
			serverProvider: providerWithServer,
			config: &config.RuntimeConfig{
				RetryJoinLAN: []string{"127.0.0.1:9876", "192.168.1.2:4321"},
			},
			hosts: []string{"198.18.0.1:1234"},
		},
		"various-addresses": {
			serverProvider: providerNone,
			config: &config.RuntimeConfig{
				RetryJoinLAN: []string{
					"192.168.1.1:5432",
					"start.local",
					"[::ffff:172.16.5.4]",
					"main.dev:6789",
					"198.18.0.1",
					"foo.com",
					"[2001:db8::1234]:1234",
					"abc.local:9876",
				},
			},
			hosts: []string{
				"192.168.1.1",
				"start.local",
				"[::ffff:172.16.5.4]",
				"main.dev",
				"198.18.0.1",
				"foo.com",
				"2001:db8::1234",
				"abc.local",
			},
		},
		"split-host-port-error": {
			serverProvider: providerNone,
			config: &config.RuntimeConfig{
				RetryJoinLAN: []string{"this-is-not:a:ip:and_port"},
			},
			err: "no auto-encrypt server addresses available for use",
		},
	}

	for name, tcase := range cases {
		t.Run(name, func(t *testing.T) {
			ac := AutoConfig{
				config: tcase.config,
				logger: testutil.Logger(t),
				acConfig: Config{
					ServerProvider: tcase.serverProvider,
				},
			}

			hosts, err := ac.joinHosts()
			if tcase.err != "" {
				testutil.RequireErrorContains(t, err, tcase.err)
			} else {
				require.NoError(t, err)
				require.Equal(t, tcase.hosts, hosts)
			}
		})
	}
}

func TestAutoEncrypt_InitialCerts(t *testing.T) {
	token := "1a148388-3dd7-4db4-9eea-520424b4a86a"
	datacenter := "foo"
	nodeName := "bar"

	mcfg := newMockedConfig(t)

	_, indexedRoots, cert := testCerts(t, nodeName, datacenter)

	// The following are called once for each round through the auto-encrypt initial certs outer loop
	// (not the per-host direct rpc attempts but the one involving the RetryWaiter)
	mcfg.tokens.On("AgentToken").Return(token).Times(2)
	mcfg.serverProvider.On("FindLANServer").Return(nil).Times(2)

	request := structs.CASignRequest{
		WriteRequest: structs.WriteRequest{Token: token},
		Datacenter:   datacenter,
		// this gets removed by the mock code as its non-deterministic what it will be
		CSR: "",
	}

	// first failure
	mcfg.directRPC.On("RPC",
		datacenter,
		nodeName,
		&net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 8300},
		"AutoEncrypt.Sign",
		&request,
		&structs.SignedResponse{},
	).Once().Return(fmt.Errorf("injected error"))
	// second failure
	mcfg.directRPC.On("RPC",
		datacenter,
		nodeName,
		&net.TCPAddr{IP: net.IPv4(198, 18, 0, 2), Port: 8300},
		"AutoEncrypt.Sign",
		&request,
		&structs.SignedResponse{},
	).Once().Return(fmt.Errorf("injected error"))
	// third times is successfuly (second attempt to first server)
	mcfg.directRPC.On("RPC",
		datacenter,
		nodeName,
		&net.TCPAddr{IP: net.IPv4(198, 18, 0, 1), Port: 8300},
		"AutoEncrypt.Sign",
		&request,
		&structs.SignedResponse{},
	).Once().Return(nil).Run(func(args mock.Arguments) {
		resp, ok := args.Get(5).(*structs.SignedResponse)
		require.True(t, ok)
		resp.ConnectCARoots = *indexedRoots
		resp.IssuedCert = *cert
		resp.VerifyServerHostname = true
	})

	mcfg.Config.Waiter = &retry.Waiter{MinFailures: 2, MaxWait: time.Millisecond}

	ac := AutoConfig{
		config: &config.RuntimeConfig{
			Datacenter:   datacenter,
			NodeName:     nodeName,
			RetryJoinLAN: []string{"198.18.0.1:1234", "198.18.0.2:3456"},
			ServerPort:   8300,
		},
		acConfig: mcfg.Config,
		logger:   testutil.Logger(t),
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	resp, err := ac.autoEncryptInitialCerts(ctx)
	require.NoError(t, err)
	require.NotNil(t, resp)
	require.True(t, resp.VerifyServerHostname)
	require.NotEmpty(t, resp.IssuedCert.PrivateKeyPEM)
	resp.IssuedCert.PrivateKeyPEM = ""
	cert.PrivateKeyPEM = ""
	require.Equal(t, cert, &resp.IssuedCert)
	require.Equal(t, indexedRoots, &resp.ConnectCARoots)
	require.Empty(t, resp.ManualCARoots)
}

func TestAutoEncrypt_InitialConfiguration(t *testing.T) {
	token := "010494ae-ee45-4433-903c-a58c91297714"
	nodeName := "auto-encrypt"
	datacenter := "dc1"

	mcfg := newMockedConfig(t)
	loader := setupRuntimeConfig(t)
	loader.addConfigHCL(`
		auto_encrypt {
			tls = true
		}
	`)
	loader.opts.FlagValues.NodeName = &nodeName
	mcfg.Config.Loader = loader.Load

	indexedRoots, cert, extraCerts := mcfg.setupInitialTLS(t, nodeName, datacenter, token)

	// prepopulation is going to grab the token to populate the correct cache key
	mcfg.tokens.On("AgentToken").Return(token).Times(0)

	// no server provider
	mcfg.serverProvider.On("FindLANServer").Return(&metadata.Server{Addr: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8300}}).Times(1)

	populateResponse := func(args mock.Arguments) {
		resp, ok := args.Get(5).(*structs.SignedResponse)
		require.True(t, ok)
		*resp = structs.SignedResponse{
			VerifyServerHostname: true,
			ConnectCARoots:       *indexedRoots,
			IssuedCert:           *cert,
			ManualCARoots:        extraCerts,
		}
	}

	expectedRequest := structs.CASignRequest{
		WriteRequest: structs.WriteRequest{Token: token},
		Datacenter:   datacenter,
		// TODO (autoconf) Maybe in the future we should populate a CSR
		// and do some manual parsing/verification of the contents. The
		// bits not having to do with the signing key such as the requested
		// SANs and CN. For now though the mockDirectRPC type will empty
		// the CSR so we have to pass in an empty string to the expectation.
		CSR: "",
	}

	mcfg.directRPC.On(
		"RPC",
		datacenter,
		nodeName,
		&net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8300},
		"AutoEncrypt.Sign",
		&expectedRequest,
		&structs.SignedResponse{}).Return(nil).Run(populateResponse)

	ac, err := New(mcfg.Config)
	require.NoError(t, err)
	require.NotNil(t, ac)

	cfg, err := ac.InitialConfiguration(context.Background())
	require.NoError(t, err)
	require.NotNil(t, cfg)

}

func TestAutoEncrypt_TokenUpdate(t *testing.T) {
	testAC := startedAutoConfig(t, true)

	newToken := "1a4cc445-86ed-46b4-a355-bbf5a11dddb0"

	rootsCtx, rootsCancel := context.WithCancel(context.Background())
	testAC.mcfg.cache.On("Notify",
		mock.Anything,
		cachetype.ConnectCARootName,
		&structs.DCSpecificRequest{Datacenter: testAC.ac.config.Datacenter},
		rootsWatchID,
		mock.Anything,
	).Return(nil).Once().Run(func(args mock.Arguments) {
		rootsCancel()
	})

	leafCtx, leafCancel := context.WithCancel(context.Background())
	testAC.mcfg.leafCerts.On("Notify",
		mock.Anything,
		&leafcert.ConnectCALeafRequest{
			Datacenter: "dc1",
			Agent:      "autoconf",
			Token:      newToken,
			DNSSAN:     defaultDNSSANs,
			IPSAN:      defaultIPSANs,
		},
		leafWatchID,
		mock.Anything,
	).Return(nil).Once().Run(func(args mock.Arguments) {
		leafCancel()
	})

	// this will be retrieved once when resetting the leaf cert watch
	testAC.mcfg.tokens.On("AgentToken").Return(newToken).Once()

	// send the notification about the token update
	testAC.tokenUpdates <- struct{}{}

	// wait for the leaf cert watches
	require.True(t, waitForChans(100*time.Millisecond, leafCtx.Done(), rootsCtx.Done()), "New cache watches were not started within 100ms")
}

func TestAutoEncrypt_RootsUpdate(t *testing.T) {
	testAC := startedAutoConfig(t, true)

	secondCA := connect.TestCA(t, testAC.initialRoots.Roots[0])
	secondRoots := structs.IndexedCARoots{
		ActiveRootID: secondCA.ID,
		TrustDomain:  connect.TestClusterID,
		Roots: []*structs.CARoot{
			secondCA,
			testAC.initialRoots.Roots[0],
		},
		QueryMeta: structs.QueryMeta{
			Index: 99,
		},
	}

	updatedCtx, cancel := context.WithCancel(context.Background())
	testAC.mcfg.tlsCfg.On("UpdateAutoTLSCA",
		[]string{secondCA.RootCert, testAC.initialRoots.Roots[0].RootCert},
	).Return(nil).Once().Run(func(args mock.Arguments) {
		cancel()
	})

	// when a cache event comes in we end up recalculating the fallback timer which requires this call
	testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
		NotAfter: time.Now().Add(10 * time.Minute),
	}).Once()

	req := structs.DCSpecificRequest{Datacenter: "dc1"}
	require.True(t, testAC.mcfg.cache.sendNotification(context.Background(), req.CacheInfo().Key, cache.UpdateEvent{
		CorrelationID: rootsWatchID,
		Result:        &secondRoots,
		Meta: cache.ResultMeta{
			Index: secondRoots.Index,
		},
	}))

	require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")
}

func TestAutoEncrypt_CertUpdate(t *testing.T) {
	testAC := startedAutoConfig(t, true)
	secondCert := newLeaf(t, "autoconf", "dc1", testAC.initialRoots.Roots[0], 99, 10*time.Minute)

	updatedCtx, cancel := context.WithCancel(context.Background())
	testAC.mcfg.tlsCfg.On("UpdateAutoTLSCert",
		secondCert.CertPEM,
		"redacted",
	).Return(nil).Once().Run(func(args mock.Arguments) {
		cancel()
	})

	// when a cache event comes in we end up recalculating the fallback timer which requires this call
	testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
		NotAfter: secondCert.ValidBefore,
	}).Once()

	req := leafcert.ConnectCALeafRequest{
		Datacenter: "dc1",
		Agent:      "autoconf",
		Token:      testAC.originalToken,
		DNSSAN:     defaultDNSSANs,
		IPSAN:      defaultIPSANs,
	}
	require.True(t, testAC.mcfg.leafCerts.sendNotification(context.Background(), req.Key(), cache.UpdateEvent{
		CorrelationID: leafWatchID,
		Result:        secondCert,
		Meta: cache.ResultMeta{
			Index: secondCert.ModifyIndex,
		},
	}))

	require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")
}

func TestAutoEncrypt_Fallback(t *testing.T) {
	testAC := startedAutoConfig(t, true)

	// at this point everything is operating normally and we are just
	// waiting for events. We are going to send a new cert that is basically
	// already expired and then allow the fallback routine to kick in.
	secondCert := newLeaf(t, "autoconf", "dc1", testAC.initialRoots.Roots[0], 100, time.Nanosecond)
	secondCA := connect.TestCA(t, testAC.initialRoots.Roots[0])
	secondRoots := structs.IndexedCARoots{
		ActiveRootID: secondCA.ID,
		TrustDomain:  connect.TestClusterID,
		Roots: []*structs.CARoot{
			secondCA,
			testAC.initialRoots.Roots[0],
		},
		QueryMeta: structs.QueryMeta{
			Index: 101,
		},
	}
	thirdCert := newLeaf(t, "autoconf", "dc1", secondCA, 102, 10*time.Minute)

	// setup the expectation for when the certs get updated initially
	updatedCtx, updateCancel := context.WithCancel(context.Background())
	testAC.mcfg.tlsCfg.On("UpdateAutoTLSCert",
		secondCert.CertPEM,
		"redacted",
	).Return(nil).Once().Run(func(args mock.Arguments) {
		updateCancel()
	})

	// when a cache event comes in we end up recalculating the fallback timer which requires this call
	testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
		NotAfter: secondCert.ValidBefore,
	}).Times(2)

	fallbackCtx, fallbackCancel := context.WithCancel(context.Background())

	// also testing here that we can change server IPs for ongoing operations
	testAC.mcfg.serverProvider.On("FindLANServer").Once().Return(&metadata.Server{
		Addr: &net.TCPAddr{IP: net.IPv4(198, 18, 23, 2), Port: 8300},
	})

	// after sending the notification for the cert update another InitialConfiguration RPC
	// will be made to pull down the latest configuration. So we need to set up the response
	// for the second RPC
	populateResponse := func(args mock.Arguments) {
		resp, ok := args.Get(5).(*structs.SignedResponse)
		require.True(t, ok)
		*resp = structs.SignedResponse{
			VerifyServerHostname: true,
			ConnectCARoots:       secondRoots,
			IssuedCert:           *thirdCert,
			ManualCARoots:        testAC.extraCerts,
		}

		fallbackCancel()
	}

	expectedRequest := structs.CASignRequest{
		WriteRequest: structs.WriteRequest{Token: testAC.originalToken},
		Datacenter:   "dc1",
		// TODO (autoconf) Maybe in the future we should populate a CSR
		// and do some manual parsing/verification of the contents. The
		// bits not having to do with the signing key such as the requested
		// SANs and CN. For now though the mockDirectRPC type will empty
		// the CSR so we have to pass in an empty string to the expectation.
		CSR: "",
	}

	// the fallback routine to perform auto-encrypt again will need to grab this
	testAC.mcfg.tokens.On("AgentToken").Return(testAC.originalToken).Once()

	testAC.mcfg.directRPC.On(
		"RPC",
		"dc1",
		"autoconf",
		&net.TCPAddr{IP: net.IPv4(198, 18, 23, 2), Port: 8300},
		"AutoEncrypt.Sign",
		&expectedRequest,
		&structs.SignedResponse{}).Return(nil).Run(populateResponse).Once()

	testAC.mcfg.expectInitialTLS(t, "autoconf", "dc1", testAC.originalToken, secondCA, &secondRoots, thirdCert, testAC.extraCerts)

	// after the second RPC we now will use the new certs validity period in the next run loop iteration
	testAC.mcfg.tlsCfg.On("AutoEncryptCert").Return(&x509.Certificate{
		NotAfter: time.Now().Add(10 * time.Minute),
	}).Once()

	// now that all the mocks are set up we can trigger the whole thing by sending the second expired cert
	// as a cache update event.
	req := leafcert.ConnectCALeafRequest{
		Datacenter: "dc1",
		Agent:      "autoconf",
		Token:      testAC.originalToken,
		DNSSAN:     defaultDNSSANs,
		IPSAN:      defaultIPSANs,
	}
	require.True(t, testAC.mcfg.leafCerts.sendNotification(context.Background(), req.Key(), cache.UpdateEvent{
		CorrelationID: leafWatchID,
		Result:        secondCert,
		Meta: cache.ResultMeta{
			Index: secondCert.ModifyIndex,
		},
	}))

	// wait for the TLS certificates to get updated
	require.True(t, waitForChans(100*time.Millisecond, updatedCtx.Done()), "TLS certificates were not updated within the alotted time")

	// now wait for the fallback routine to be invoked
	require.True(t, waitForChans(100*time.Millisecond, fallbackCtx.Done()), "fallback routines did not get invoked within the alotted time")
}
