/*
Copyright (c) Edgeless Systems GmbH

SPDX-License-Identifier: AGPL-3.0-only
*/

package gcp

import (
	"context"
	"errors"
	"testing"

	compute "cloud.google.com/go/compute/apiv1"
	"github.com/edgelesssys/constellation/v2/internal/cloud/metadata"
	"github.com/edgelesssys/constellation/v2/internal/role"
	gax "github.com/googleapis/gax-go/v2"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.uber.org/goleak"
	"google.golang.org/api/iterator"
	computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
	"google.golang.org/protobuf/proto"
)

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m,
		// https://github.com/census-instrumentation/opencensus-go/issues/1262
		goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
	)
}

func TestRetrieveInstances(t *testing.T) {
	uid := "1234"
	someErr := errors.New("failed")
	newTestIter := func() *stubInstanceIterator {
		return &stubInstanceIterator{
			instances: []*computepb.Instance{
				{
					Name: proto.String("someInstance"),
					Metadata: &computepb.Metadata{
						Items: []*computepb.Items{
							{
								Key:   proto.String("ssh-keys"),
								Value: proto.String("bob:ssh-rsa bobskey"),
							},
							{
								Key:   proto.String("key-2"),
								Value: proto.String("value-2"),
							},
							{
								Key:   proto.String(constellationUIDMetadataKey),
								Value: proto.String(uid),
							},
							{
								Key:   proto.String(roleMetadataKey),
								Value: proto.String(role.ControlPlane.String()),
							},
						},
					},
					NetworkInterfaces: []*computepb.NetworkInterface{
						{
							Name:          proto.String("nic0"),
							NetworkIP:     proto.String("192.0.2.0"),
							AliasIpRanges: []*computepb.AliasIpRange{{IpCidrRange: proto.String("192.0.2.0/16")}},
							AccessConfigs: []*computepb.AccessConfig{{NatIP: proto.String("192.0.2.1")}},
						},
					},
				},
			},
		}
	}

	testCases := map[string]struct {
		client              stubInstancesClient
		metadata            stubMetadataClient
		instanceIter        *stubInstanceIterator
		instanceIterMutator func(*stubInstanceIterator)
		wantInstances       []metadata.InstanceMetadata
		wantErr             bool
	}{
		"retrieve works": {
			client:       stubInstancesClient{},
			metadata:     stubMetadataClient{InstanceValue: uid},
			instanceIter: newTestIter(),
			wantInstances: []metadata.InstanceMetadata{
				{
					Name:          "someInstance",
					ProviderID:    "gce://someProject/someZone/someInstance",
					Role:          role.ControlPlane,
					AliasIPRanges: []string{"192.0.2.0/16"},
					PublicIP:      "192.0.2.1",
					VPCIP:         "192.0.2.0",
					SSHKeys:       map[string][]string{"bob": {"ssh-rsa bobskey"}},
				},
			},
		},
		"instance name is null": {
			client:              stubInstancesClient{},
			metadata:            stubMetadataClient{InstanceValue: uid},
			instanceIter:        newTestIter(),
			instanceIterMutator: func(sii *stubInstanceIterator) { sii.instances[0].Name = nil },
			wantErr:             true,
		},
		"no instance with network ip": {
			client:              stubInstancesClient{},
			metadata:            stubMetadataClient{InstanceValue: uid},
			instanceIter:        newTestIter(),
			instanceIterMutator: func(sii *stubInstanceIterator) { sii.instances[0].NetworkInterfaces = nil },
			wantInstances: []metadata.InstanceMetadata{
				{
					Name:          "someInstance",
					ProviderID:    "gce://someProject/someZone/someInstance",
					Role:          role.ControlPlane,
					AliasIPRanges: []string{},
					PublicIP:      "",
					VPCIP:         "",
					SSHKeys:       map[string][]string{"bob": {"ssh-rsa bobskey"}},
				},
			},
		},
		"network ip is nil": {
			client:              stubInstancesClient{},
			metadata:            stubMetadataClient{InstanceValue: uid},
			instanceIter:        newTestIter(),
			instanceIterMutator: func(sii *stubInstanceIterator) { sii.instances[0].NetworkInterfaces[0].NetworkIP = nil },
			wantInstances: []metadata.InstanceMetadata{
				{
					Name:          "someInstance",
					ProviderID:    "gce://someProject/someZone/someInstance",
					Role:          role.ControlPlane,
					AliasIPRanges: []string{"192.0.2.0/16"},
					PublicIP:      "192.0.2.1",
					VPCIP:         "",
					SSHKeys:       map[string][]string{"bob": {"ssh-rsa bobskey"}},
				},
			},
		},
		"constellation id is not set": {
			client:              stubInstancesClient{},
			metadata:            stubMetadataClient{InstanceValue: uid},
			instanceIter:        newTestIter(),
			instanceIterMutator: func(sii *stubInstanceIterator) { sii.instances[0].Metadata.Items[2].Key = proto.String("") },
			wantInstances:       []metadata.InstanceMetadata{},
		},
		"constellation retrieval fails": {
			client:       stubInstancesClient{},
			metadata:     stubMetadataClient{InstanceErr: someErr},
			instanceIter: newTestIter(),
			wantErr:      true,
		},
		"role is not set": {
			client:              stubInstancesClient{},
			metadata:            stubMetadataClient{InstanceValue: uid},
			instanceIter:        newTestIter(),
			instanceIterMutator: func(sii *stubInstanceIterator) { sii.instances[0].Metadata.Items[3].Key = proto.String("") },
			wantInstances: []metadata.InstanceMetadata{
				{
					Name:          "someInstance",
					ProviderID:    "gce://someProject/someZone/someInstance",
					Role:          role.Unknown,
					AliasIPRanges: []string{"192.0.2.0/16"},
					PublicIP:      "192.0.2.1",
					VPCIP:         "192.0.2.0",
					SSHKeys:       map[string][]string{"bob": {"ssh-rsa bobskey"}},
				},
			},
		},
		"instance iterator Next() errors": {
			client:       stubInstancesClient{},
			metadata:     stubMetadataClient{InstanceValue: uid},
			instanceIter: &stubInstanceIterator{nextErr: someErr},
			wantErr:      true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			if tc.instanceIterMutator != nil {
				tc.instanceIterMutator(tc.instanceIter)
			}
			tc.client.ListInstanceIterator = tc.instanceIter
			client := Client{
				instanceAPI: tc.client,
				metadataAPI: tc.metadata,
			}

			instances, err := client.RetrieveInstances(context.Background(), "someProject", "someZone")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantInstances, instances)
		})
	}
}

func TestRetrieveInstance(t *testing.T) {
	newTestInstance := func() *computepb.Instance {
		return &computepb.Instance{
			Name: proto.String("someInstance"),
			Metadata: &computepb.Metadata{
				Items: []*computepb.Items{
					{
						Key:   proto.String("key-1"),
						Value: proto.String("value-1"),
					},
					{
						Key:   proto.String("key-2"),
						Value: proto.String("value-2"),
					},
				},
			},
			NetworkInterfaces: []*computepb.NetworkInterface{
				{
					Name:          proto.String("nic0"),
					NetworkIP:     proto.String("192.0.2.0"),
					AliasIpRanges: []*computepb.AliasIpRange{{IpCidrRange: proto.String("192.0.2.0/16")}},
					AccessConfigs: []*computepb.AccessConfig{{NatIP: proto.String("192.0.2.1")}},
				},
			},
		}
	}

	testCases := map[string]struct {
		client                stubInstancesClient
		clientInstance        *computepb.Instance
		clientInstanceMutator func(*computepb.Instance)
		wantInstance          metadata.InstanceMetadata
		wantErr               bool
	}{
		"retrieve works": {
			client:         stubInstancesClient{},
			clientInstance: newTestInstance(),
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"retrieve with SSH key works": {
			client:         stubInstancesClient{},
			clientInstance: newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) {
				i.Metadata.Items[0].Key = proto.String("ssh-keys")
				i.Metadata.Items[0].Value = proto.String("bob:ssh-rsa bobskey")
			},
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{"bob": {"ssh-rsa bobskey"}},
			},
		},
		"retrieve with Role works": {
			client:         stubInstancesClient{},
			clientInstance: newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) {
				i.Metadata.Items[0].Key = proto.String(roleMetadataKey)
				i.Metadata.Items[0].Value = proto.String(role.ControlPlane.String())
			},
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				Role:          role.ControlPlane,
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"retrieve fails": {
			client: stubInstancesClient{
				GetErr: errors.New("retrieve error"),
			},
			clientInstance: nil,
			wantErr:        true,
		},
		"metadata item is null": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.Metadata.Items[0] = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"metadata key is null": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.Metadata.Items[0].Key = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"metadata value is null": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.Metadata.Items[0].Value = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"instance without network ip": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.NetworkInterfaces[0] = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{},
				PublicIP:      "",
				VPCIP:         "",
				SSHKeys:       map[string][]string{},
			},
		},
		"network ip is nil": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.NetworkInterfaces[0].NetworkIP = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "192.0.2.1",
				VPCIP:         "",
				SSHKeys:       map[string][]string{},
			},
		},
		"network alias cidr is nil": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.NetworkInterfaces[0].AliasIpRanges[0].IpCidrRange = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{},
				PublicIP:      "192.0.2.1",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
		"network public ip is nil": {
			client:                stubInstancesClient{},
			clientInstance:        newTestInstance(),
			clientInstanceMutator: func(i *computepb.Instance) { i.NetworkInterfaces[0].AccessConfigs[0].NatIP = nil },
			wantInstance: metadata.InstanceMetadata{
				Name:          "someInstance",
				ProviderID:    "gce://someProject/someZone/someInstance",
				AliasIPRanges: []string{"192.0.2.0/16"},
				PublicIP:      "",
				VPCIP:         "192.0.2.0",
				SSHKeys:       map[string][]string{},
			},
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			if tc.clientInstanceMutator != nil {
				tc.clientInstanceMutator(tc.clientInstance)
			}
			tc.client.GetInstance = tc.clientInstance
			client := Client{instanceAPI: tc.client}

			instance, err := client.RetrieveInstance(context.Background(), "someProject", "someZone", "someInstance")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantInstance, instance)
		})
	}
}

func TestRetrieveProjectID(t *testing.T) {
	someErr := errors.New("failed")

	testCases := map[string]struct {
		client    stubMetadataClient
		wantValue string
		wantErr   bool
	}{
		"retrieve works": {
			client:    stubMetadataClient{ProjectIDValue: "someProjectID"},
			wantValue: "someProjectID",
		},
		"retrieve fails": {
			client:  stubMetadataClient{ProjectIDErr: someErr},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{metadataAPI: tc.client}
			value, err := client.RetrieveProjectID()

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantValue, value)
		})
	}
}

func TestRetrieveZone(t *testing.T) {
	someErr := errors.New("failed")

	testCases := map[string]struct {
		client    stubMetadataClient
		wantValue string
		wantErr   bool
	}{
		"retrieve works": {
			client:    stubMetadataClient{ZoneValue: "someZone"},
			wantValue: "someZone",
		},
		"retrieve fails": {
			client:  stubMetadataClient{ZoneErr: someErr},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{metadataAPI: tc.client}
			value, err := client.RetrieveZone()

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantValue, value)
		})
	}
}

func TestRetrieveInstanceName(t *testing.T) {
	someErr := errors.New("failed")

	testCases := map[string]struct {
		client    stubMetadataClient
		wantValue string
		wantErr   bool
	}{
		"retrieve works": {
			client:    stubMetadataClient{InstanceNameValue: "someInstanceName"},
			wantValue: "someInstanceName",
		},
		"retrieve fails": {
			client:  stubMetadataClient{InstanceNameErr: someErr},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{metadataAPI: tc.client}
			value, err := client.RetrieveInstanceName()

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantValue, value)
		})
	}
}

func TestRetrieveInstanceMetadata(t *testing.T) {
	someErr := errors.New("failed")
	attr := "someAttribute"

	testCases := map[string]struct {
		client    stubMetadataClient
		attr      string
		wantValue string
		wantErr   bool
	}{
		"retrieve works": {
			client: stubMetadataClient{
				InstanceValue: "someValue",
				InstanceErr:   nil,
			},
			wantValue: "someValue",
		},
		"retrieve fails": {
			client: stubMetadataClient{
				InstanceValue: "",
				InstanceErr:   someErr,
			},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{metadataAPI: tc.client}
			value, err := client.RetrieveInstanceMetadata(attr)

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantValue, value)
		})
	}
}

func TestSetInstanceMetadata(t *testing.T) {
	someErr := errors.New("failed")

	testCases := map[string]struct {
		client  stubInstancesClient
		wantErr bool
	}{
		"set works": {
			client: stubInstancesClient{
				GetInstance: &computepb.Instance{
					Metadata: &computepb.Metadata{
						Fingerprint: proto.String("someFingerprint"),
						Kind:        proto.String("compute#metadata"),
						Items:       []*computepb.Items{},
					},
				},
			},
		},
		"retrieve fails": {
			client: stubInstancesClient{
				GetErr: someErr,
			},
			wantErr: true,
		},
		"retrieve returns nil": {
			wantErr: true,
		},
		"setting fails": {
			client: stubInstancesClient{
				GetInstance: &computepb.Instance{
					Metadata: &computepb.Metadata{
						Fingerprint: proto.String("someFingerprint"),
						Kind:        proto.String("compute#metadata"),
						Items:       []*computepb.Items{},
					},
				},
				SetMetadataErr: someErr,
			},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{instanceAPI: tc.client}
			err := client.SetInstanceMetadata(context.Background(), "project", "zone", "instanceName", "key", "value")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
		})
	}
}

func TestUnsetInstanceMetadata(t *testing.T) {
	someErr := errors.New("failed")

	testCases := map[string]struct {
		client  stubInstancesClient
		wantErr bool
	}{
		"unset works": {
			client: stubInstancesClient{
				GetInstance: &computepb.Instance{
					Metadata: &computepb.Metadata{
						Fingerprint: proto.String("someFingerprint"),
						Kind:        proto.String("compute#metadata"),
						Items:       []*computepb.Items{},
					},
				},
			},
		},
		"unset with existing key works": {
			client: stubInstancesClient{
				GetInstance: &computepb.Instance{
					Metadata: &computepb.Metadata{
						Fingerprint: proto.String("someFingerprint"),
						Kind:        proto.String("compute#metadata"),
						Items: []*computepb.Items{
							{
								Key:   proto.String("key"),
								Value: proto.String("value"),
							},
						},
					},
				},
			},
		},
		"retrieve fails": {
			client:  stubInstancesClient{GetErr: someErr},
			wantErr: true,
		},
		"retrieve returns nil": {
			wantErr: true,
		},
		"setting fails": {
			client: stubInstancesClient{
				GetInstance: &computepb.Instance{
					Metadata: &computepb.Metadata{
						Fingerprint: proto.String("someFingerprint"),
						Kind:        proto.String("compute#metadata"),
						Items:       []*computepb.Items{},
					},
				},
				SetMetadataErr: someErr,
			},
			wantErr: true,
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{instanceAPI: tc.client}
			err := client.UnsetInstanceMetadata(context.Background(), "project", "zone", "instanceName", "key")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
		})
	}
}

func TestRetrieveSubnetworkAliasCIDR(t *testing.T) {
	aliasCIDR := "192.0.2.1/24"
	someErr := errors.New("some error")
	testCases := map[string]struct {
		stubInstancesClient   stubInstancesClient
		stubSubnetworksClient stubSubnetworksClient
		wantAliasCIDR         string
		wantErr               bool
	}{
		"RetrieveSubnetworkAliasCIDR works": {
			stubInstancesClient: stubInstancesClient{
				GetInstance: &computepb.Instance{
					NetworkInterfaces: []*computepb.NetworkInterface{
						{
							Subnetwork: proto.String("projects/project/regions/region/subnetworks/subnetwork"),
						},
					},
				},
			},
			stubSubnetworksClient: stubSubnetworksClient{
				GetSubnetwork: &computepb.Subnetwork{
					SecondaryIpRanges: []*computepb.SubnetworkSecondaryRange{
						{
							IpCidrRange: proto.String(aliasCIDR),
						},
					},
				},
			},
			wantAliasCIDR: aliasCIDR,
		},
		"instance has no network interface": {
			stubInstancesClient: stubInstancesClient{
				GetInstance: &computepb.Instance{
					NetworkInterfaces: []*computepb.NetworkInterface{},
				},
			},
			wantErr: true,
		},
		"cannot get instance": {
			stubInstancesClient: stubInstancesClient{
				GetErr: someErr,
			},
			wantErr: true,
		},
		"cannot get subnetwork": {
			stubInstancesClient: stubInstancesClient{
				GetInstance: &computepb.Instance{
					NetworkInterfaces: []*computepb.NetworkInterface{
						{
							Subnetwork: proto.String("projects/project/regions/region/subnetworks/subnetwork"),
						},
					},
				},
			},
			stubSubnetworksClient: stubSubnetworksClient{
				GetErr: someErr,
			},
			wantErr: true,
		},
		"subnetwork has no cidr range": {
			stubInstancesClient: stubInstancesClient{
				GetInstance: &computepb.Instance{
					NetworkInterfaces: []*computepb.NetworkInterface{
						{
							Subnetwork: proto.String("projects/project/regions/region/subnetworks/subnetwork"),
						},
					},
				},
			},
			stubSubnetworksClient: stubSubnetworksClient{
				GetSubnetwork: &computepb.Subnetwork{},
			},
			wantErr: true,
		},
	}
	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{instanceAPI: tc.stubInstancesClient, subnetworkAPI: tc.stubSubnetworksClient}
			aliasCIDR, err := client.RetrieveSubnetworkAliasCIDR(context.Background(), "project", "us-central1-a", "subnetwork")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantAliasCIDR, aliasCIDR)
		})
	}
}

func TestRetrieveLoadBalancerEndpoint(t *testing.T) {
	loadBalancerIP := "192.0.2.1"
	uid := "uid"
	someErr := errors.New("some error")
	testCases := map[string]struct {
		stubForwardingRulesClient stubForwardingRulesClient
		stubMetadataClient        stubMetadataClient
		wantLoadBalancerIP        string
		wantErr                   bool
	}{
		"works": {
			stubMetadataClient: stubMetadataClient{InstanceValue: uid},
			stubForwardingRulesClient: stubForwardingRulesClient{
				ForwardingRuleIterator: &stubForwardingRuleIterator{
					rules: []*computepb.ForwardingRule{
						{
							IPAddress: proto.String(loadBalancerIP),
							PortRange: proto.String("100-100"),
							Labels:    map[string]string{"constellation-uid": uid},
						},
					},
				},
			},
			wantLoadBalancerIP: loadBalancerIP,
		},
		"fails when no matching load balancers exists": {
			stubMetadataClient: stubMetadataClient{InstanceValue: uid},
			stubForwardingRulesClient: stubForwardingRulesClient{
				ForwardingRuleIterator: &stubForwardingRuleIterator{
					rules: []*computepb.ForwardingRule{
						{
							IPAddress: proto.String(loadBalancerIP),
							PortRange: proto.String("100-100"),
						},
					},
				},
			},
			wantErr: true,
		},
		"fails when retrieving uid": {
			stubMetadataClient: stubMetadataClient{InstanceErr: someErr},
			stubForwardingRulesClient: stubForwardingRulesClient{
				ForwardingRuleIterator: &stubForwardingRuleIterator{
					rules: []*computepb.ForwardingRule{
						{
							IPAddress: proto.String(loadBalancerIP),
							PortRange: proto.String("100-100"),
							Labels:    map[string]string{"constellation-uid": uid},
						},
					},
				},
			},
			wantErr: true,
		},
		"fails when answer has empty port range": {
			stubMetadataClient: stubMetadataClient{InstanceErr: someErr},
			stubForwardingRulesClient: stubForwardingRulesClient{
				ForwardingRuleIterator: &stubForwardingRuleIterator{
					rules: []*computepb.ForwardingRule{
						{
							IPAddress: proto.String(loadBalancerIP),
							Labels:    map[string]string{"constellation-uid": uid},
						},
					},
				},
			},
			wantErr: true,
		},
		"fails when retrieving loadbalancer IP": {
			stubMetadataClient: stubMetadataClient{},
			stubForwardingRulesClient: stubForwardingRulesClient{
				ForwardingRuleIterator: &stubForwardingRuleIterator{
					nextErr: someErr,
					rules: []*computepb.ForwardingRule{
						{
							IPAddress: proto.String(loadBalancerIP),
							PortRange: proto.String("100-100"),
							Labels:    map[string]string{"constellation-uid": uid},
						},
					},
				},
			},
			wantErr: true,
		},
	}
	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)
			require := require.New(t)

			client := Client{forwardingRulesAPI: tc.stubForwardingRulesClient, metadataAPI: tc.stubMetadataClient}
			aliasCIDR, err := client.RetrieveLoadBalancerEndpoint(context.Background(), "project")

			if tc.wantErr {
				assert.Error(err)
				return
			}
			require.NoError(err)
			assert.Equal(tc.wantLoadBalancerIP+":100", aliasCIDR)
		})
	}
}

func TestClose(t *testing.T) {
	someErr := errors.New("failed")

	assert := assert.New(t)

	client := Client{instanceAPI: stubInstancesClient{}, subnetworkAPI: stubSubnetworksClient{}, forwardingRulesAPI: stubForwardingRulesClient{}}
	assert.NoError(client.Close())

	client = Client{instanceAPI: stubInstancesClient{CloseErr: someErr}, subnetworkAPI: stubSubnetworksClient{}, forwardingRulesAPI: stubForwardingRulesClient{}}
	assert.Error(client.Close())

	client = Client{instanceAPI: stubInstancesClient{}, subnetworkAPI: stubSubnetworksClient{CloseErr: someErr}, forwardingRulesAPI: stubForwardingRulesClient{}}
	assert.Error(client.Close())

	client = Client{instanceAPI: stubInstancesClient{}, subnetworkAPI: stubSubnetworksClient{}, forwardingRulesAPI: stubForwardingRulesClient{CloseErr: someErr}}
	assert.Error(client.Close())
}

func TestFetchSSHKeys(t *testing.T) {
	testCases := map[string]struct {
		metadata map[string]string
		wantKeys map[string][]string
	}{
		"fetch works": {
			metadata: map[string]string{"ssh-keys": "bob:ssh-rsa bobskey"},
			wantKeys: map[string][]string{"bob": {"ssh-rsa bobskey"}},
		},
		"google ssh key metadata is ignored": {
			metadata: map[string]string{"ssh-keys": "bob:ssh-rsa bobskey google-ssh {\"userName\":\"bob\",\"expireOn\":\"2021-06-14T16:59:03+0000\"}"},
			wantKeys: map[string][]string{"bob": {"ssh-rsa bobskey"}},
		},
		"ssh key format error is ignored": {
			metadata: map[string]string{"ssh-keys": "incorrect-format"},
			wantKeys: map[string][]string{},
		},
		"ssh key format space error is ignored": {
			metadata: map[string]string{"ssh-keys": "user:incorrect-key-format"},
			wantKeys: map[string][]string{},
		},
		"metadata field empty": {
			metadata: map[string]string{"ssh-keys": ""},
			wantKeys: map[string][]string{},
		},
		"metadata field missing": {
			metadata: map[string]string{},
			wantKeys: map[string][]string{},
		},
		"multiple keys": {
			metadata: map[string]string{"ssh-keys": "bob:ssh-rsa bobskey\nalice:ssh-rsa alicekey"},
			wantKeys: map[string][]string{
				"bob":   {"ssh-rsa bobskey"},
				"alice": {"ssh-rsa alicekey"},
			},
		},
	}

	for name, tc := range testCases {
		t.Run(name, func(t *testing.T) {
			assert := assert.New(t)

			keys := extractSSHKeys(tc.metadata)
			assert.Equal(tc.wantKeys, keys)
		})
	}
}

type stubInstanceIterator struct {
	instances []*computepb.Instance
	nextErr   error

	internalCounter int
}

func (i *stubInstanceIterator) Next() (*computepb.Instance, error) {
	if i.nextErr != nil {
		return nil, i.nextErr
	}
	if i.internalCounter >= len(i.instances) {
		i.internalCounter = 0
		return nil, iterator.Done
	}
	resp := i.instances[i.internalCounter]
	i.internalCounter++
	return resp, nil
}

type stubInstancesClient struct {
	GetInstance          *computepb.Instance
	GetErr               error
	ListInstanceIterator InstanceIterator
	SetMetadataOperation *compute.Operation
	SetMetadataErr       error
	CloseErr             error
}

func (s stubInstancesClient) Get(ctx context.Context, req *computepb.GetInstanceRequest, opts ...gax.CallOption) (*computepb.Instance, error) {
	return s.GetInstance, s.GetErr
}

func (s stubInstancesClient) List(ctx context.Context, req *computepb.ListInstancesRequest, opts ...gax.CallOption) InstanceIterator {
	return s.ListInstanceIterator
}

func (s stubInstancesClient) SetMetadata(ctx context.Context, req *computepb.SetMetadataInstanceRequest, opts ...gax.CallOption) (*compute.Operation, error) {
	return s.SetMetadataOperation, s.SetMetadataErr
}

func (s stubInstancesClient) Close() error {
	return s.CloseErr
}

type stubSubnetworksClient struct {
	GetSubnetwork      *computepb.Subnetwork
	GetErr             error
	SubnetworkIterator SubnetworkIterator
	CloseErr           error
}

func (s stubSubnetworksClient) Get(ctx context.Context, req *computepb.GetSubnetworkRequest, opts ...gax.CallOption) (*computepb.Subnetwork, error) {
	return s.GetSubnetwork, s.GetErr
}

func (s stubSubnetworksClient) List(ctx context.Context, req *computepb.ListSubnetworksRequest, opts ...gax.CallOption) SubnetworkIterator {
	return s.SubnetworkIterator
}

func (s stubSubnetworksClient) Close() error {
	return s.CloseErr
}

type stubForwardingRuleIterator struct {
	rules   []*computepb.ForwardingRule
	nextErr error

	internalCounter int
}

func (i *stubForwardingRuleIterator) Next() (*computepb.ForwardingRule, error) {
	if i.nextErr != nil {
		return nil, i.nextErr
	}
	if i.internalCounter >= len(i.rules) {
		i.internalCounter = 0
		return nil, iterator.Done
	}
	resp := i.rules[i.internalCounter]
	i.internalCounter++
	return resp, nil
}

type stubForwardingRulesClient struct {
	ForwardingRuleIterator ForwardingRuleIterator
	GetErr                 error
	CloseErr               error
}

func (s stubForwardingRulesClient) List(ctx context.Context, req *computepb.ListGlobalForwardingRulesRequest, opts ...gax.CallOption) ForwardingRuleIterator {
	return s.ForwardingRuleIterator
}

func (s stubForwardingRulesClient) Close() error {
	return s.CloseErr
}

type stubMetadataClient struct {
	InstanceValue     string
	InstanceErr       error
	ProjectIDValue    string
	ProjectIDErr      error
	ZoneValue         string
	ZoneErr           error
	InstanceNameValue string
	InstanceNameErr   error
}

func (s stubMetadataClient) InstanceAttributeValue(attr string) (string, error) {
	return s.InstanceValue, s.InstanceErr
}

func (s stubMetadataClient) ProjectID() (string, error) {
	return s.ProjectIDValue, s.ProjectIDErr
}

func (s stubMetadataClient) Zone() (string, error) {
	return s.ZoneValue, s.ZoneErr
}

func (s stubMetadataClient) InstanceName() (string, error) {
	return s.InstanceNameValue, s.InstanceNameErr
}