// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package testing import ( "context" "fmt" "os" "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/hashicorp/boundary-plugin-host-aws/internal/credential" "github.com/hashicorp/boundary-plugin-host-aws/internal/values" "github.com/hashicorp/boundary-plugin-host-aws/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/hashicorp/go-secure-stdlib/awsutil" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) const ( expectedIamUserCount = 6 expectedEc2InstanceCount = 5 ) var expectedTags = []string{"foo", "bar", "baz"} func TestPlugin(t *testing.T) { region := os.Getenv("AWS_REGION") if region == "" { t.Skip("set AWS_REGION to use this test") } if envAccessKeyId := os.Getenv("AWS_ACCESS_KEY_ID"); envAccessKeyId == "" { t.Skip("set AWS_ACCESS_KEY_ID to use this test") } if envSecretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY"); envSecretAccessKey == "" { t.Skip("set AWS_SECRET_ACCESS_KEY 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) } }() iamUserNames, err := tf.GetOutputSlice("iam_user_names") require.NoError(err) require.Len(iamUserNames, expectedIamUserCount) iamUserArns, err := tf.GetOutputSlice("iam_user_arns") require.NoError(err) require.Len(iamUserArns, expectedIamUserCount) iamAccessKeyIds, err := tf.GetOutputSlice("iam_access_key_ids") require.NoError(err) require.Len(iamAccessKeyIds, expectedIamUserCount) iamSecretAccessKeys, err := tf.GetOutputSlice("iam_secret_access_keys") require.NoError(err) require.Len(iamSecretAccessKeys, expectedIamUserCount) ec2InstanceIds, err := tf.GetOutputSlice("instance_ids") require.NoError(err) require.Len(ec2InstanceIds, expectedEc2InstanceCount) ec2InstanceAddrs, err := tf.GetOutputMap("instance_addrs") require.NoError(err) require.Len(ec2InstanceAddrs, expectedEc2InstanceCount) ec2InstanceTags, err := tf.GetOutputMap("instance_tags") require.NoError(err) require.Len(ec2InstanceTags, expectedEc2InstanceCount) // Start the workflow now. Set up the host catalog. Note that this // will cause the state to go out of drift above in the sense that // the access key ID/secret access key will no longer be valid. We // will assert this through the returned state. p := new(host.HostPlugin) ctx := context.Background() // ******************** // * OnCreateCatalog // ******************** // // Test non-rotation (using primary user). keyid, secret := testPluginOnCreateCatalog(ctx, t, p, region, iamAccessKeyIds[0].(string), iamSecretAccessKeys[0].(string), false) // Test rotation next. keyid, secret = testPluginOnCreateCatalog(ctx, t, p, region, keyid, secret, true) // ******************** // * OnUpdateCatalog // ******************** // // Test no-op non-rotation. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, false) // Switch to rotation. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", false, true) // Test no-op with rotation. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, "", "", true, true) // Switch credentials to next user. Don't rotate. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[1].(string), iamSecretAccessKeys[1].(string), true, false) // Switch credentials to next user. Add rotation. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[2].(string), iamSecretAccessKeys[2].(string), false, true) // Switch to next user, with rotation disabled. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[3].(string), iamSecretAccessKeys[3].(string), true, false) // Last case - switch to another user and keep rotation off. keyid, secret = testPluginOnUpdateCatalog(ctx, t, p, region, keyid, secret, iamAccessKeyIds[4].(string), iamSecretAccessKeys[4].(string), false, false) // ******************** // * OnDeleteCatalog // ******************** // // Test non-rotated. testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, false) // Test as if we had rotated these credentials (note that this // makes this test set unusable). testPluginOnDeleteCatalog(ctx, t, p, region, keyid, secret, true) // ******************** // * Host set stuff // ******************** // Reassign the keyid and secret first. keyid, secret = iamAccessKeyIds[5].(string), iamSecretAccessKeys[5].(string) // Process the collection of instances and index by expected tag names. expectedTagInstancesMap := make(map[string][]string) for instanceId, instanceTags := range ec2InstanceTags { for tagKey := range instanceTags.(map[string]any) { for _, expectedTag := range expectedTags { if tagKey == expectedTag { expectedTagInstancesMap[tagKey] = append(expectedTagInstancesMap[tagKey], instanceId) } } } } cases := [][]string{ {"foo"}, {"bar"}, {"baz"}, {"foo", "bar"}, {"foo", "baz"}, {"bar", "baz"}, {"foo", "bar", "baz"}, } for _, tc := range cases { // Test create/update in one step testPluginOnCreateUpdateSet(ctx, t, p, region, keyid, secret, tc) // Test ListHosts testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap) // TODO: add OnDeleteSet if it needs to be implemented } } func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { t.Helper() t.Logf("testing OnCreateCatalog (region=%s, rotate=%t)", region, rotate) require := require.New(t) reqAttrs, err := structpb.NewStruct(map[string]any{ credential.ConstRegion: region, credential.ConstDisableCredentialRotation: !rotate, }) require.NoError(err) reqSecrets, err := structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: accessKeyId, credential.ConstSecretAccessKey: secretAccessKey, }) 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) persistedSecrets := persisted.GetSecrets() require.NotNil(persistedSecrets) persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true) require.NoError(err) require.NotZero(persistedAccessKeyId) if rotate { require.NotEqual(accessKeyId, persistedAccessKeyId) } else { require.Equal(accessKeyId, persistedAccessKeyId) } persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true) require.NoError(err) require.NotZero(persistedSecretAccessKey) if rotate { require.NotEqual(secretAccessKey, persistedSecretAccessKey) } else { require.Equal(secretAccessKey, persistedSecretAccessKey) } persistedCredsLastRotatedTime, err := values.GetTimeValue(persistedSecrets, credential.ConstCredsLastRotatedTime) require.NoError(err) if rotate { require.NotZero(persistedCredsLastRotatedTime) requireCredentialsInvalid(t, accessKeyId, secretAccessKey) } else { require.Zero(persistedCredsLastRotatedTime) } return persistedAccessKeyId, persistedSecretAccessKey } func testPluginOnUpdateCatalog( ctx context.Context, t *testing.T, p *host.HostPlugin, region, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey string, rotated, rotate bool, ) (string, string) { t.Helper() t.Logf("testing OnUpdateCatalog (region=%s, newcreds=%t, rotated=%t, rotate=%t)", region, newAccessKeyId != "" && newSecretAccessKey != "", 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.ConstRegion: region, credential.ConstDisableCredentialRotation: !rotated, }) require.NoError(err) reqNewAttrs, err := structpb.NewStruct(map[string]any{ credential.ConstRegion: region, credential.ConstDisableCredentialRotation: !rotate, }) require.NoError(err) var reqSecrets *structpb.Struct if newAccessKeyId != "" && newSecretAccessKey != "" { reqSecrets, err = structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: newAccessKeyId, credential.ConstSecretAccessKey: newSecretAccessKey, }) require.NoError(err) } reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: currentAccessKeyId, credential.ConstSecretAccessKey: currentSecretAccessKey, 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) persistedSecrets := persisted.GetSecrets() require.NotNil(persistedSecrets) // Complex checks based on the scenarios. persistedAccessKeyId, err := values.GetStringValue(persistedSecrets, credential.ConstAccessKeyId, true) require.NoError(err) require.NotZero(persistedAccessKeyId) persistedSecretAccessKey, err := values.GetStringValue(persistedSecrets, credential.ConstSecretAccessKey, true) require.NoError(err) require.NotZero(persistedSecretAccessKey) 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 newAccessKeyId != "" && rotated && rotate: // The new access 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, currentAccessKeyId, currentSecretAccessKey) require.NotEqual(persistedAccessKeyId, newAccessKeyId) require.NotEqual(persistedSecretAccessKey, newSecretAccessKey) require.NotZero(persistedCredsLastRotatedTime) require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime)) case newAccessKeyId != "" && rotated && !rotate: // The new access 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, currentAccessKeyId, currentSecretAccessKey) require.Equal(persistedAccessKeyId, newAccessKeyId) require.Equal(persistedSecretAccessKey, newSecretAccessKey) require.Zero(persistedCredsLastRotatedTime) case newAccessKeyId != "" && !rotated && rotate: // The new access key ID 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, currentAccessKeyId, currentSecretAccessKey) require.NotEqual(persistedAccessKeyId, newAccessKeyId) require.NotEqual(persistedSecretAccessKey, newSecretAccessKey) require.NotZero(persistedCredsLastRotatedTime) require.True(persistedCredsLastRotatedTime.After(currentCredsLastRotatedTime)) case newAccessKeyId != "" && !rotated && !rotate: // The new access key ID 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, currentAccessKeyId, currentSecretAccessKey) require.Equal(persistedAccessKeyId, newAccessKeyId) require.Equal(persistedSecretAccessKey, newSecretAccessKey) require.Zero(persistedCredsLastRotatedTime) case newAccessKeyId == "" && 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, currentAccessKeyId, currentSecretAccessKey) require.Equal(persistedAccessKeyId, currentAccessKeyId) require.Equal(persistedSecretAccessKey, currentSecretAccessKey) require.NotZero(persistedCredsLastRotatedTime) require.True(currentCredsLastRotatedTime.Equal(persistedCredsLastRotatedTime)) case newAccessKeyId == "" && 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 newAccessKeyId == "" && !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, currentAccessKeyId, currentSecretAccessKey) require.NotEqual(persistedAccessKeyId, currentAccessKeyId) require.NotEqual(persistedSecretAccessKey, currentSecretAccessKey) require.NotZero(persistedCredsLastRotatedTime) case newAccessKeyId == "" && !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, currentAccessKeyId, currentSecretAccessKey) require.Equal(persistedAccessKeyId, currentAccessKeyId) require.Equal(persistedSecretAccessKey, currentSecretAccessKey) require.Zero(persistedCredsLastRotatedTime) default: // Scenario was reached that was not covered by this function. require.FailNow("unknown test scenario") } return persistedAccessKeyId, persistedSecretAccessKey } func testPluginOnDeleteCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotated bool) { t.Helper() t.Logf("testing OnDeleteCatalog (region=%s, rotated=%t)", region, rotated) require := require.New(t) reqAttrs, err := structpb.NewStruct(map[string]any{ credential.ConstRegion: region, credential.ConstDisableCredentialRotation: !rotated, }) require.NoError(err) reqSecrets, err := structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: accessKeyId, credential.ConstSecretAccessKey: secretAccessKey, }) require.NoError(err) reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: accessKeyId, credential.ConstSecretAccessKey: secretAccessKey, 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, accessKeyId, secretAccessKey) } 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, accessKeyId, secretAccessKey) } } func testPluginOnCreateUpdateSet(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, tags []string) { t.Helper() t.Logf("testing OnCreateSet (region=%s, tags=%v)", region, tags) require := require.New(t) catalogAttrs, err := structpb.NewStruct(map[string]any{ credential.ConstRegion: region, 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.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", strings.Join(tags, ","))}, }) require.NoError(err) reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ credential.ConstAccessKeyId: accessKeyId, credential.ConstSecretAccessKey: secretAccessKey, 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 (region=%s, tags=%v)", region, tags) 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, region, accessKeyId, secretAccessKey string, tags []string, expected map[string][]string) { t.Helper() t.Logf("testing ListHosts (region=%s, tags=%v)", region, tags) require := require.New(t) catalogAttrs, err := structpb.NewStruct(map[string]any{ credential.ConstRegion: region, credential.ConstDisableCredentialRotation: true, // Note that this does nothing in sets, but just noting for tests }) require.NoError(err) sets := make([]*hostsets.HostSet, len(tags)) for i, tag := range tags { setAttrs, err := structpb.NewStruct(map[string]any{ host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("tag-key=%s", tag)}, }) 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.ConstAccessKeyId: accessKeyId, credential.ConstSecretAccessKey: secretAccessKey, 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, tag := range tags { for _, instanceId := range expected[tag] { 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))", region, tags, len(actualInstances), actualInstances) } func requireCredentialsInvalid(t *testing.T, accessKeyId, secretAccessKey string) { t.Helper() require := require.New(t) c, err := awsutil.NewCredentialsConfig( awsutil.WithAccessKey(accessKeyId), awsutil.WithSecretKey(secretAccessKey), ) require.NoError(err) // We need to wait for invalidation as while awsutil 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.GetCallerIdentity() if err != nil { break } select { case <-time.After(time.Second): // pass case <-timeoutCtx.Done(): break waitErr } } require.NotNil(err) awsErr, ok := err.(awserr.Error) require.True(ok) require.Equal("InvalidClientTokenId", awsErr.Code()) } func requireCredentialsValid(t *testing.T, accessKeyId, secretAccessKey string) { t.Helper() require := require.New(t) c, err := awsutil.NewCredentialsConfig( awsutil.WithAccessKey(accessKeyId), awsutil.WithSecretKey(secretAccessKey), ) require.NoError(err) _, err = c.GetCallerIdentity() require.NoError(err) }