diff --git a/go.mod b/go.mod index e622134..d154619 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/go-cmp v0.6.0 github.com/googleapis/gax-go/v2 v2.13.0 github.com/hashicorp/boundary/sdk v0.0.47 + github.com/hashicorp/terraform-json v0.23.0 golang.org/x/oauth2 v0.22.0 google.golang.org/api v0.196.0 ) @@ -18,12 +19,15 @@ require ( cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/longrunning v0.6.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/zclconf/go-cty v1.15.0 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect diff --git a/go.sum b/go.sum index 9912ce6..34305b8 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= @@ -125,8 +127,12 @@ github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3ly github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= +github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= @@ -187,6 +193,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= diff --git a/testing/e2e_host_test.go b/testing/e2e_host_test.go new file mode 100644 index 0000000..8128f75 --- /dev/null +++ b/testing/e2e_host_test.go @@ -0,0 +1,589 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testing + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/hashicorp/boundary-plugin-gcp/internal/credential" + "github.com/hashicorp/boundary-plugin-gcp/plugin/service/host" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostsets" + pb "github.com/hashicorp/boundary/sdk/pbs/plugin" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + expectedInstanceCount = 3 + expectedTagsCount = 3 + expectedLabelsCount = 3 +) + +// All the tests in this test depend on each other +// to run in order. This is because the tests are +// testing the same plugin instance and the same +// persisted state. The tests are designed to run +// in order to test the plugin's ability to rotate +// credentials and manage the state of the plugin +// instance. +// To run the tests you need the following +// environment variables set: +// GOOGLE_APPLICATION_CREDENTIALS: This should be +// the path to the service account key. This can also +// be automatically set after authenticating with gcloud +// using `gcloud auth application-default login`. +// +// TF_VAR_project_id: The project_id of the project. +// +// TF_VAR_region: The region the resources should be +// deployed in. +// +// TF_VAR_zone: The zone the resources should be +// deployed in. +func TestHostPlugin(t *testing.T) { + creds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if creds == "" { + t.Skip("set GOOGLE_APPLICATION_CREDENTIALS to use this test") + } + projectId := os.Getenv("TF_VAR_project_id") + if projectId == "" { + t.Skip("set TF_VAR_project_id to use this test") + } + region := os.Getenv("TF_VAR_region") + if region == "" { + t.Skip("set TF_VAR_region to use this test") + } + zone := os.Getenv("TF_VAR_zone") + if zone == "" { + t.Skip("set TF_VAR_zone to use this test") + } + + require := require.New(t) + tf, err := NewTestTerraformer("testdata/host") + require.NoError(err) + require.NotNil(tf) + + t.Log("===== deploying test Terraform workspace =====") + err = tf.Deploy() + require.NoError(err) + + defer func() { + t.Log("===== destroying test Terraform workspace =====") + if err := tf.Destroy(); err != nil { + t.Logf("WARNING: could not run Terraform destroy: %s", err) + } + }() + + clientEmail, err := tf.GetOutput("client_email") + require.NoError(err) + require.NotNil(clientEmail) + + instanceIds, err := tf.GetOutputSlice("instance_ids") + require.NoError(err) + require.Len(instanceIds, expectedInstanceCount) + + instanceTags, err := tf.GetOutputMap("instance_tags") + require.NoError(err) + require.Len(instanceTags, expectedInstanceCount) + + instanceLabels, err := tf.GetOutputMap("instance_labels") + require.NoError(err) + require.Len(instanceLabels, expectedLabelsCount) + + privateKeys, err := tf.GetOutputSlice("private_keys") + require.NoError(err) + require.Len(privateKeys, expectedServiceAccountKeys) + + privateKeyIds, err := tf.GetOutputSlice("private_key_ids") + require.NoError(err) + require.Len(privateKeyIds, expectedServiceAccountKeys) + + p := new(host.HostPlugin) + ctx := context.Background() + + var keyId, secret string + + t.Run("OnCreateCatalog", func(t *testing.T) { + t.Run("Test non-rotation", func(t *testing.T) { + keyId, secret = testPluginOnCreateCatalog( + ctx, + t, + p, + zone, + projectId, + clientEmail.(string), + privateKeyIds[0].(string), + privateKeys[0].(string), + false, + ) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Test rotation", func(t *testing.T) { + keyId, secret = testPluginOnCreateCatalog( + ctx, + t, + p, + zone, + projectId, + clientEmail.(string), + keyId, + secret, + true, + ) + require.NotNil(keyId) + require.NotNil(secret) + }) + }) + + t.Run("OnUpdateCatalog", func(t *testing.T) { + t.Run("Test no-op non-rotation", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, false) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Switch to rotation", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", false, true) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Test no-op with rotation", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, "", "", true, true) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Switch credentials to next service account key. Don't rotate", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[1].(string), privateKeys[1].(string), true, false) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Switch credentials to next service account key. Add rotation", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[2].(string), privateKeys[2].(string), false, true) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Switch to next service account key, with rotation disabled", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[3].(string), privateKeys[3].(string), true, false) + require.NotNil(keyId) + require.NotNil(secret) + }) + + t.Run("Switch to another service account key and keep rotation off", func(t *testing.T) { + keyId, secret = testPluginOnUpdateCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, privateKeyIds[4].(string), privateKeys[4].(string), false, false) + require.NotNil(keyId) + require.NotNil(secret) + }) + }) + + t.Run("OnDeleteCatalog", func(t *testing.T) { + t.Run("Test non-rotated", func(t *testing.T) { + testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, false) + }) + + t.Run("Test as if we had rotated these credentials.", func(t *testing.T) { + testPluginOnDeleteCatalog(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, true) + }) + }) + + t.Run("HostSet", func(t *testing.T) { + keyId, secret := privateKeyIds[5].(string), privateKeys[5].(string) + expectedLabelInstancesMap := make(map[string][]string) + for instanceId, instanceLabel := range instanceLabels { + for tagKey := range instanceLabel.(map[string]any) { + for _, expectedTag := range instanceLabels { + if tagKey == expectedTag { + expectedLabelInstancesMap[tagKey] = append(expectedLabelInstancesMap[tagKey], instanceId) + } + } + } + } + + cases := map[string][]string{ + "label-1": {"foo"}, + "label-2": {"bar"}, + "label-3": {"baz"}, + "label-4": {"foo", "bar"}, + "label-5": {"foo", "baz"}, + "label-6": {"bar", "baz"}, + "label-7": {"foo", "bar", "baz"}, + } + + for key, tc := range cases { + t.Run(fmt.Sprintf("Label=%s", key), func(t *testing.T) { + labels := make([]string, 0, len(tc)) + for _, label := range tc { + labels = append(labels, fmt.Sprintf("labels.%s=%s", key, label)) + } + testPluginOnCreateUpdateSet(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels) + testPluginListHosts(ctx, t, p, zone, projectId, clientEmail.(string), keyId, secret, labels, expectedLabelInstancesMap) + }) + } + }) +} + +func testPluginOnCreateCatalog( + ctx context.Context, + t *testing.T, + p *host.HostPlugin, + zone string, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string, + rotate bool) (string, string) { + t.Helper() + t.Logf("testing OnCreateCatalog (zone=%s, rotate=%t)", zone, rotate) + require := require.New(t) + + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstClientEmail: clientEmail, + credential.ConstProjectId: projectId, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: !rotate, + }) + require.NoError(err) + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: privateKeyId, + credential.ConstPrivateKey: privateKey, + }) + require.NoError(err) + request := &pb.OnCreateCatalogRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqAttrs, + }, + Secrets: reqSecrets, + }, + } + response, err := p.OnCreateCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + persisted := response.GetPersisted() + require.NotNil(persisted) + return validatePersistedSecrets(t, persisted.GetSecrets(), projectId, clientEmail, privateKeyId, privateKey, rotate) +} + +func testPluginOnUpdateCatalog( + ctx context.Context, + t *testing.T, + p *host.HostPlugin, + zone string, + projectId string, + clientEmail string, + currentPrivateKeyId string, + currentPrivateKey string, + newPrivateKeyId string, + newPrivateKey string, + rotated bool, + rotate bool, +) (string, string) { + t.Helper() + t.Logf("testing OnUpdateCatalog (zone=%s, newcreds=%t, rotated=%t, rotate=%t)", zone, newPrivateKeyId != "" && newPrivateKey != "", rotated, rotate) + require := require.New(t) + + // Take a timestamp of the current time to get a point in time to + // reference, ensuring that we are updating credential rotation + // timestamps. + currentCredsLastRotatedTime := time.Now() + + reqCurrentAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstProjectId: projectId, + credential.ConstClientEmail: clientEmail, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: !rotated, + }) + require.NoError(err) + reqNewAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstProjectId: projectId, + credential.ConstClientEmail: clientEmail, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: !rotate, + }) + require.NoError(err) + var reqSecrets *structpb.Struct + if newPrivateKeyId != "" && newPrivateKey != "" { + reqSecrets, err = structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: newPrivateKeyId, + credential.ConstPrivateKey: newPrivateKey, + }) + require.NoError(err) + } + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: currentPrivateKeyId, + credential.ConstPrivateKey: currentPrivateKey, + credential.ConstCredsLastRotatedTime: func() string { + if rotated { + return currentCredsLastRotatedTime.Format(time.RFC3339Nano) + } + + return (time.Time{}).Format(time.RFC3339Nano) + }(), + }) + require.NoError(err) + require.NotNil(reqPersistedSecrets) + request := &pb.OnUpdateCatalogRequest{ + CurrentCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqCurrentAttrs, + }, + }, + NewCatalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqNewAttrs, + }, + Secrets: reqSecrets, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.OnUpdateCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + persisted := response.GetPersisted() + require.NotNil(persisted) + return validateUpdateSecrets( + t, + projectId, + clientEmail, + persisted.GetSecrets(), + currentCredsLastRotatedTime, + currentPrivateKeyId, + currentPrivateKey, + newPrivateKeyId, + newPrivateKey, + rotated, + rotate, + ) +} + +func testPluginOnDeleteCatalog( + ctx context.Context, + t *testing.T, + p *host.HostPlugin, + zone string, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string, + rotated bool) { + t.Helper() + t.Logf("testing OnDeleteCatalog (zone=%s, rotated=%t)", zone, rotated) + require := require.New(t) + + reqAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstProjectId: projectId, + credential.ConstClientEmail: clientEmail, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: !rotated, + }) + require.NoError(err) + reqSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: privateKeyId, + credential.ConstPrivateKey: privateKey, + }) + require.NoError(err) + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: privateKeyId, + credential.ConstPrivateKey: privateKey, + credential.ConstCredsLastRotatedTime: func() string { + if rotated { + return time.Now().Format(time.RFC3339Nano) + } + + return (time.Time{}).Format(time.RFC3339Nano) + }(), + }) + require.NoError(err) + request := &pb.OnDeleteCatalogRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: reqAttrs, + }, + Secrets: reqSecrets, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.OnDeleteCatalog(ctx, request) + require.NoError(err) + require.NotNil(response) + + // We want to test the validity of the credentials post-deletion. + if rotated { + // The credentials should no longer be valid. + requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey) + } else { + // The credentials should still be valid. Sleep 10s first just to + // be sure, since we're not rotating. + time.Sleep(time.Second * 10) + requireCredentialsValid(t, projectId, clientEmail, privateKeyId, privateKey) + } +} + +func testPluginOnCreateUpdateSet( + ctx context.Context, + t *testing.T, + p *host.HostPlugin, + zone string, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string, + labels []string) { + t.Helper() + t.Logf("testing OnCreateSet (zone=%s, labels=%v)", zone, labels) + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstProjectId: projectId, + credential.ConstClientEmail: clientEmail, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + }) + require.NoError(err) + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstListInstancesFilter: []any{strings.Join(labels, " AND ")}, + }) + require.NoError(err) + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: privateKeyId, + credential.ConstPrivateKey: privateKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + createRequest := &pb.OnCreateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Set: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + createResponse, err := p.OnCreateSet(ctx, createRequest) + require.NoError(err) + require.NotNil(createResponse) + + // Do an update test in the same function, as it's pretty much the + // same function right now. + t.Logf("testing OnUpdateSet (zone=%s, labels=%v)", zone, labels) + updateRequest := &pb.OnUpdateSetRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + CurrentSet: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + NewSet: &hostsets.HostSet{ + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + }, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + updateResponse, err := p.OnUpdateSet(ctx, updateRequest) + require.NoError(err) + require.NotNil(updateResponse) +} + +func testPluginListHosts( + ctx context.Context, + t *testing.T, + p *host.HostPlugin, + zone string, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string, + labels []string, + expected map[string][]string) { + t.Helper() + t.Logf("testing ListHosts (zone=%s, labels=%v)", zone, labels) + require := require.New(t) + catalogAttrs, err := structpb.NewStruct(map[string]any{ + credential.ConstProjectId: projectId, + credential.ConstClientEmail: clientEmail, + credential.ConstZone: zone, + credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests + }) + require.NoError(err) + sets := make([]*hostsets.HostSet, len(labels)) + for i, label := range labels { + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstListInstancesFilter: []any{label}, + }) + require.NoError(err) + sets[i] = &hostsets.HostSet{ + Id: fmt.Sprintf("hostset-%d", i), + Attrs: &hostsets.HostSet_Attributes{ + Attributes: setAttrs, + }, + } + } + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstPrivateKeyId: privateKeyId, + credential.ConstPrivateKey: privateKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + request := &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Sets: sets, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + response, err := p.ListHosts(ctx, request) + require.NoError(err) + require.NotNil(response) + + // Validate the returned instances by ID. Assemble the instance + // details from the expected set. + expectedInstances := make(map[string][]string) + for i, label := range labels { + for _, instanceId := range expected[label] { + expectedInstances[instanceId] = append(expectedInstances[instanceId], fmt.Sprintf("hostset-%d", i)) + } + } + + // Take the returned hosts by ID and create the same kind of map. + actualInstances := make(map[string][]string) + for _, host := range response.GetHosts() { + actualInstances[host.ExternalId] = host.SetIds + } + + // Compare + require.Equal(expectedInstances, actualInstances) + // Success + t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", zone, labels, len(actualInstances), actualInstances) +} diff --git a/testing/terraform/main.tf b/testing/terraform/main.tf deleted file mode 100644 index 6448713..0000000 --- a/testing/terraform/main.tf +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) HashiCorp, Inc. -# SPDX-License-Identifier: MPL-2.0 - -data "google_compute_zones" "available" {} - -resource "google_compute_instance" "default" { - count = var.num_instances - name = "boundary-${count.index}" - machine_type = "n2-standard-2" - zone = data.google_compute_zones.available.names[0] - - labels = var.labels - - boot_disk { - initialize_params { - image = "ubuntu-os-cloud/ubuntu-2204-lts" - labels = var.labels - } - } - - network_interface { - network = "default" - - access_config { - // Ephemeral public IP - } - } - - metadata = { - ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}" - } - - dynamic "service_account" { - for_each = var.service_account_email != null ? [var.service_account_email] : [] - content { - email = service_account.value - scopes = ["cloud-platform"] - } - } -} - -resource "google_compute_instance_group" "webservers" { - name = "boundary-servers" - description = "Boundary server group" - - instances = google_compute_instance.default.*.id - - zone = data.google_compute_zones.available.names[0] -} - -# RSA key of size 4096 bits -resource "tls_private_key" "ssh" { - algorithm = "RSA" - rsa_bits = 4096 -} - -resource "google_compute_firewall" "rules" { - name = "allow-ssh" - network = "default" - - allow { - protocol = "tcp" - ports = ["22"] - } - source_ranges = [var.client_cidr_block] -} \ No newline at end of file diff --git a/testing/terraform/output.tf b/testing/terraform/output.tf deleted file mode 100644 index 4e37251..0000000 --- a/testing/terraform/output.tf +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) HashiCorp, Inc. -# SPDX-License-Identifier: MPL-2.0 - -resource "local_file" "ssh_key" { - content = tls_private_key.ssh.private_key_openssh - filename = "${path.module}/secrets/id_rsa.pem" - file_permission = "0400" -} \ No newline at end of file diff --git a/testing/terraform/variables.tf b/testing/terraform/variables.tf deleted file mode 100644 index 4e79107..0000000 --- a/testing/terraform/variables.tf +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) HashiCorp, Inc. -# SPDX-License-Identifier: MPL-2.0 - -variable "labels" { - type = map(string) - default = { - repository = "boundary-plugin-gcp" - purpose = "testing" - } -} - -variable "region" { - type = string - default = "us-central1" -} - -variable "num_instances" { - type = number - default = 3 -} - -variable "service_account_email" { - type = string - default = null -} - -variable "client_cidr_block" { - type = string -} \ No newline at end of file diff --git a/testing/testdata/host/main.tf b/testing/testdata/host/main.tf new file mode 100644 index 0000000..f19c63c --- /dev/null +++ b/testing/testdata/host/main.tf @@ -0,0 +1,77 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "random_id" "service_account_client_email" { + prefix = "test-${var.user_prefix}" + byte_length = 2 +} + +resource "google_service_account" "test_service_account" { + project = var.project_id + account_id = random_id.service_account_client_email.dec + display_name = "test-${var.user_prefix}" +} + +resource "google_project_iam_binding" "test_service_account_user" { + project = var.project_id + count = length(var.rolesList) + role = var.rolesList[count.index] + members = [ + "serviceAccount:${google_service_account.test_service_account.email}" + ] +} + +resource "google_service_account_key" "test_service_account_key" { + service_account_id = google_service_account.test_service_account.name + public_key_type = "TYPE_X509_PEM_FILE" + private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE" + key_algorithm = "KEY_ALG_RSA_2048" +} + +resource "google_service_account_key" "test_service_account_keys" { + count = var.num_service_account_keys + service_account_id = google_service_account.test_service_account.name + public_key_type = "TYPE_X509_PEM_FILE" + private_key_type = "TYPE_GOOGLE_CREDENTIALS_FILE" + key_algorithm = "KEY_ALG_RSA_2048" +} + +resource "google_compute_instance" "instances" { + project = var.project_id + count = var.num_instances + name = "boundary-${count.index}" + machine_type = "n2-standard-2" + zone = var.zone + labels = var.labels + tags = ["test-instance-${count.index}"] + + boot_disk { + initialize_params { + image = "ubuntu-os-cloud/ubuntu-2204-lts" + labels = var.labels + } + } + + network_interface { + network = "default" + + access_config { + // Ephemeral public IP + } + } + + metadata = { + ssh-keys = "ubuntu:${tls_private_key.ssh.public_key_openssh}" + } + + service_account { + email = google_service_account.test_service_account.email + scopes = ["cloud-platform"] + } +} + +# RSA key of size 4096 bits +resource "tls_private_key" "ssh" { + algorithm = "RSA" + rsa_bits = 4096 +} \ No newline at end of file diff --git a/testing/testdata/host/output.tf b/testing/testdata/host/output.tf new file mode 100644 index 0000000..e85eea0 --- /dev/null +++ b/testing/testdata/host/output.tf @@ -0,0 +1,32 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +output "client_email" { + value = google_service_account.test_service_account.email +} + +output "private_keys" { + value = [for key in google_service_account_key.test_service_account_keys : jsondecode(base64decode(key.private_key)).private_key] + sensitive = true +} + +output "private_key_ids" { + value = [for key in google_service_account_key.test_service_account_keys : chomp(reverse(split("/", key.id))[0])] + sensitive = true +} + +output "instance_ids" { + value = google_compute_instance.instances.*.id +} + +output "instance_tags" { + value = { + for instance in google_compute_instance.instances : instance.id => instance.tags + } +} + +output "instance_labels" { + value = { + for instance in google_compute_instance.instances : instance.id => instance.labels + } +} \ No newline at end of file diff --git a/testing/terraform/providers.tf b/testing/testdata/host/providers.tf similarity index 89% rename from testing/terraform/providers.tf rename to testing/testdata/host/providers.tf index eb48707..dbddbfc 100644 --- a/testing/terraform/providers.tf +++ b/testing/testdata/host/providers.tf @@ -12,4 +12,5 @@ terraform { provider "google" { region = var.region + project = var.project_id } \ No newline at end of file diff --git a/testing/testdata/host/variables.tf b/testing/testdata/host/variables.tf new file mode 100644 index 0000000..d8eb867 --- /dev/null +++ b/testing/testdata/host/variables.tf @@ -0,0 +1,50 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +data "google_client_openid_userinfo" "current" {} + +variable "labels" { + type = map(string) + default = { + repository = "boundary-plugin-gcp" + purpose = "testing" + } +} + +variable "project_id" { + type = string +} + +variable "region" { + type = string +} + +variable "zone" { + type = string +} + +variable "num_instances" { + type = number + default = 3 +} + +variable "service_account_email" { + type = string + default = null +} + +variable "rolesList" { + type = list(string) + description = "List of roles to assign to the service account" + default = ["roles/compute.viewer","roles/iam.serviceAccountKeyAdmin"] +} + +variable "user_prefix" { + type = string + default = "boundary-gcp-plugin" +} + +variable "num_service_account_keys" { + type = number + default = 6 +} \ No newline at end of file diff --git a/testing/testing.go b/testing/testing.go new file mode 100644 index 0000000..4866f50 --- /dev/null +++ b/testing/testing.go @@ -0,0 +1,513 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testing + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "sync" + "testing" + "time" + + "github.com/hashicorp/boundary-plugin-gcp/internal/credential" + "github.com/hashicorp/boundary-plugin-gcp/internal/values" + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/require" + "google.golang.org/api/option" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + // expectedServiceAccountKeys is the number of service account keys + // expected to be created in the Terraform project. + expectedServiceAccountKeys = 6 +) + +// TestTerraformer is an object representing the lifecycle of a +// particular Terraform project. +// +// Note that Terraform must be installed in the system PATH (not to +// be confused with the working directory below). This object does +// not manage anything with regards to installation or version +// management. +type TestTerraformer struct { + // The full path to the Terraform binary, set during initialization + // to the binary found in the system path. + tfPath string + + // The working directory for Terraform commands. + workDir string + + // The Terraform state. Use the Output method to look up particular + // outputs from the Terraform state. + state *tfjson.State + + // Keeps track of whether or not we have run the init command + // during this run of Terraform. This only needs to be done once. + // Do not use initErr directly; it stores the init error if one was + // encountered on init. + initOnce sync.Once + initErr error +} + +// NewTestTerraformer initializes a Terraform directory. It just +// returns an initialized project, it does not perform any actions on +// it. +func NewTestTerraformer(workDir string) (*TestTerraformer, error) { + tfPath, err := exec.LookPath("terraform") + if err != nil { + return nil, fmt.Errorf("LookPath error: %w", err) + } + + if workDir == "" { + return nil, errors.New("workDir is empty") + } + + return &TestTerraformer{ + tfPath: tfPath, + workDir: workDir, + }, nil +} + +// Deploy runs the following commands, in order: init, apply, plan, +// json. The output of the JSON command is then imported to the +// internal state. +func (t *TestTerraformer) Deploy() error { + if err := t.init(); err != nil { + return fmt.Errorf("error running Terraform init: %w", err) + } + + if err := t.apply(); err != nil { + return fmt.Errorf("error running Terraform apply: %w", err) + } + + if err := t.plan(); err != nil { + return fmt.Errorf("error running Terraform plan: %w", err) + } + + output, err := t.showJson() + if err != nil { + return fmt.Errorf("error getting Terraform plan JSON data: %w", err) + } + + newState := new(tfjson.Plan) + if err := json.Unmarshal(output, newState); err != nil { + return fmt.Errorf("error reading new Terraform plan JSON data: %w", err) + } + + t.state = newState.PriorState + return nil +} + +// Destroy runs the following commands, in order: init, destroy. The +// state is also cleared. +func (t *TestTerraformer) Destroy() error { + if err := t.init(); err != nil { + return fmt.Errorf("error running Terraform init: %w", err) + } + + if err := t.destroy(); err != nil { + return fmt.Errorf("error running Terraform destroy: %w", err) + } + + t.state = nil + return nil +} + +// GetOutput returns the value of the output located at key. +// +// Outputs for the root module are supported only. It's an error to +// run this when no state exists. +func (t *TestTerraformer) GetOutput(key string) (any, error) { + if key == "" { + return nil, errors.New("key must not be empty") + } + + if t.state == nil { + return nil, errors.New("state is nil") + } + + if t.state.Values == nil { + return nil, errors.New("state integrity error: no state values") + } + + if t.state.Values.Outputs == nil { + return nil, errors.New("state integrity error: outputs is nil") + } + + output, ok := t.state.Values.Outputs[key] + if !ok { + return nil, fmt.Errorf("output with key %q not found", key) + } + + if output == nil { + return nil, fmt.Errorf("state integrity error: output at key %q is nil", key) + } + + return output.Value, nil +} + +// GetOutputString wraps GetOutput and returns the value as a string. It's an +// error if the return value is actually not a string. +func (t *TestTerraformer) GetOutputString(key string) (string, error) { + value, err := t.GetOutput(key) + if err != nil { + return "", err + } + + str, ok := value.(string) + if !ok { + return "", fmt.Errorf("expected output value at key %q to be string, got %T", key, value) + } + + return str, nil +} + +// GetOutputMap wraps GetOutput and returns the value as a map. It's an +// error if the return value is actually not a map. +func (t *TestTerraformer) GetOutputMap(key string) (map[string]any, error) { + value, err := t.GetOutput(key) + if err != nil { + return nil, err + } + + m, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("expected output value at key %q to be map, got %T", key, value) + } + + return m, nil +} + +// GetOutputSlice wraps GetOutput and returns the value as a slice. It's an +// error if the return value is actually not a slice. +func (t *TestTerraformer) GetOutputSlice(key string) ([]any, error) { + value, err := t.GetOutput(key) + if err != nil { + return nil, err + } + + m, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("expected output value at key %q to be slice, got %T", key, value) + } + + return m, nil +} + +// init runs "terraform init" in the project's working directory. +// +// This is only ever done once. +func (t *TestTerraformer) init() error { + t.initOnce.Do(func() { + cmd := exec.Command(t.tfPath, "init") + cmd.Dir = t.workDir + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + t.initErr = fmt.Errorf("%s\n%s", stdoutStderr, err) + } + }) + + return t.initErr +} + +// apply runs "terraform apply" in the project's working directory. +func (t *TestTerraformer) apply() error { + cmd := exec.Command(t.tfPath, "apply", "-input=false", "-auto-approve") + cmd.Dir = t.workDir + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s\n%s", stdoutStderr, err) + } + + return nil +} + +// destroy runs "terraform destroy" in the project's working directory. +func (t *TestTerraformer) destroy() error { + cmd := exec.Command(t.tfPath, "destroy", "-input=false", "-auto-approve") + cmd.Dir = t.workDir + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s\n%s", stdoutStderr, err) + } + + return nil +} + +// plan runs "terraform plan" in the project's working directory. +// +// Note that plan is not run as part of the apply workflow; rather, +// it's run to get the data for for importing the state via +// terraform-json. The apply method ignores the plan generated here. +func (t *TestTerraformer) plan() error { + cmd := exec.Command(t.tfPath, "plan", "-input=false", "-out=plan.tfplan") + cmd.Dir = t.workDir + stdoutStderr, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s\n%s", stdoutStderr, err) + } + + return nil +} + +// showJson runs "terraform show -json". The output is returned along +// with any error. +// +// Output is only the results of stdout and should be suitable for +// parsing. +// +// This method expects the plan.tfplan file to be generated in the +// working directory already. This can be done with the plan method. +func (t *TestTerraformer) showJson() ([]byte, error) { + cmd := exec.Command(t.tfPath, "show", "-json", "plan.tfplan") + cmd.Dir = t.workDir + output, err := cmd.Output() + if err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + return nil, err + } + + return nil, fmt.Errorf("%s\n%s", exitErr.Stderr, exitErr) + } + + return output, nil +} + +func requireCredentialsInvalid( + t *testing.T, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string) { + t.Helper() + require := require.New(t) + + c, err := credential.NewConfig( + credential.WithProjectId(projectId), + credential.WithClientEmail(clientEmail), + credential.WithPrivateKeyId(privateKeyId), + credential.WithPrivateKey(privateKey)) + require.NoError(err) + + creds, err := c.GenerateCredentials(context.Background()) + require.NoError(err) + + // We need to wait for invalidation as while gcp waits for + // credential creation, deletion of the old credentials returns + // immediately. + timeoutCtx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() +waitErr: + for { + _, err = c.ValidateIamPermissions(timeoutCtx, []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource)) + if err != nil { + break + } + + select { + case <-time.After(time.Second): + // pass + + case <-timeoutCtx.Done(): + break waitErr + } + } + + require.Error(err) + require.Contains(err.Error(), "failed to validate IAM permissions") +} + +func requireCredentialsValid( + t *testing.T, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string) { + t.Helper() + require := require.New(t) + + c, err := credential.NewConfig( + credential.WithProjectId(projectId), + credential.WithClientEmail(clientEmail), + credential.WithPrivateKeyId(privateKeyId), + credential.WithPrivateKey(privateKey)) + require.NoError(err) + + creds, err := c.GenerateCredentials(context.Background()) + require.NoError(err) + + _, err = c.ValidateIamPermissions(context.Background(), []string{credential.ComputeInstancesListPermission}, option.WithTokenSource(creds.TokenSource)) + require.NoError(err) +} + +func validatePersistedSecrets( + t *testing.T, + persistedSecrets *structpb.Struct, + projectId string, + clientEmail string, + privateKeyId string, + privateKey string, + rotate bool) (string, string) { + t.Helper() + require := require.New(t) + + require.NotNil(persistedSecrets) + persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true) + require.NoError(err) + require.NotZero(persistedPrivateKeyId) + if rotate { + require.NotEqual(privateKeyId, persistedPrivateKeyId) + } else { + require.Equal(privateKeyId, persistedPrivateKeyId) + } + + persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true) + require.NoError(err) + require.NotZero(persistedPrivateKey) + if rotate { + require.NotEqual(privateKey, persistedPrivateKey) + } else { + require.Equal(privateKey, persistedPrivateKey) + } + + persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime) + require.NoError(err) + if rotate { + require.NotZero(persistedCredsLastRotatedTime) + requireCredentialsInvalid(t, projectId, clientEmail, privateKeyId, privateKey) + } else { + require.Zero(persistedCredsLastRotatedTime) + } + + return persistedPrivateKeyId, persistedPrivateKey +} + +func validateUpdateSecrets( + t *testing.T, + projectId string, + clientEmail string, + persistedSecrets *structpb.Struct, + currentCredsLastRotatedTime time.Time, + currentPrivateKeyId string, + currentPrivateKey string, + newPrivateKeyId string, + newPrivateKey string, + rotated bool, + rotate bool) (string, string) { + t.Helper() + require := require.New(t) + + // Complex checks based on the scenarios. + persistedPrivateKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKeyId, true) + require.NoError(err) + require.NotZero(persistedPrivateKeyId) + persistedPrivateKey, err := values.GetStringValue(persistedSecrets, credential.ConstPrivateKey, true) + require.NoError(err) + require.NotZero(persistedPrivateKey) + persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime) + require.NoError(err) + + // Our test scenarios are complex due the multi-dimensional nature + // of criteria, so we lay them out in a switch below. + switch { + case newPrivateKeyId != "" && rotated && rotate: + // The new private key ID was provided, we had previously rotated + // the credentials before, and the new credential set is to be + // rotated as well. In this case, the old credentials should have + // been deleted, and the new credentials should have been rotated, + // hence, should not match the new credentials initially + // provided. Rotation time should be non-zero and updated. + requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.NotEqual(persistedPrivateKeyId, newPrivateKeyId) + require.NotEqual(persistedPrivateKey, newPrivateKey) + require.NotZero(persistedCredsLastRotatedTime) + require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime)) + + case newPrivateKeyId != "" && rotated && !rotate: + // The new private key ID was provided, we had previously rotated + // the credentials before, and the new credential is *not* + // rotated. In this case, the old credentials should have + // been deleted, But the new credentials should have not been + // rotated, and hence should be the same. Rotation time should be + // zero. + requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.Equal(persistedPrivateKeyId, newPrivateKeyId) + require.Equal(persistedPrivateKey, newPrivateKey) + require.Zero(persistedCredsLastRotatedTime) + + case newPrivateKeyId != "" && !rotated && rotate: + // The new private key was provided, we *have not* previously + // rotated the credentials, and the new credential set is to be + // rotated. In this case, the old credentials should have been + // left alone, and the new credentials should have been rotated, + // hence, should not match the new credentials initially + // provided. Rotation time should be non-zero, but updated. + requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.NotEqual(persistedPrivateKeyId, newPrivateKeyId) + require.NotEqual(persistedPrivateKey, newPrivateKey) + require.NotZero(persistedCredsLastRotatedTime) + require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime)) + + case newPrivateKeyId != "" && !rotated && !rotate: + // The new private key was provided, but we have not rotated + // the credentials previously and we still don't plan on rotating + // them. In this case, the old credentials should still be valid, + // and the persisted ones should match the new ones provided. + // Rotation time should be zero. + requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.Equal(persistedPrivateKeyId, newPrivateKeyId) + require.Equal(persistedPrivateKey, newPrivateKey) + require.Zero(persistedCredsLastRotatedTime) + + case newPrivateKeyId == "" && rotated && rotate: + // No new credentials have been provided, but we have previously + // rotated and are still rotating credentials. This is a no-op. + // Existing credentials should still be valid and match the ones + // persisted to state. Rotation time should be identical since + // no new rotation occurred. + requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.Equal(persistedPrivateKeyId, currentPrivateKeyId) + require.Equal(persistedPrivateKey, currentPrivateKey) + require.NotZero(persistedCredsLastRotatedTime) + require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime)) + + case newPrivateKeyId == "" && rotated && !rotate: + // No new credentials have been provided, and we have previously + // rotated the credentials. This is actually an error, but we + // don't test it here; it's covered in unit testing (see + // plugin_test.go). + require.FailNow("testing rotated-to-not-rotated scenario not implemented by this helper") + + case newPrivateKeyId == "" && !rotated && rotate: + // No new credentials have been provided, and while we did not + // rotate before, we want to switch to rotation. In this case, + // the existing persisted credentials should have been rotated, + // with a new non-zero timestamp. + requireCredentialsInvalid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.NotEqual(persistedPrivateKeyId, currentPrivateKeyId) + require.NotEqual(persistedPrivateKey, currentPrivateKey) + require.NotZero(persistedCredsLastRotatedTime) + + case newPrivateKeyId == "" && !rotated && !rotate: + // No new credentials have been provided and we have not, nor do + // not, plan on rotating the credentials. This is a no-op. + // Existing credentials should still be valid and match the ones + // persisted to state. Rotation time should remain at zero. + requireCredentialsValid(t, projectId, clientEmail, currentPrivateKeyId, currentPrivateKey) + require.Equal(persistedPrivateKeyId, currentPrivateKeyId) + require.Equal(persistedPrivateKey, currentPrivateKey) + require.Zero(persistedCredsLastRotatedTime) + + default: + // Scenario was reached that was not covered by this function. + require.FailNow("unknown test scenario") + } + + return persistedPrivateKeyId, persistedPrivateKey +}