// Copyright IBM Corp. 2021, 2026 // SPDX-License-Identifier: MPL-2.0 package testing import ( "context" "fmt" "os" "strings" "testing" "time" "github.com/hashicorp/boundary-plugin-aws/internal/credential" "github.com/hashicorp/boundary-plugin-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/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" ) const expectedEc2InstanceCount = 5 func TestHostPlugin(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) rawExpectedTags, err := tf.GetOutputSlice("instance_tag_keys") require.NoError(err) require.Len(rawExpectedTags, 3) expectedTags := make([]string, 0, len(rawExpectedTags)) for _, rawExpectedTag := range rawExpectedTags { expectedTags = append(expectedTags, rawExpectedTag.(string)) } // 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{ {expectedTags[0]}, {expectedTags[1]}, {expectedTags[2]}, {expectedTags[0], expectedTags[1]}, {expectedTags[0], expectedTags[2]}, {expectedTags[1], expectedTags[2]}, {expectedTags[0], expectedTags[1], expectedTags[2]}, } 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 } // finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret) } 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) return validatePersistedSecrets(t, persisted.GetSecrets(), accessKeyId, secretAccessKey, rotate) } 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) return validateUpdateSecrets(t, persisted.GetSecrets(), currentCredsLastRotatedTime, currentAccessKeyId, currentSecretAccessKey, newAccessKeyId, newSecretAccessKey, rotated, rotate) } 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 testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) { t.Helper() t.Logf("testing testInstanceAddressesOnlyListHosts") // Get the instance details from tf output instanceID, err := tf.GetOutputString("multi_address_instance_id") require.NoError(t, err) primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses") require.NoError(t, err) secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses") require.NoError(t, err) var primary, allAddresses []string for _, addr := range primaryAddresses { primary = append(primary, addr.(string)) } allAddresses = append(allAddresses, primary...) for _, addr := range secondaryAddresses { allAddresses = append(allAddresses, addr.(string)) } cases := []struct { name string instanceAddressesOnly bool expectedIPs []string }{ { name: "all addresses", expectedIPs: allAddresses, }, { name: "instance addresses only", instanceAddressesOnly: true, expectedIPs: primary, }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) catalogAttrMap := map[string]any{ credential.ConstRegion: region, credential.ConstDisableCredentialRotation: true, } if tc.instanceAddressesOnly { catalogAttrMap[host.ConstInstanceAddressesOnly] = true } catalogAttrs, err := structpb.NewStruct(catalogAttrMap) require.NoError(err) setAttrs, err := structpb.NewStruct(map[string]any{ host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)}, }) 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) request := &pb.ListHostsRequest{ Catalog: &hostcatalogs.HostCatalog{ Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: catalogAttrs, }, }, Sets: []*hostsets.HostSet{{ Id: "hostset-0", Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}, }}, Persisted: &pb.HostCatalogPersisted{ Secrets: reqPersistedSecrets, }, } response, err := p.ListHosts(ctx, request) require.NoError(err) require.Len(response.GetHosts(), 1) hostResp := response.GetHosts()[0] require.Equal(instanceID, hostResp.ExternalId) require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs) }) } }