diff --git a/.changes/unreleased/FEATURES-20250513-115526.yaml b/.changes/unreleased/FEATURES-20250513-115526.yaml new file mode 100644 index 000000000..2e3aebe02 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250513-115526.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'statecheck: Added `ExpectIdentityValueMatchesState` state check to assert that an identity value matches a state value at the same path.' +time: 2025-05-13T11:55:26.406171-04:00 +custom: + Issue: "503" diff --git a/.changes/unreleased/FEATURES-20250514-095016.yaml b/.changes/unreleased/FEATURES-20250514-095016.yaml new file mode 100644 index 000000000..f415885ba --- /dev/null +++ b/.changes/unreleased/FEATURES-20250514-095016.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'statecheck: Added `ExpectIdentityValueMatchesStateAtPath` state check to assert that an identity value matches a state value at different paths.' +time: 2025-05-14T09:50:16.101201-04:00 +custom: + Issue: "503" diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index b50a319d8..0855ff733 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -890,7 +890,7 @@ func (s ProviderServer) UpgradeResourceState(ctx context.Context, req *tfprotov6 } func (s ProviderServer) UpgradeResourceIdentity(context.Context, *tfprotov6.UpgradeResourceIdentityRequest) (*tfprotov6.UpgradeResourceIdentityResponse, error) { - // TODO: Implement + // TODO: This isn't currently being used by the testing framework provider, so no need to implement it until then. return nil, errors.New("UpgradeResourceIdentity is not currently implemented in testprovider") } diff --git a/statecheck/expect_identity_test.go b/statecheck/expect_identity_test.go index 5a0101435..20032bfe0 100644 --- a/statecheck/expect_identity_test.go +++ b/statecheck/expect_identity_test.go @@ -7,7 +7,6 @@ import ( "regexp" "testing" - "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-plugin-go/tfprotov6" r "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -134,12 +133,6 @@ func TestExpectIdentity_CheckState(t *testing.T) { r.Test(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ tfversion.SkipBelow(tfversion.Version1_12_0), - // TODO: There is currently a bug in Terraform v1.12.0-alpha20250319 that causes a panic - // when refreshing a resource that has an identity stored via protocol v6. - // - // We can remove this skip once the bug fix is merged/released: - // - https://github.com/hashicorp/terraform/pull/36756 - tfversion.SkipIf(version.Must(version.NewVersion("1.12.0-alpha20250319"))), }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": examplecloudProviderWithResourceIdentity(), diff --git a/statecheck/expect_identity_value_matches_state.go b/statecheck/expect_identity_value_matches_state.go new file mode 100644 index 000000000..1e3c6ea14 --- /dev/null +++ b/statecheck/expect_identity_value_matches_state.go @@ -0,0 +1,97 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck + +import ( + "context" + "fmt" + "reflect" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ StateCheck = expectIdentityValueMatchesState{} + +type expectIdentityValueMatchesState struct { + resourceAddress string + attributePath tfjsonpath.Path +} + +// CheckState implements the state check logic. +func (e expectIdentityValueMatchesState) CheckState(ctx context.Context, req CheckStateRequest, resp *CheckStateResponse) { + var resource *tfjson.StateResource + + if req.State == nil { + resp.Error = fmt.Errorf("state is nil") + + return + } + + if req.State.Values == nil { + resp.Error = fmt.Errorf("state does not contain any state values") + + return + } + + if req.State.Values.RootModule == nil { + resp.Error = fmt.Errorf("state does not contain a root module") + + return + } + + for _, r := range req.State.Values.RootModule.Resources { + if e.resourceAddress == r.Address { + resource = r + + break + } + } + + if resource == nil { + resp.Error = fmt.Errorf("%s - Resource not found in state", e.resourceAddress) + + return + } + + if resource.IdentitySchemaVersion == nil || len(resource.IdentityValues) == 0 { + resp.Error = fmt.Errorf("%s - Identity not found in state. Either the resource does not support identity or the Terraform version running the test does not support identity. (must be v1.12+)", e.resourceAddress) + + return + } + + identityResult, err := tfjsonpath.Traverse(resource.IdentityValues, e.attributePath) + + if err != nil { + resp.Error = err + + return + } + + stateResult, err := tfjsonpath.Traverse(resource.AttributeValues, e.attributePath) + + if err != nil { + resp.Error = err + + return + } + + if !reflect.DeepEqual(identityResult, stateResult) { + resp.Error = fmt.Errorf("expected identity and state value at path to match, but they differ: %s.%s, identity value: %v, state value: %v", e.resourceAddress, e.attributePath.String(), identityResult, stateResult) + + return + } +} + +// ExpectIdentityValueMatchesState returns a state check that asserts that the specified identity attribute at the given resource +// matches the same attribute in state. This is useful when an identity attribute is in sync with a state attribute of the same path. +// +// This state check can only be used with managed resources that support resource identity. Resource identity is only supported in Terraform v1.12+ +func ExpectIdentityValueMatchesState(resourceAddress string, attributePath tfjsonpath.Path) StateCheck { + return expectIdentityValueMatchesState{ + resourceAddress: resourceAddress, + attributePath: attributePath, + } +} diff --git a/statecheck/expect_identity_value_matches_state_at_path.go b/statecheck/expect_identity_value_matches_state_at_path.go new file mode 100644 index 000000000..257243998 --- /dev/null +++ b/statecheck/expect_identity_value_matches_state_at_path.go @@ -0,0 +1,106 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck + +import ( + "context" + "fmt" + "reflect" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ StateCheck = expectIdentityValueMatchesStateAtPath{} + +type expectIdentityValueMatchesStateAtPath struct { + resourceAddress string + identityAttrPath tfjsonpath.Path + stateAttrPath tfjsonpath.Path +} + +// CheckState implements the state check logic. +func (e expectIdentityValueMatchesStateAtPath) CheckState(ctx context.Context, req CheckStateRequest, resp *CheckStateResponse) { + var resource *tfjson.StateResource + + if req.State == nil { + resp.Error = fmt.Errorf("state is nil") + + return + } + + if req.State.Values == nil { + resp.Error = fmt.Errorf("state does not contain any state values") + + return + } + + if req.State.Values.RootModule == nil { + resp.Error = fmt.Errorf("state does not contain a root module") + + return + } + + for _, r := range req.State.Values.RootModule.Resources { + if e.resourceAddress == r.Address { + resource = r + + break + } + } + + if resource == nil { + resp.Error = fmt.Errorf("%s - Resource not found in state", e.resourceAddress) + + return + } + + if resource.IdentitySchemaVersion == nil || len(resource.IdentityValues) == 0 { + resp.Error = fmt.Errorf("%s - Identity not found in state. Either the resource does not support identity or the Terraform version running the test does not support identity. (must be v1.12+)", e.resourceAddress) + + return + } + + identityResult, err := tfjsonpath.Traverse(resource.IdentityValues, e.identityAttrPath) + + if err != nil { + resp.Error = err + + return + } + + stateResult, err := tfjsonpath.Traverse(resource.AttributeValues, e.stateAttrPath) + + if err != nil { + resp.Error = err + + return + } + + if !reflect.DeepEqual(identityResult, stateResult) { + resp.Error = fmt.Errorf( + "expected identity (%[1]s.%[2]s) and state value (%[1]s.%[3]s) to match, but they differ: identity value: %[4]v, state value: %[5]v", + e.resourceAddress, + e.identityAttrPath.String(), + e.stateAttrPath.String(), + identityResult, + stateResult, + ) + + return + } +} + +// ExpectIdentityValueMatchesStateAtPath returns a state check that asserts that the specified identity attribute at the given resource +// matches the specified attribute in state. This is useful when an identity attribute is in sync with a state attribute of a different path. +// +// This state check can only be used with managed resources that support resource identity. Resource identity is only supported in Terraform v1.12+ +func ExpectIdentityValueMatchesStateAtPath(resourceAddress string, identityAttrPath, stateAttrPath tfjsonpath.Path) StateCheck { + return expectIdentityValueMatchesStateAtPath{ + resourceAddress: resourceAddress, + identityAttrPath: identityAttrPath, + stateAttrPath: stateAttrPath, + } +} diff --git a/statecheck/expect_identity_value_matches_state_at_path_example_test.go b/statecheck/expect_identity_value_matches_state_at_path_example_test.go new file mode 100644 index 000000000..474864631 --- /dev/null +++ b/statecheck/expect_identity_value_matches_state_at_path_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func ExampleExpectIdentityValueMatchesStateAtPath() { + // A typical test would accept *testing.T as a function parameter, for instance `func TestSomething(t *testing.T) { ... }`. + t := &testing.T{} + t.Parallel() + + resource.Test(t, resource.TestCase{ + // Resource identity support is only available in Terraform v1.12+ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + // Provider definition omitted. Assuming "test_resource": + // - Has an identity schema with an "identity_id" string attribute + // - Has a resource schema with an "state_id" string attribute + Steps: []resource.TestStep{ + { + Config: `resource "test_resource" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + // The identity attribute at "identity_id" and state attribute at "state_id" must match + statecheck.ExpectIdentityValueMatchesStateAtPath( + "test_resource.one", + tfjsonpath.New("identity_id"), + tfjsonpath.New("state_id"), + ), + }, + }, + }, + }) +} diff --git a/statecheck/expect_identity_value_matches_state_at_path_test.go b/statecheck/expect_identity_value_matches_state_at_path_test.go new file mode 100644 index 000000000..3ee7a4a64 --- /dev/null +++ b/statecheck/expect_identity_value_matches_state_at_path_test.go @@ -0,0 +1,344 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.two", + tfjsonpath.New("id"), + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile("examplecloud_thing.two - Resource not found in state"), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_No_Terraform_Identity_Support(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories + tfversion.SkipAbove(tfversion.Version1_11_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource support identity, but the Terraform versions running will not. + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("id"), + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_No_Identity(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource does not support identity + "examplecloud": examplecloudProviderNoIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("id"), + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_String_Matches(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentityDifferentPaths(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("identity_id"), + tfjsonpath.New("state_id"), + ), + }, + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_String_DoesntMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithMismatchedResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("id"), + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile(`expected identity \(examplecloud_thing.one.id\) and state value \(examplecloud_thing.one.id\) to match, but they differ: identity value: id-123, state value: 321-di`), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_List(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentityDifferentPaths(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("identity_list_of_numbers"), + tfjsonpath.New("state_list_of_numbers"), + ), + }, + }, + }, + }) +} + +func TestExpectIdentityValueMatchesStateAtPath_CheckState_List_DoesntMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithMismatchedResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesStateAtPath( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers"), + tfjsonpath.New("list_of_numbers"), + ), + }, + ExpectError: regexp.MustCompile(`expected identity \(examplecloud_thing.one.list_of_numbers\) and state value \(examplecloud_thing.one.list_of_numbers\) to match, but they differ: identity value: \[1 2 3 4\], state value: \[4 3 2 1\]`), + }, + }, + }) +} + +func examplecloudProviderWithResourceIdentityDifferentPaths() func() (tfprotov6.ProviderServer, error) { + return providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_thing": { + CreateResponse: &resource.CreateResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "state_id": tftypes.String, + "state_list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + "state_id": tftypes.NewValue(tftypes.String, "id-123"), + "state_list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "identity_id": tftypes.String, + "identity_list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "identity_id": tftypes.NewValue(tftypes.String, "id-123"), + "identity_list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + ReadResponse: &resource.ReadResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "state_id": tftypes.String, + "state_list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + "state_id": tftypes.NewValue(tftypes.String, "id-123"), + "state_list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "identity_id": tftypes.String, + "identity_list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "identity_id": tftypes.NewValue(tftypes.String, "id-123"), + "identity_list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "identity_id", + Type: tftypes.String, + RequiredForImport: true, + }, + { + Name: "identity_list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + OptionalForImport: true, + }, + }, + }, + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "name", + Type: tftypes.String, + Computed: true, + }, + { + Name: "state_id", + Type: tftypes.String, + Computed: true, + }, + { + Name: "state_list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }) +} diff --git a/statecheck/expect_identity_value_matches_state_example_test.go b/statecheck/expect_identity_value_matches_state_example_test.go new file mode 100644 index 000000000..df0dd546c --- /dev/null +++ b/statecheck/expect_identity_value_matches_state_example_test.go @@ -0,0 +1,38 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func ExampleExpectIdentityValueMatchesState() { + // A typical test would accept *testing.T as a function parameter, for instance `func TestSomething(t *testing.T) { ... }`. + t := &testing.T{} + t.Parallel() + + resource.Test(t, resource.TestCase{ + // Resource identity support is only available in Terraform v1.12+ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + // Provider definition omitted. Assuming "test_resource": + // - Has an identity schema with an "id" string attribute + // - Has a resource schema with an "id" string attribute + Steps: []resource.TestStep{ + { + Config: `resource "test_resource" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + // The identity attribute and state attribute at "id" must match + statecheck.ExpectIdentityValueMatchesState("test_resource.one", tfjsonpath.New("id")), + }, + }, + }, + }) +} diff --git a/statecheck/expect_identity_value_matches_state_test.go b/statecheck/expect_identity_value_matches_state_test.go new file mode 100644 index 000000000..d3248e15b --- /dev/null +++ b/statecheck/expect_identity_value_matches_state_test.go @@ -0,0 +1,337 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestExpectIdentityValueMatchesState_CheckState_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.two", + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile("examplecloud_thing.two - Resource not found in state"), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_No_Terraform_Identity_Support(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories + tfversion.SkipAbove(tfversion.Version1_11_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource support identity, but the Terraform versions running will not. + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_No_Identity(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource does not support identity + "examplecloud": examplecloudProviderNoIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_String_Matches(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("id"), + ), + }, + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_String_DoesntMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithMismatchedResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("id"), + ), + }, + ExpectError: regexp.MustCompile("expected identity and state value at path to match, but they differ: examplecloud_thing.one.id, identity value: id-123, state value: 321-di"), + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_List(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers"), + ), + }, + }, + }, + }) +} + +func TestExpectIdentityValueMatchesState_CheckState_List_DoesntMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithMismatchedResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValueMatchesState( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers"), + ), + }, + ExpectError: regexp.MustCompile(`expected identity and state value at path to match, but they differ: examplecloud_thing.one.list_of_numbers, identity value: \[1 2 3 4\], state value: \[4 3 2 1\]`), + }, + }, + }) +} + +func examplecloudProviderWithMismatchedResourceIdentity() func() (tfprotov6.ProviderServer, error) { + return providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_thing": { + CreateResponse: &resource.CreateResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + "id": tftypes.NewValue(tftypes.String, "321-di"), // doesn't match identity -> id + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 4), // doesn't match identity -> list_of_numbers[0] + tftypes.NewValue(tftypes.Number, 3), // doesn't match identity -> list_of_numbers[1] + tftypes.NewValue(tftypes.Number, 2), // doesn't match identity -> list_of_numbers[2] + tftypes.NewValue(tftypes.Number, 1), // doesn't match identity -> list_of_numbers[3] + }, + ), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + ReadResponse: &resource.ReadResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + "id": tftypes.NewValue(tftypes.String, "321-di"), // doesn't match identity -> id + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 4), // doesn't match identity -> list_of_numbers[0] + tftypes.NewValue(tftypes.Number, 3), // doesn't match identity -> list_of_numbers[1] + tftypes.NewValue(tftypes.Number, 2), // doesn't match identity -> list_of_numbers[2] + tftypes.NewValue(tftypes.Number, 1), // doesn't match identity -> list_of_numbers[3] + }, + ), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + RequiredForImport: true, + }, + { + Name: "list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + OptionalForImport: true, + }, + }, + }, + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "name", + Type: tftypes.String, + Computed: true, + }, + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + { + Name: "list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }) +} diff --git a/statecheck/expect_identity_value_test.go b/statecheck/expect_identity_value_test.go index 71c56c072..8ee701272 100644 --- a/statecheck/expect_identity_value_test.go +++ b/statecheck/expect_identity_value_test.go @@ -7,7 +7,6 @@ import ( "regexp" "testing" - "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -113,12 +112,6 @@ func TestExpectIdentityValue_CheckState_String(t *testing.T) { r.Test(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ tfversion.SkipBelow(tfversion.Version1_12_0), - // TODO: There is currently a bug in Terraform v1.12.0-alpha20250319 that causes a panic - // when refreshing a resource that has an identity stored via protocol v6. - // - // We can remove this skip once the bug fix is merged/released: - // - https://github.com/hashicorp/terraform/pull/36756 - tfversion.SkipIf(version.Must(version.NewVersion("1.12.0-alpha20250319"))), }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": examplecloudProviderWithResourceIdentity(), @@ -193,12 +186,6 @@ func TestExpectIdentityValue_CheckState_List(t *testing.T) { r.Test(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ tfversion.SkipBelow(tfversion.Version1_12_0), - // TODO: There is currently a bug in Terraform v1.12.0-alpha20250319 that causes a panic - // when refreshing a resource that has an identity stored via protocol v6. - // - // We can remove this skip once the bug fix is merged/released: - // - https://github.com/hashicorp/terraform/pull/36756 - tfversion.SkipIf(version.Must(version.NewVersion("1.12.0-alpha20250319"))), }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": examplecloudProviderWithResourceIdentity(), @@ -299,11 +286,23 @@ func examplecloudProviderWithResourceIdentity() func() (tfprotov6.ProviderServer NewState: tftypes.NewValue( tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "name": tftypes.String, + "name": tftypes.String, + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, }, }, map[string]tftypes.Value{ "name": tftypes.NewValue(tftypes.String, "test value"), + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), }, ), NewIdentity: teststep.Pointer(tftypes.NewValue( @@ -331,11 +330,23 @@ func examplecloudProviderWithResourceIdentity() func() (tfprotov6.ProviderServer NewState: tftypes.NewValue( tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ - "name": tftypes.String, + "name": tftypes.String, + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, }, }, map[string]tftypes.Value{ "name": tftypes.NewValue(tftypes.String, "test value"), + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), }, ), NewIdentity: teststep.Pointer(tftypes.NewValue( @@ -384,6 +395,16 @@ func examplecloudProviderWithResourceIdentity() func() (tfprotov6.ProviderServer Type: tftypes.String, Computed: true, }, + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + { + Name: "list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + Computed: true, + }, }, }, },