From 298d360c35f602ef490cfbc4eb5b70dec881e371 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:05:21 -0400 Subject: [PATCH 01/13] Implement `tfjsonpath` package --- tfjsonpath/doc.go | 3 + tfjsonpath/path.go | 98 ++++++++++++++++ tfjsonpath/path_test.go | 245 ++++++++++++++++++++++++++++++++++++++++ tfjsonpath/step.go | 11 ++ 4 files changed, 357 insertions(+) create mode 100644 tfjsonpath/doc.go create mode 100644 tfjsonpath/path.go create mode 100644 tfjsonpath/path_test.go create mode 100644 tfjsonpath/step.go diff --git a/tfjsonpath/doc.go b/tfjsonpath/doc.go new file mode 100644 index 000000000..d5d5b344a --- /dev/null +++ b/tfjsonpath/doc.go @@ -0,0 +1,3 @@ +// Package tfjsonpath implements terraform-json path functionality, which defines +// traversals into Terraform JSON data, for testing purposes. +package tfjsonpath diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go new file mode 100644 index 000000000..336d6ea5e --- /dev/null +++ b/tfjsonpath/path.go @@ -0,0 +1,98 @@ +package tfjsonpath + +import ( + "fmt" +) + +// Path represents exact traversal steps specifying a value inside +// Terraform JSON data. These steps always start from a MapStep with a key +// specifying the name of a top-level JSON object or array. +// +// The [terraform-json] library serves as the de facto documentation +// for JSON format of Terraform data. +// +// Use the Create() function to create a Path with an initial AtMapKey() step. +// Path functionality follows a builder pattern, which allows for chaining method +// calls to construct a full path. The available traversal steps after Path +// creation are: +// +// - AtSliceIndex(): Step into a slice at a specific 0-based index +// - AtMapKey(): Step into a map at a specific key +// +// For example, to represent the first element in a top-level JSON array +// named "some_array": +// +// path.Create("some_array").AtSliceIndex(0) +// +// [terraform-json]: (https://pkg.go.dev/github.com/hashicorp/terraform-json) +type Path struct { + steps []step +} + +// New creates a new path with an initial MapStep. +func New(name string) Path { + return Path{ + steps: []step{ + MapStep(name), + }, + } +} + +// AtSliceIndex returns a copied Path with a new SliceStep at the end. +func (s Path) AtSliceIndex(index int) Path { + newSteps := append(s.steps, SliceStep(index)) + s.steps = newSteps + return s +} + +// AtMapKey returns a copied Path with a new MapStep at the end. +func (s Path) AtMapKey(key string) Path { + newSteps := append(s.steps, MapStep(key)) + s.steps = newSteps + return s +} + +// Traverse returns the element found when traversing the given +// object using the specified Path. The object is an unmarshalled +// JSON object representing Terraform data. +// +// Traverse returns an error if the value specified by the Path +// is not found in the given object or if the given object does not +// conform to format of Terraform JSON data. +func Traverse(object any, attrPath Path) (any, error) { + _, ok := object.(map[string]any) + + if !ok { + return nil, fmt.Errorf("cannot convert given object to map[string]any") + } + + result := object + + for _, step := range attrPath.steps { + switch s := step.(type) { + case MapStep: + mapObj, ok := result.(map[string]any) + if !ok { + return nil, fmt.Errorf("path not found: cannot convert object at MapStep %s to map[string]any", string(s)) + } + result, ok = mapObj[string(s)] + if !ok { + return nil, fmt.Errorf("path not found: specified key %s not found in map", string(s)) + } + + case SliceStep: + sliceObj, ok := result.([]any) + if !ok { + return nil, fmt.Errorf("path not found: cannot convert object at SliceStep %d to []any", s) + } + + if int(s) >= len(sliceObj) { + return nil, fmt.Errorf("path not found: SliceStep index %d is out of range with slice length %d", s, len(sliceObj)) + } + + result = sliceObj[s] + } + } + + return result, nil +} diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go new file mode 100644 index 000000000..2abe7162a --- /dev/null +++ b/tfjsonpath/path_test.go @@ -0,0 +1,245 @@ +package tfjsonpath + +import ( + "encoding/json" + "strings" + "testing" +) + +func Test_Traverse_StringValue(t *testing.T) { + path := New("StringValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := "example" + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_NumberValue(t *testing.T) { + path := New("NumberValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := 0.0 + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_BooleanValue(t *testing.T) { + path := New("BooleanValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := false + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } +} + +func Test_Traverse_Array(t *testing.T) { + testCases := []struct { + path Path + expected any + }{ + { + path: New("Array").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New("Array").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New("Array").AtSliceIndex(2), + expected: "example2", + }, + { + path: New("Array").AtSliceIndex(3), + expected: nil, + }, + { + path: New("Array").AtSliceIndex(4), + expected: true, + }, + { + path: New("Array").AtSliceIndex(5).AtMapKey("NestedStringValue"), + expected: "example3", + }, + { + path: New("Array").AtSliceIndex(6).AtSliceIndex(0), + expected: true, + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestObject(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + +func Test_Traverse_Object(t *testing.T) { + testCases := []struct { + path Path + expected any + }{ + { + path: New("Object").AtMapKey("StringValue"), + expected: "example", + }, + { + path: New("Object").AtMapKey("NumberValue"), + expected: 0.0, + }, + { + path: New("Object").AtMapKey("BooleanValue"), + expected: false, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(0), + expected: 10.0, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(1), + expected: 15.2, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(2), + expected: "example2", + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(3), + expected: nil, + }, + { + path: New("Object").AtMapKey("ArrayValue").AtSliceIndex(4), + expected: true, + }, + { + path: New("Object").AtMapKey("ObjectValue").AtMapKey("NestedStringValue"), + expected: "example3", + }, + } + + for _, tc := range testCases { + actual, err := Traverse(createTestObject(), tc.path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + expected := tc.expected + + if expected != actual { + t.Errorf("Output %v not equal to expected %v", actual, expected) + } + } +} + +func Test_Traverse_ExpectError(t *testing.T) { + testCases := []struct { + path Path + expectedError func(err error) bool + }{ + // specified key not found + { + path: New("ObjectA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: specified key ObjectA not found in map`) + }, + }, + { + path: New("Object").AtMapKey("MapValueA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: specified key MapValueA not found in map`) + }, + }, + + // cannot convert object + { + path: New("StringValue").AtSliceIndex(0), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at SliceStep`) + }, + }, + { + path: New("StringValue").AtMapKey("MapKeyA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + }, + }, + { + path: New("Array").AtSliceIndex(0).AtMapKey("MapValueA"), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: cannot convert object at MapStep`) + }, + }, + + // index out of bounds + { + path: New("Array").AtSliceIndex(10), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 10 is out of range with slice length 7`) + }, + }, + { + path: New("Array").AtSliceIndex(7), + expectedError: func(err error) bool { + return strings.Contains(err.Error(), `path not found: SliceStep index 7 is out of range with slice length 7`) + }, + }, + } + + for _, tc := range testCases { + _, err := Traverse(createTestObject(), tc.path) + if err == nil { + t.Fatalf("Expected error but got none") + } + + if !tc.expectedError(err) { + t.Errorf("Unexpected error: %s", err) + } + } +} + +func createTestObject() any { + var jsonObject any + jsonstring := + `{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "Array": [10, 15.2, "example2", null, true, {"NestedStringValue": "example3"}, [true]], + "Object":{ + "StringValue": "example", + "NumberValue": 0, + "BooleanValue": false, + "ArrayValue": [10, 15.2, "example2", null, true], + "ObjectValue": { + "NestedStringValue": "example3" + } + } + }` + err := json.Unmarshal([]byte(jsonstring), &jsonObject) + if err != nil { + return nil + } + + return jsonObject +} diff --git a/tfjsonpath/step.go b/tfjsonpath/step.go new file mode 100644 index 000000000..8ebd8d6ce --- /dev/null +++ b/tfjsonpath/step.go @@ -0,0 +1,11 @@ +package tfjsonpath + +// step represents a traversal type indicating the underlying Go type +// representation for a Terraform JSON value. +type step interface{} + +// MapStep represents a traversal for map[string]any +type MapStep string + +// SliceStep represents a traversal for []any +type SliceStep int From 2d8c854c580ebaa2f16c0e0c7ac6978b89e0d548 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:05:53 -0400 Subject: [PATCH 02/13] Implement `ExpectSensitiveValue` plan check --- plancheck/expect_sensitive_value.go | 57 +++++ plancheck/expect_sensitive_value_test.go | 275 +++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 plancheck/expect_sensitive_value.go create mode 100644 plancheck/expect_sensitive_value_test.go diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go new file mode 100644 index 000000000..f488f2a19 --- /dev/null +++ b/plancheck/expect_sensitive_value.go @@ -0,0 +1,57 @@ +package plancheck + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectSensitiveValue{} + +type expectSensitiveValue struct { + resourceAddress string + attributePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var result error + + for _, rc := range req.Plan.ResourceChanges { + if e.resourceAddress != rc.Address { + continue + } + + result, err := tfjsonpath.Traverse(rc.Change.AfterSensitive, e.attributePath) + if err != nil { + resp.Error = err + return + } + + isSensitive, ok := result.(bool) + if !ok { + resp.Error = fmt.Errorf("path not found: cannot convert final value to bool") + return + } + + if !isSensitive { + resp.Error = fmt.Errorf("attribute at path is not sensitive") + return + } + } + + resp.Error = result +} + +// ExpectSensitiveValue returns a plan check that asserts that the specified attribute at the given resource has a sensitive value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of sensitive +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of sensitive values, such +// as marking whole maps as sensitive rather than individual element values. +func ExpectSensitiveValue(resourceAddress string, attributePath tfjsonpath.Path) PlanCheck { + return expectSensitiveValue{ + resourceAddress: resourceAddress, + attributePath: attributePath, + } +} diff --git a/plancheck/expect_sensitive_value_test.go b/plancheck/expect_sensitive_value_test.go new file mode 100644 index 000000000..ded3a586c --- /dev/null +++ b/plancheck/expect_sensitive_value_test.go @@ -0,0 +1,275 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +// TODO: change to r.Test +func Test_ExpectSensitiveValue_SensitiveStringAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_string_attribute = "test" + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveListAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_list_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveSetAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_set_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SensitiveMapAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + sensitive_map_attribute = { + key1 = "value1", + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("sensitive_map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_ListNestedBlock_SensitiveAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + list_nested_block_sensitive_attribute { + sensitive_list_nested_block_attribute = "sensitive-test" + list_nested_block_attribute = "test" + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("list_nested_block_sensitive_attribute").AtSliceIndex(0). + AtMapKey("sensitive_list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectSensitiveValue_SetNestedBlock_SensitiveAttribute(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_nested_block_sensitive_attribute { + sensitive_set_nested_block_attribute = "sensitive-test" + set_nested_block_attribute = "test" + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.one", + tfjsonpath.New("set_nested_block_sensitive_attribute")), + }, + }, + }, + }, + }) +} + +func testProviderSensitive() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test_resource": { + CreateContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + d.SetId("test") + return nil + }, + UpdateContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "sensitive_string_attribute": { + Sensitive: true, + Optional: true, + Type: schema.TypeString, + }, + "sensitive_list_attribute": { + Sensitive: true, + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "sensitive_set_attribute": { + Sensitive: true, + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "sensitive_map_attribute": { + Sensitive: true, + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "list_nested_block_sensitive_attribute": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + "sensitive_list_nested_block_attribute": { + Sensitive: true, + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "set_nested_block_sensitive_attribute": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "set_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + "sensitive_set_nested_block_attribute": { + Sensitive: true, + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + } +} From b273439db265bf60f0c4a6ed9e57e06f0b6c3bcd Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:06:03 -0400 Subject: [PATCH 03/13] Implement `ExpectUnknownValue` plan check --- plancheck/expect_unknown_value.go | 57 +++++ plancheck/expect_unknown_value_test.go | 327 +++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 plancheck/expect_unknown_value.go create mode 100644 plancheck/expect_unknown_value_test.go diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go new file mode 100644 index 000000000..533d33346 --- /dev/null +++ b/plancheck/expect_unknown_value.go @@ -0,0 +1,57 @@ +package plancheck + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ PlanCheck = expectUnknownValue{} + +type expectUnknownValue struct { + resourceAddress string + attributePath tfjsonpath.Path +} + +// CheckPlan implements the plan check logic. +func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + var result error + + for _, rc := range req.Plan.ResourceChanges { + if e.resourceAddress != rc.Address { + continue + } + + result, err := tfjsonpath.Traverse(rc.Change.AfterUnknown, e.attributePath) + if err != nil { + resp.Error = err + return + } + + isUnknown, ok := result.(bool) + if !ok { + resp.Error = fmt.Errorf("path not found: cannot convert final value to bool") + return + } + + if !isUnknown { + resp.Error = fmt.Errorf("attribute at path is known") + return + } + } + + resp.Error = result +} + +// ExpectUnknownValue returns a plan check that asserts that the specified attribute at the given resource has an unknown value. +// +// Due to implementation differences between the terraform-plugin-sdk and the terraform-plugin-framework, representation of unknown +// values may differ. For example, terraform-plugin-sdk based providers may have less precise representations of unknown values, such +// as marking whole maps as unknown rather than individual element values. +func ExpectUnknownValue(resourceAddress string, attributePath tfjsonpath.Path) PlanCheck { + return expectUnknownValue{ + resourceAddress: resourceAddress, + attributePath: attributePath, + } +} diff --git a/plancheck/expect_unknown_value_test.go b/plancheck/expect_unknown_value_test.go new file mode 100644 index 000000000..0b41aa105 --- /dev/null +++ b/plancheck/expect_unknown_value_test.go @@ -0,0 +1,327 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func Test_ExpectUnknownValue_StringAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + string_attribute = time_static.one.rfc3339 + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("string_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ListAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + list_attribute = ["value1", time_static.one.rfc3339] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("list_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_SetAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + set_attribute = ["value1", time_static.one.rfc3339] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_MapAttribute(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + map_attribute = { + key1 = "value1", + key2 = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("map_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ListNestedBlock(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + list_nested_block { + list_nested_block_attribute = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", + tfjsonpath.New("list_nested_block").AtSliceIndex(0).AtMapKey("list_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_SetNestedBlock(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + ExternalProviders: map[string]r.ExternalProvider{ + "time": { + Source: "registry.terraform.io/hashicorp/time", + }, + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: `resource "time_static" "one" {} + + resource "test_resource" "two" { + set_nested_block { + set_nested_block_attribute = time_static.one.rfc3339 + } + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", + tfjsonpath.New("set_nested_block").AtSliceIndex(0).AtMapKey("set_nested_block_attribute")), + }, + }, + }, + }, + }) +} + +func Test_ExpectUnknownValue_ExpectError_KnownValue(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" { + set_attribute = ["value1"] + } + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.one", tfjsonpath.New("set_attribute").AtSliceIndex(0)), + }, + }, + ExpectError: regexp.MustCompile(`attribute at path is known`), + }, + }, + }) +} + +func testProvider() *schema.Provider { + return &schema.Provider{ + ResourcesMap: map[string]*schema.Resource{ + "test_resource": { + CreateContext: func(_ context.Context, d *schema.ResourceData, _ interface{}) diag.Diagnostics { + d.SetId("test") + return nil + }, + UpdateContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + DeleteContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + ReadContext: func(_ context.Context, _ *schema.ResourceData, _ interface{}) diag.Diagnostics { + return nil + }, + Schema: map[string]*schema.Schema{ + "string_attribute": { + Optional: true, + Type: schema.TypeString, + }, + + "list_attribute": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "set_attribute": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + "root_map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + }, + + "list_nested_block": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "list_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + "set_nested_block": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "set_nested_block_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + } +} From 69f08714a4415c29632b0ae1c8930e262b1d2142 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:06:24 -0400 Subject: [PATCH 04/13] Update website documentation --- website/data/plugin-testing-nav-data.json | 18 +- .../testing/acceptance-tests/plan-checks.mdx | 12 +- .../testing/acceptance-tests/tfjson-paths.mdx | 689 ++++++++++++++++++ 3 files changed, 711 insertions(+), 8 deletions(-) create mode 100644 website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx diff --git a/website/data/plugin-testing-nav-data.json b/website/data/plugin-testing-nav-data.json index 5e6468792..1616b0935 100644 --- a/website/data/plugin-testing-nav-data.json +++ b/website/data/plugin-testing-nav-data.json @@ -1,7 +1,15 @@ [ - { "heading": "Testing" }, - { "title": "Overview", "path": "" }, - { "title": "Migrating from SDK", "path": "migrating" }, + { + "heading": "Testing" + }, + { + "title": "Overview", + "path": "" + }, + { + "title": "Migrating from SDK", + "path": "migrating" + }, { "title": "Acceptance Testing", "routes": [ @@ -28,6 +36,10 @@ { "title": "Sweepers", "path": "acceptance-tests/sweepers" + }, + { + "title": "Terraform JSON Paths", + "path": "acceptance-tests/tfjson-paths" } ] }, diff --git a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx index fa91a402f..fa6a93b38 100644 --- a/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx +++ b/website/docs/plugin/testing/acceptance-tests/plan-checks.mdx @@ -21,11 +21,13 @@ A **plan check** is a test assertion that inspects the plan file at a specific p The `terraform-plugin-testing` module provides a package [`plancheck`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck) with built-in plan checks for common use-cases: -| Check | Description | -|---|---| -| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | -| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | -| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given resource has the specified operation for apply. | +| Check | Description | +|---------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| [`plancheck.ExpectEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectEmptyPlan) | Asserts the entire plan has no operations for apply. | +| [`plancheck.ExpectNonEmptyPlan()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectNonEmptyPlan) | Asserts the entire plan contains at least one operation for apply. | +| [`plancheck.ExpectResourceAction(address, operation)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectResourceAction) | Asserts the given resource has the specified operation for apply. | +| [`plancheck.ExpectUnknownValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectUnknownValue) | Asserts the specified attribute at the given resource has an unknown value. | +| [`plancheck.ExpectSensitiveValue(address, path)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#ExpectSensitiveValue) | Asserts the specified attribute at the given resource has a sensitive value. | ### Examples using `plancheck.ExpectResourceAction` diff --git a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx new file mode 100644 index 000000000..d1ffb1252 --- /dev/null +++ b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx @@ -0,0 +1,689 @@ +--- +page_title: 'Plugin Development - Acceptance Testing: Terraform JSON Paths' +description: >- + How to implement attribute paths in the testing module. + Attribute paths represent the location of an attribute within Terraform JSON data. +--- + +# Terraform JSON Paths + +An exact location within Terraform JSON data is referred to as a Terraform JSON or tfjson path. + +## Usage + +Example uses in the testing module include: + +- The `ExpectUnknownValue()` and `ExpectSensitiveValue()` [built-in plan checks](/terraform/plugin/testing/acceptance-tests/plan-checks#built-in-plan-checks) for specifying an attribute to make the check assertion against. + +## Concepts + +Terraform JSON Paths are designed around the underlying Go types corresponding to the Terraform JSON implementation of a schema and schema-based data. The [terraform-json](https://pkg.go.dev/github.com/hashicorp/terraform-json) library serves as the de-facto documentation for Terraform JSON data. Paths are always absolute and start from the root, or top level, of a JSON object. + +Given the tree structure of JSON objects, descriptions of paths and their steps borrow certain hierarchy terminology such as parent and child. A parent path describes a path without one or more of the final steps of a given path, or put differently, a partial path closer to the root of the object. A child path describes a path with one or more additional steps beyond a given path, or put differently, a path containing the given path but further from the root of the object. + +## Building Paths + +The `terraform-plugin-testing` module implementation for tfjson paths is in the [`tfjsonpath` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath), with the [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) being the main provider developer interaction point. Call the [`tfjsonpath.Create()` function](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Create) with an element name at the root of the object to begin a path. + +Given the following JSON object + +```json +{ + "firstName": "John", + "lastName": "Doe", + "age": 18, + "streetAddress": "123 Terraform Dr.", + "phoneNumbers": [ + { "Mobile": "111-111-1111" }, + { "Home": "222-222-2222" } + ] +} +``` + +The call to `tfjsonpath.Create()` which matches the location of `firstName` string value is: + +```go +tfjsonpath.Create("firstName") +``` + +Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows for chaining method calls to construct a full path. + +The path which matches the location of the string value `"222-222-222"` is: + +```go +tfjsonpath.Create("phoneNumbers").atSliceIndex(1).AtMapKey("Home") +``` + +### Building Attribute Paths + +The most common usage of `tfjsonpath.Path` is to specify an attribute within Terraform JSON data. When used in this way, the root of the JSON object is the same as the root of a schema. + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for attribute implementations. Attribute types that cannot be traversed further are shown with N/A (not applicable). + +| Framework Attribute Type | SDKv2 Attribute Type | Child Path Method | +|---------------------------|----------------------|-------------------| +| `schema.BoolAttribute` | `schema.TypeBool` | N/A | +| `schema.Float64Attribute` | `schema.TypeFloat` | N/A | +| `schema.Int64Attribute` | `schema.TypeInt` | N/A | +| `schema.ListAttribute` | `schema.TypeList` | `AtSliceIndex()` | +| `schema.MapAttribute` | `schema.TypeMap` | `AtMapKey()` | +| `schema.NumberAttribute` | N/A | N/A | +| `schema.ObjectAttribute` | N/A | `AtMapKey()` | +| `schema.SetAttribute` | `schema.TypeSet` | `AtSliceIndex()` | +| `schema.StringAttribute` | `schema.TypeString` | N/A | + + +Given this example schema with a root attribute named `example_root_attribute`: + +```go +//Terraform Plugin Framework +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_root_attribute": schema.StringAttribute{ + Required: true, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "example_root_attribute": { + Type: schema.TypeString, + Required: true, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "example_root_attribute": "example-value" +} +``` + +The call to `tfjsonpath.Create()` which matches the location of `example_root_attribute` string value is: + +```go +tfjsonpath.Create("example_root_attribute") +``` + +For blocks, the beginning of a path is similarly defined. + +Given this example schema with a root block named `example_root_block`: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "example_root_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{/* ... */}, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "example_root_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{/* ... */}, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "example_root_block": [ + {} + ] +} +``` + +The call to `tfjsonpath.Create()` which matches the location of `example_root_block` slice value is: + +```go +tfjsonpath.Create("example_root_block") +``` + +### Building Aggregate Type Attribute Paths + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_map_attribute": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + }, + "root_list_attribute": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + }, + "root_set_attribute": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_map_attribute": { + Type: schema.TypeMap, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, + "root_list_attribute": { + Type: schema.TypeList, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, + "root_set_attribute": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Required: true, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_map_attribute": { + "example-key": "map-value" + }, + "root_list_attribute": [ + "list-value1", + "list-value2" + ], + "root_set_attribute": [ + "set-value1", + "set-value2" + ] +} +``` + +The path which matches the string value associated with the map key `example-key` of the `root_map_attribute` attribute is: + +```go +tfjsonpath.Create("root_map_attribute").AtMapKey("example-key") +``` + +The path which matches the string value `list-value1` in the `root_list_attribute` attribute is: + +```go +tfjsonpath.Create("root_list_attribute").AtSliceIndex(0) +``` + +The path which matches the string value `set-value2` in the `root_set_attribute` attribute is: + +```go +tfjsonpath.Create("root_set_attribute").AtSliceIndex(1) +``` + +Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +### Building Nested Attribute Paths + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for nested attributes. + +| Nested Attribute Type | Child Path Method | +|--------------------------------|-------------------| +| `schema.ListNestedAttribute` | `AtSliceIndex()` | +| `schema.MapNestedAttribute` | `AtMapKey()` | +| `schema.SetNestedAttribute` | `AtSliceIndex()` | +| `schema.SingleNestedAttribute` | `AtMapKey()` | + +Nested attributes eventually follow the same path rules as attributes at child paths, which follow the methods shown in the [Building Attribute Paths section](#building-attribute-paths). + +#### Building List Nested Attributes Paths + +An attribute that implements `schema.ListNestedAttribute` conceptually is a slice containing a map with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_list_attribute": schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_list_attribute": [ + { + "nested_string_attribute": "value" + } + ] +} +``` + +The path which matches the slice associated with the `root_list_attribute` attribute is: + +```go +tfjsonpath.Create("root_list_attribute") +``` + +The path which matches the first map in the slice associated with the `root_list_attribute` attribute is: + +```go +tfjsonpath.Create("root_list_attribute").AtSliceIndex(0) +``` + +The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_list_attribute` attribute is: + +```go +tfjsonpath.Create("root_list_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +``` + +#### Building Map Nested Attributes Paths + +An attribute that implements `schema.MapNestedAttribute` conceptually is a map containing values of maps with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_map_attribute": schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_map_attribute": { + "example-key" : { + "nested_string_attribute": "value" + } + } +} +``` + +The path which matches the map associated with the `root_map_attribute` attribute is: + +```go +tfjsonpath.Create("root_map_attribute") +``` + +The path which matches the `"example-key"` object in the map associated with the `root_map_attribute` attribute is: + +```go +tfjsonpath.Create("root_map_attribute").AtMapKey("example-key") +``` + +The path which matches the `nested_string_attribute` string value in a `"example-key"` object in the map associated with `root_map_attribute` attribute is: + +```go +tfjsonpath.Create("root_map_attribute").AtMapKey("example-key").AtMapKey("nested_string_attribute") +``` + +#### Building Set Nested Attributes Paths + +An attribute that implements `schema.SetNestedAttribute` conceptually is a slice containing maps with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_set_attribute": schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_set_attribute": [ + { + "nested_string_attribute": "value" + } + ] +} +``` + +The path which matches the set associated with the `root_set_attribute` attribute is: + +```go +tfjsonpath.Create("root_set_attribute") +``` + +The path which matches the first map in the slice associated with the `root_set_attribute` attribute is: + +```go +tfjsonpath.Create("root_set_attribute").AtSliceIndex(0) +``` + +Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_set_attribute` attribute is: + +```go +tfjsonpath.Create("root_set_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +``` + +#### Building Single Nested Attributes Paths + +An attribute that implements `schema.SingleNestedAttribute` conceptually is a map with attribute names as keys. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_grouped_attributes": schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "nested_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Required: true, + }, + }, +} +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_grouped_attributes": { + "nested_string_attribute": "value" + } +} +``` + +The path which matches the map associated with the `root_grouped_attributes` attribute is: + +```go +path.Create("root_grouped_attributes") +``` + +The path which matches the `nested_string_attribute` string value in the map associated with the `root_grouped_attributes` attribute is: + +```go +path.Create("root_grouped_attributes").AtMapKey("nested_string_attribute") +``` + +### Building Block Paths + +The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for blocks. + +| Block Type | Child Path Method | +|---------------------|-------------------| +| `ListNestedBlock` | `AtSliceIndex()` | +| `SetNestedBlock` | `AtSliceIndex()` | +| `SingleNestedBlock` | `AtMapKey()` | + +Blocks can implement nested blocks. Paths can continue to be built using the associated method with each level of the block type. + +Blocks eventually follow the same path rules as attributes at child paths, which follow the methods shown in the [Building Attribute Paths section](#building-attribute-paths). Blocks cannot contain nested attributes. + +#### Building List Block Paths + +A `ListNestedBlock` conceptually is a slice containing maps with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_list_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]schema.Block{ + "nested_list_block": schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "nested_block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_list_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block_string_attribute": { + Type: schema.TypeString, + Required: true, + }, + "nested_list_block": { + Type: schema.TypeList, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "nested_block_string_attribute": { + Type: schema.TypeString, + Required: true, + }, + }, + }, + }, + }, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_list_block": [ + { + "block_string_attribute": "value1", + "nested_list_block": [ + {"nested_block_string_attribute": "value2"} + ] + } + ] +} +``` + +The path which matches the slice associated with the `root_list_block` block is: + +```go +path.Create("root_list_block") +``` + +The path which matches the first map in the slice associated with the `root_list_block` block is: + +```go +path.Create("root_list_block").AtSliceIndex(0) +``` + +The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_list_block` block is: + +```go +path.Create("root_list_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +``` + +The path which matches the `nested_list_block` slice in the first object in the slice associated with `root_list_block` block is: + +```go +path.Create("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block") +``` + +The path which matches the `nested_block_string_attribute` string value in the first map in the slice associated with the `nested_list_block` slice in the first map in the slice associated with `root_list_block` block is: + +```go +path.Create("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block").AtSliceIndex(0).AtMapKey("nested_block_string_attribute") +``` + +#### Building Set Block Paths + +A `SetNestedBlock` conceptually is a slice containing maps with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_set_block": schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, +} + +//Terraform Plugin SDKv2 +Schema: map[string]*schema.Schema{ + "root_set_block": { + Type: schema.TypeSet, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "block_string_attribute": { + Type: schema.TypeString, + Optional: true, + }, + }, + }, + }, +}, +``` + +And the following Terraform JSON object representation of the state: +```json +{ + "root_set_block": [ + { + "block_string_attribute": "value1" + } + ] +} +``` + +The path which matches the slice associated with the `root_set_block` block is: + +```go +path.Create("root_set_block") +``` + + +The path which matches the first map in the slice associated with the `root_set_block` block is: + +```go +path.Create("root_set_block").AtSliceIndex(0) +``` + +Note that because sets are unordered in Terraform, the ordering of set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. + +The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_set_block` block is: + +```go +path.Create("root_set_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +```` + +#### Building Single Block Paths + +A `SingleNestedBlock` conceptually is a map with attribute or block names as keys. + +Given following schema example: + +```go +//Terraform Plugin Framework +schema.Schema{ + Blocks: map[string]schema.Block{ + "root_single_block": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]schema.Block{ + "nested_single_block": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "nested_block_string_attribute": schema.StringAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, +} +``` + +The path which matches the map associated with the `root_single_block` block is: + +```go +tfjsonpath.Create("root_single_block") +``` + +The path which matches the `block_string_attribute` string value in the map associated with `root_single_block` block is: + +```go +tfjsonpath.Create("root_single_block").AtMapKey("block_string_attribute") +``` + +The path which matches the `nested_single_block` map in the map associated with `root_single_block` block is: + +```go +tfjsonpath.Create("root_single_block").AtMapKey("nested_single_block") +``` + +The path which matches the `nested_block_string_attribute` string value in the map associated with the `nested_single_block` in the map associated with `root_single_block` block is: + +```go +tfjsonpath.Create("root_single_block").AtMapKey("nested_single_block").AtMapKey("nested_block_string_attribute") +``` From 6d008238312ae93bde5f42195e007f140b8c3e56 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:10:57 -0400 Subject: [PATCH 05/13] Resolve linting errors --- plancheck/expect_sensitive_value_test.go | 1 - tfjsonpath/path_test.go | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plancheck/expect_sensitive_value_test.go b/plancheck/expect_sensitive_value_test.go index ded3a586c..dc093a06b 100644 --- a/plancheck/expect_sensitive_value_test.go +++ b/plancheck/expect_sensitive_value_test.go @@ -15,7 +15,6 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) -// TODO: change to r.Test func Test_ExpectSensitiveValue_SensitiveStringAttribute(t *testing.T) { t.Parallel() diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go index 2abe7162a..a8d1ff0ee 100644 --- a/tfjsonpath/path_test.go +++ b/tfjsonpath/path_test.go @@ -7,6 +7,8 @@ import ( ) func Test_Traverse_StringValue(t *testing.T) { + t.Parallel() + path := New("StringValue") actual, err := Traverse(createTestObject(), path) @@ -21,6 +23,8 @@ func Test_Traverse_StringValue(t *testing.T) { } func Test_Traverse_NumberValue(t *testing.T) { + t.Parallel() + path := New("NumberValue") actual, err := Traverse(createTestObject(), path) @@ -35,6 +39,8 @@ func Test_Traverse_NumberValue(t *testing.T) { } func Test_Traverse_BooleanValue(t *testing.T) { + t.Parallel() + path := New("BooleanValue") actual, err := Traverse(createTestObject(), path) @@ -49,6 +55,8 @@ func Test_Traverse_BooleanValue(t *testing.T) { } func Test_Traverse_Array(t *testing.T) { + t.Parallel() + testCases := []struct { path Path expected any @@ -97,6 +105,8 @@ func Test_Traverse_Array(t *testing.T) { } func Test_Traverse_Object(t *testing.T) { + t.Parallel() + testCases := []struct { path Path expected any @@ -153,6 +163,8 @@ func Test_Traverse_Object(t *testing.T) { } func Test_Traverse_ExpectError(t *testing.T) { + t.Parallel() + testCases := []struct { path Path expectedError func(err error) bool From 201e1c31551058c7fa1e4d18ece03e5e99c89021 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 19 Jul 2023 18:18:16 -0400 Subject: [PATCH 06/13] Add copyright headers --- plancheck/expect_sensitive_value.go | 3 +++ plancheck/expect_unknown_value.go | 3 +++ tfjsonpath/doc.go | 3 +++ tfjsonpath/path.go | 3 +++ tfjsonpath/path_test.go | 3 +++ tfjsonpath/step.go | 3 +++ 6 files changed, 18 insertions(+) diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go index f488f2a19..6f5cd8740 100644 --- a/plancheck/expect_sensitive_value.go +++ b/plancheck/expect_sensitive_value.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package plancheck import ( diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go index 533d33346..220771ae2 100644 --- a/plancheck/expect_unknown_value.go +++ b/plancheck/expect_unknown_value.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package plancheck import ( diff --git a/tfjsonpath/doc.go b/tfjsonpath/doc.go index d5d5b344a..4b1a4923b 100644 --- a/tfjsonpath/doc.go +++ b/tfjsonpath/doc.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + // Package tfjsonpath implements terraform-json path functionality, which defines // traversals into Terraform JSON data, for testing purposes. package tfjsonpath diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go index 336d6ea5e..33f66649a 100644 --- a/tfjsonpath/path.go +++ b/tfjsonpath/path.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfjsonpath import ( diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go index a8d1ff0ee..87db6f9ab 100644 --- a/tfjsonpath/path_test.go +++ b/tfjsonpath/path_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfjsonpath import ( diff --git a/tfjsonpath/step.go b/tfjsonpath/step.go index 8ebd8d6ce..7b6813d60 100644 --- a/tfjsonpath/step.go +++ b/tfjsonpath/step.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package tfjsonpath // step represents a traversal type indicating the underlying Go type From b120febcb22a4fe1397e5ae2eb4c34fc680d2479 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 20 Jul 2023 16:17:57 -0400 Subject: [PATCH 07/13] Add test for `null` values --- tfjsonpath/path_test.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tfjsonpath/path_test.go b/tfjsonpath/path_test.go index 87db6f9ab..9a37c9918 100644 --- a/tfjsonpath/path_test.go +++ b/tfjsonpath/path_test.go @@ -57,6 +57,21 @@ func Test_Traverse_BooleanValue(t *testing.T) { } } +func Test_Traverse_NullValue(t *testing.T) { + t.Parallel() + + path := New("NullValue") + + actual, err := Traverse(createTestObject(), path) + if err != nil { + t.Errorf("Error traversing JSON object %s", err) + } + + if actual != nil { + t.Errorf("Output %v not equal to expected %v", actual, nil) + } +} + func Test_Traverse_Array(t *testing.T) { t.Parallel() @@ -240,6 +255,7 @@ func createTestObject() any { "StringValue": "example", "NumberValue": 0, "BooleanValue": false, + "NullValue": null, "Array": [10, 15.2, "example2", null, true, {"NestedStringValue": "example3"}, [true]], "Object":{ "StringValue": "example", From 111dbd50af013d3a560263e6ee0e0f732248956e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 20 Jul 2023 16:31:08 -0400 Subject: [PATCH 08/13] Reword bool assertion error --- plancheck/expect_sensitive_value.go | 2 +- plancheck/expect_unknown_value.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go index 6f5cd8740..a0db3d5c7 100644 --- a/plancheck/expect_sensitive_value.go +++ b/plancheck/expect_sensitive_value.go @@ -34,7 +34,7 @@ func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanReques isSensitive, ok := result.(bool) if !ok { - resp.Error = fmt.Errorf("path not found: cannot convert final value to bool") + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") return } diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go index 220771ae2..be642c92d 100644 --- a/plancheck/expect_unknown_value.go +++ b/plancheck/expect_unknown_value.go @@ -34,7 +34,7 @@ func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, isUnknown, ok := result.(bool) if !ok { - resp.Error = fmt.Errorf("path not found: cannot convert final value to bool") + resp.Error = fmt.Errorf("invalid path: the path value cannot be asserted as bool") return } From 751c2e2048024bbb1a64afd8c0ce66218879e980 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 20 Jul 2023 16:38:40 -0400 Subject: [PATCH 09/13] Add Changie Entries --- .changes/unreleased/FEATURES-20230720-163447.yaml | 6 ++++++ .changes/unreleased/FEATURES-20230720-163627.yaml | 6 ++++++ .changes/unreleased/FEATURES-20230720-163828.yaml | 6 ++++++ 3 files changed, 18 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20230720-163447.yaml create mode 100644 .changes/unreleased/FEATURES-20230720-163627.yaml create mode 100644 .changes/unreleased/FEATURES-20230720-163828.yaml diff --git a/.changes/unreleased/FEATURES-20230720-163447.yaml b/.changes/unreleased/FEATURES-20230720-163447.yaml new file mode 100644 index 000000000..4ebc80328 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163447.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'tfjsonpath: Introduced new `tfjsonpath` package which contains methods that + allow traversal of Terraform JSON data' +time: 2023-07-20T16:34:47.373683-04:00 +custom: + Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163627.yaml b/.changes/unreleased/FEATURES-20230720-163627.yaml new file mode 100644 index 000000000..d277734c4 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163627.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectUnknownValue` built-in plan check, which asserts that + a given attribute has an unknown value' +time: 2023-07-20T16:36:27.361538-04:00 +custom: + Issue: "154" diff --git a/.changes/unreleased/FEATURES-20230720-163828.yaml b/.changes/unreleased/FEATURES-20230720-163828.yaml new file mode 100644 index 000000000..8411348f3 --- /dev/null +++ b/.changes/unreleased/FEATURES-20230720-163828.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectSensitiveValue` built-in plan check, which asserts + that a given attribute has a sensitive value' +time: 2023-07-20T16:38:28.94511-04:00 +custom: + Issue: "154" From 3fa021dea61c474373b478fb8bbec2b2aee3016e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 21 Jul 2023 13:28:06 -0400 Subject: [PATCH 10/13] Correct documentation typos Co-authored-by: Brian Flad --- tfjsonpath/path.go | 14 ++-- .../testing/acceptance-tests/tfjson-paths.mdx | 66 +++++++++---------- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go index 33f66649a..09a91c770 100644 --- a/tfjsonpath/path.go +++ b/tfjsonpath/path.go @@ -14,7 +14,7 @@ import ( // The [terraform-json] library serves as the de facto documentation // for JSON format of Terraform data. // -// Use the Create() function to create a Path with an initial AtMapKey() step. +// Use the New() function to create a Path with an initial AtMapKey() step. // Path functionality follows a builder pattern, which allows for chaining method // calls to construct a full path. The available traversal steps after Path // creation are: @@ -22,10 +22,16 @@ import ( // - AtSliceIndex(): Step into a slice at a specific 0-based index // - AtMapKey(): Step into a map at a specific key // -// For example, to represent the first element in a top-level JSON array -// named "some_array": +// For example, to represent the first element of a JSON array +// underneath a "some_array" property of this JSON value: // -// path.Create("some_array").AtSliceIndex(0) +// { +// "some_array": [true] +// } +// +// The path code would be represented by: +// +// tfjsonpath.New("some_array").AtSliceIndex(0) // // [terraform-json]: (https://pkg.go.dev/github.com/hashicorp/terraform-json) type Path struct { diff --git a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx index d1ffb1252..123c94b0c 100644 --- a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx +++ b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx @@ -23,7 +23,7 @@ Given the tree structure of JSON objects, descriptions of paths and their steps ## Building Paths -The `terraform-plugin-testing` module implementation for tfjson paths is in the [`tfjsonpath` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath), with the [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) being the main provider developer interaction point. Call the [`tfjsonpath.Create()` function](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Create) with an element name at the root of the object to begin a path. +The `terraform-plugin-testing` module implementation for tfjson paths is in the [`tfjsonpath` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath), with the [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) being the main provider developer interaction point. Call the [`tfjsonpath.New()` function](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#New) with a property name at the root of the object to begin a path. Given the following JSON object @@ -40,10 +40,10 @@ Given the following JSON object } ``` -The call to `tfjsonpath.Create()` which matches the location of `firstName` string value is: +The call to `tfjsonpath.New()` which matches the location of `firstName` string value is: ```go -tfjsonpath.Create("firstName") +tfjsonpath.New("firstName") ``` Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows for chaining method calls to construct a full path. @@ -51,7 +51,7 @@ Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows The path which matches the location of the string value `"222-222-222"` is: ```go -tfjsonpath.Create("phoneNumbers").atSliceIndex(1).AtMapKey("Home") +tfjsonpath.New("phoneNumbers").AtSliceIndex(1).AtMapKey("Home") ``` ### Building Attribute Paths @@ -101,10 +101,10 @@ And the following Terraform JSON object representation of the state: } ``` -The call to `tfjsonpath.Create()` which matches the location of `example_root_attribute` string value is: +The call to `tfjsonpath.New()` which matches the location of `example_root_attribute` string value is: ```go -tfjsonpath.Create("example_root_attribute") +tfjsonpath.New("example_root_attribute") ``` For blocks, the beginning of a path is similarly defined. @@ -141,10 +141,10 @@ And the following Terraform JSON object representation of the state: } ``` -The call to `tfjsonpath.Create()` which matches the location of `example_root_block` slice value is: +The call to `tfjsonpath.New()` which matches the location of `example_root_block` slice value is: ```go -tfjsonpath.Create("example_root_block") +tfjsonpath.New("example_root_block") ``` ### Building Aggregate Type Attribute Paths @@ -216,19 +216,19 @@ And the following Terraform JSON object representation of the state: The path which matches the string value associated with the map key `example-key` of the `root_map_attribute` attribute is: ```go -tfjsonpath.Create("root_map_attribute").AtMapKey("example-key") +tfjsonpath.New("root_map_attribute").AtMapKey("example-key") ``` The path which matches the string value `list-value1` in the `root_list_attribute` attribute is: ```go -tfjsonpath.Create("root_list_attribute").AtSliceIndex(0) +tfjsonpath.New("root_list_attribute").AtSliceIndex(0) ``` The path which matches the string value `set-value2` in the `root_set_attribute` attribute is: ```go -tfjsonpath.Create("root_set_attribute").AtSliceIndex(1) +tfjsonpath.New("root_set_attribute").AtSliceIndex(1) ``` Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. @@ -283,19 +283,19 @@ And the following Terraform JSON object representation of the state: The path which matches the slice associated with the `root_list_attribute` attribute is: ```go -tfjsonpath.Create("root_list_attribute") +tfjsonpath.New("root_list_attribute") ``` The path which matches the first map in the slice associated with the `root_list_attribute` attribute is: ```go -tfjsonpath.Create("root_list_attribute").AtSliceIndex(0) +tfjsonpath.New("root_list_attribute").AtSliceIndex(0) ``` The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_list_attribute` attribute is: ```go -tfjsonpath.Create("root_list_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +tfjsonpath.New("root_list_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") ``` #### Building Map Nested Attributes Paths @@ -335,19 +335,19 @@ And the following Terraform JSON object representation of the state: The path which matches the map associated with the `root_map_attribute` attribute is: ```go -tfjsonpath.Create("root_map_attribute") +tfjsonpath.New("root_map_attribute") ``` The path which matches the `"example-key"` object in the map associated with the `root_map_attribute` attribute is: ```go -tfjsonpath.Create("root_map_attribute").AtMapKey("example-key") +tfjsonpath.New("root_map_attribute").AtMapKey("example-key") ``` The path which matches the `nested_string_attribute` string value in a `"example-key"` object in the map associated with `root_map_attribute` attribute is: ```go -tfjsonpath.Create("root_map_attribute").AtMapKey("example-key").AtMapKey("nested_string_attribute") +tfjsonpath.New("root_map_attribute").AtMapKey("example-key").AtMapKey("nested_string_attribute") ``` #### Building Set Nested Attributes Paths @@ -387,13 +387,13 @@ And the following Terraform JSON object representation of the state: The path which matches the set associated with the `root_set_attribute` attribute is: ```go -tfjsonpath.Create("root_set_attribute") +tfjsonpath.New("root_set_attribute") ``` The path which matches the first map in the slice associated with the `root_set_attribute` attribute is: ```go -tfjsonpath.Create("root_set_attribute").AtSliceIndex(0) +tfjsonpath.New("root_set_attribute").AtSliceIndex(0) ``` Note that because Sets are unordered in Terraform, the ordering of Set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. @@ -401,7 +401,7 @@ Note that because Sets are unordered in Terraform, the ordering of Set elements The path which matches the `nested_string_attribute` map key in the first map in the slice associated with `root_set_attribute` attribute is: ```go -tfjsonpath.Create("root_set_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") +tfjsonpath.New("root_set_attribute").AtSliceIndex(0).AtMapKey("nested_string_attribute") ``` #### Building Single Nested Attributes Paths @@ -437,13 +437,13 @@ And the following Terraform JSON object representation of the state: The path which matches the map associated with the `root_grouped_attributes` attribute is: ```go -path.Create("root_grouped_attributes") +tfjsonpath.New("root_grouped_attributes") ``` The path which matches the `nested_string_attribute` string value in the map associated with the `root_grouped_attributes` attribute is: ```go -path.Create("root_grouped_attributes").AtMapKey("nested_string_attribute") +tfjsonpath.New("root_grouped_attributes").AtMapKey("nested_string_attribute") ``` ### Building Block Paths @@ -537,31 +537,31 @@ And the following Terraform JSON object representation of the state: The path which matches the slice associated with the `root_list_block` block is: ```go -path.Create("root_list_block") +tfjsonpath.New("root_list_block") ``` The path which matches the first map in the slice associated with the `root_list_block` block is: ```go -path.Create("root_list_block").AtSliceIndex(0) +tfjsonpath.New("root_list_block").AtSliceIndex(0) ``` The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_list_block` block is: ```go -path.Create("root_list_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("block_string_attribute") ``` The path which matches the `nested_list_block` slice in the first object in the slice associated with `root_list_block` block is: ```go -path.Create("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block") +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block") ``` The path which matches the `nested_block_string_attribute` string value in the first map in the slice associated with the `nested_list_block` slice in the first map in the slice associated with `root_list_block` block is: ```go -path.Create("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block").AtSliceIndex(0).AtMapKey("nested_block_string_attribute") +tfjsonpath.New("root_list_block").AtSliceIndex(0).AtMapKey("nested_list_block").AtSliceIndex(0).AtMapKey("nested_block_string_attribute") ``` #### Building Set Block Paths @@ -616,14 +616,14 @@ And the following Terraform JSON object representation of the state: The path which matches the slice associated with the `root_set_block` block is: ```go -path.Create("root_set_block") +tfjsonpath.New("root_set_block") ``` The path which matches the first map in the slice associated with the `root_set_block` block is: ```go -path.Create("root_set_block").AtSliceIndex(0) +tfjsonpath.New("root_set_block").AtSliceIndex(0) ``` Note that because sets are unordered in Terraform, the ordering of set elements in the Terraform JSON data is not guaranteed to be the same as the ordering in the configuration. @@ -631,7 +631,7 @@ Note that because sets are unordered in Terraform, the ordering of set elements The path which matches the `block_string_attribute` string value in the first map in the slice associated with `root_set_block` block is: ```go -path.Create("root_set_block").AtSliceIndex(0).AtMapKey("block_string_attribute") +tfjsonpath.New("root_set_block").AtSliceIndex(0).AtMapKey("block_string_attribute") ```` #### Building Single Block Paths @@ -673,17 +673,17 @@ tfjsonpath.Create("root_single_block") The path which matches the `block_string_attribute` string value in the map associated with `root_single_block` block is: ```go -tfjsonpath.Create("root_single_block").AtMapKey("block_string_attribute") +tfjsonpath.New("root_single_block").AtMapKey("block_string_attribute") ``` The path which matches the `nested_single_block` map in the map associated with `root_single_block` block is: ```go -tfjsonpath.Create("root_single_block").AtMapKey("nested_single_block") +tfjsonpath.New("root_single_block").AtMapKey("nested_single_block") ``` The path which matches the `nested_block_string_attribute` string value in the map associated with the `nested_single_block` in the map associated with `root_single_block` block is: ```go -tfjsonpath.Create("root_single_block").AtMapKey("nested_single_block").AtMapKey("nested_block_string_attribute") +tfjsonpath.New("root_single_block").AtMapKey("nested_single_block").AtMapKey("nested_block_string_attribute") ``` From 9aa2fc5d0ed7c69f04593bf3b1cea1775fd2ec32 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 21 Jul 2023 13:40:00 -0400 Subject: [PATCH 11/13] Add error for when resource is not found --- plancheck/expect_sensitive_value.go | 6 ++++-- plancheck/expect_sensitive_value_test.go | 26 ++++++++++++++++++++++++ plancheck/expect_unknown_value.go | 6 ++++-- plancheck/expect_unknown_value_test.go | 25 +++++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go index a0db3d5c7..436940957 100644 --- a/plancheck/expect_sensitive_value.go +++ b/plancheck/expect_sensitive_value.go @@ -19,7 +19,6 @@ type expectSensitiveValue struct { // CheckPlan implements the plan check logic. func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { - var result error for _, rc := range req.Plan.ResourceChanges { if e.resourceAddress != rc.Address { @@ -42,9 +41,12 @@ func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanReques resp.Error = fmt.Errorf("attribute at path is not sensitive") return } + + return } - resp.Error = result + resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) + return } // ExpectSensitiveValue returns a plan check that asserts that the specified attribute at the given resource has a sensitive value. diff --git a/plancheck/expect_sensitive_value_test.go b/plancheck/expect_sensitive_value_test.go index dc093a06b..9cddc9737 100644 --- a/plancheck/expect_sensitive_value_test.go +++ b/plancheck/expect_sensitive_value_test.go @@ -5,6 +5,7 @@ package plancheck_test import ( "context" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -186,6 +187,31 @@ func Test_ExpectSensitiveValue_SetNestedBlock_SensitiveAttribute(t *testing.T) { }) } +func Test_ExpectSensitiveValue_ExpectError_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProviderSensitive(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" {} + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectSensitiveValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`test_resource.two - Resource not found in plan ResourceChanges`), + }, + }, + }) +} + func testProviderSensitive() *schema.Provider { return &schema.Provider{ ResourcesMap: map[string]*schema.Resource{ diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go index be642c92d..7882af43b 100644 --- a/plancheck/expect_unknown_value.go +++ b/plancheck/expect_unknown_value.go @@ -19,7 +19,6 @@ type expectUnknownValue struct { // CheckPlan implements the plan check logic. func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { - var result error for _, rc := range req.Plan.ResourceChanges { if e.resourceAddress != rc.Address { @@ -42,9 +41,12 @@ func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp.Error = fmt.Errorf("attribute at path is known") return } + + return } - resp.Error = result + resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) + return } // ExpectUnknownValue returns a plan check that asserts that the specified attribute at the given resource has an unknown value. diff --git a/plancheck/expect_unknown_value_test.go b/plancheck/expect_unknown_value_test.go index 0b41aa105..31798b9d0 100644 --- a/plancheck/expect_unknown_value_test.go +++ b/plancheck/expect_unknown_value_test.go @@ -244,6 +244,31 @@ func Test_ExpectUnknownValue_ExpectError_KnownValue(t *testing.T) { }) } +func Test_ExpectUnknownValue_ExpectError_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + ProviderFactories: map[string]func() (*schema.Provider, error){ + "test": func() (*schema.Provider, error) { //nolint:unparam // required signature + return testProvider(), nil + }, + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource" "one" {} + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectUnknownValue("test_resource.two", tfjsonpath.New("set_attribute")), + }, + }, + ExpectError: regexp.MustCompile(`test_resource.two - Resource not found in plan ResourceChanges`), + }, + }, + }) +} + func testProvider() *schema.Provider { return &schema.Provider{ ResourcesMap: map[string]*schema.Resource{ From 41078d925669867317df3b99078635442f5ec2ef Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 21 Jul 2023 14:16:47 -0400 Subject: [PATCH 12/13] Add additional wording to attribute path subsections --- .../testing/acceptance-tests/tfjson-paths.mdx | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx index 123c94b0c..e169026d2 100644 --- a/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx +++ b/website/docs/plugin/testing/acceptance-tests/tfjson-paths.mdx @@ -29,21 +29,21 @@ Given the following JSON object ```json { - "firstName": "John", - "lastName": "Doe", + "first_name": "John", + "last_name": "Doe", "age": 18, - "streetAddress": "123 Terraform Dr.", - "phoneNumbers": [ - { "Mobile": "111-111-1111" }, - { "Home": "222-222-2222" } + "street_address": "123 Terraform Dr.", + "phone_numbers": [ + { "mobile": "111-111-1111" }, + { "home": "222-222-2222" } ] } ``` -The call to `tfjsonpath.New()` which matches the location of `firstName` string value is: +The call to `tfjsonpath.New()` which matches the location of `first_name` string value is: ```go -tfjsonpath.New("firstName") +tfjsonpath.New("first_name") ``` Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows for chaining method calls to construct a full path. @@ -51,12 +51,13 @@ Once a `tfjsonpath.Path` is started, it supports a builder pattern, which allows The path which matches the location of the string value `"222-222-222"` is: ```go -tfjsonpath.New("phoneNumbers").AtSliceIndex(1).AtMapKey("Home") +tfjsonpath.New("phone_numbers").AtSliceIndex(1).AtMapKey("home") ``` -### Building Attribute Paths - The most common usage of `tfjsonpath.Path` is to specify an attribute within Terraform JSON data. When used in this way, the root of the JSON object is the same as the root of a schema. +The follow sections show how to build attribute paths for [primitive attributes](#building-attribute-paths), [aggregate attributes](#building-aggregate-type-attribute-paths), [nested attributes](#building-nested-attribute-paths), and [blocks](#building-block-paths). + +### Building Attribute Paths The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for attribute implementations. Attribute types that cannot be traversed further are shown with N/A (not applicable). @@ -237,12 +238,12 @@ Note that because Sets are unordered in Terraform, the ordering of Set elements The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for nested attributes. -| Nested Attribute Type | Child Path Method | -|--------------------------------|-------------------| -| `schema.ListNestedAttribute` | `AtSliceIndex()` | -| `schema.MapNestedAttribute` | `AtMapKey()` | -| `schema.SetNestedAttribute` | `AtSliceIndex()` | -| `schema.SingleNestedAttribute` | `AtMapKey()` | +| Nested Attribute Type | Child Path Method(s) | +|--------------------------------|-----------------------------| +| `schema.ListNestedAttribute` | `AtSliceIndex().AtMapKey()` | +| `schema.MapNestedAttribute` | `AtMapKey().AtMapKey()` | +| `schema.SetNestedAttribute` | `AtSliceIndex().AtMapKey()` | +| `schema.SingleNestedAttribute` | `AtMapKey()` | Nested attributes eventually follow the same path rules as attributes at child paths, which follow the methods shown in the [Building Attribute Paths section](#building-attribute-paths). @@ -450,11 +451,11 @@ tfjsonpath.New("root_grouped_attributes").AtMapKey("nested_string_attribute") The following table shows the different [`tfjsonpath.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/tfjsonpath#Path) methods associated with building paths for blocks. -| Block Type | Child Path Method | -|---------------------|-------------------| -| `ListNestedBlock` | `AtSliceIndex()` | -| `SetNestedBlock` | `AtSliceIndex()` | -| `SingleNestedBlock` | `AtMapKey()` | +| Block Type | Child Path Method(s) | +|---------------------|-----------------------------| +| `ListNestedBlock` | `AtSliceIndex().AtMapKey()` | +| `SetNestedBlock` | `AtSliceIndex().AtMapKey()` | +| `SingleNestedBlock` | `AtMapKey()` | Blocks can implement nested blocks. Paths can continue to be built using the associated method with each level of the block type. @@ -667,7 +668,7 @@ schema.Schema{ The path which matches the map associated with the `root_single_block` block is: ```go -tfjsonpath.Create("root_single_block") +tfjsonpath.New("root_single_block") ``` The path which matches the `block_string_attribute` string value in the map associated with `root_single_block` block is: From 8ca5d893ca73ac99b486692a0a59efb9f453420c Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 21 Jul 2023 14:17:54 -0400 Subject: [PATCH 13/13] Resolve linting errors --- plancheck/expect_sensitive_value.go | 1 - plancheck/expect_unknown_value.go | 1 - tfjsonpath/path.go | 10 +++++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/plancheck/expect_sensitive_value.go b/plancheck/expect_sensitive_value.go index 436940957..b6c3a5194 100644 --- a/plancheck/expect_sensitive_value.go +++ b/plancheck/expect_sensitive_value.go @@ -46,7 +46,6 @@ func (e expectSensitiveValue) CheckPlan(ctx context.Context, req CheckPlanReques } resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) - return } // ExpectSensitiveValue returns a plan check that asserts that the specified attribute at the given resource has a sensitive value. diff --git a/plancheck/expect_unknown_value.go b/plancheck/expect_unknown_value.go index 7882af43b..1569397c2 100644 --- a/plancheck/expect_unknown_value.go +++ b/plancheck/expect_unknown_value.go @@ -46,7 +46,6 @@ func (e expectUnknownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, } resp.Error = fmt.Errorf("%s - Resource not found in plan ResourceChanges", e.resourceAddress) - return } // ExpectUnknownValue returns a plan check that asserts that the specified attribute at the given resource has an unknown value. diff --git a/tfjsonpath/path.go b/tfjsonpath/path.go index 09a91c770..ef3930dfb 100644 --- a/tfjsonpath/path.go +++ b/tfjsonpath/path.go @@ -25,13 +25,13 @@ import ( // For example, to represent the first element of a JSON array // underneath a "some_array" property of this JSON value: // -// { -// "some_array": [true] -// } +// { +// "some_array": [true] +// } // -// The path code would be represented by: +// The path code would be represented by: // -// tfjsonpath.New("some_array").AtSliceIndex(0) +// tfjsonpath.New("some_array").AtSliceIndex(0) // // [terraform-json]: (https://pkg.go.dev/github.com/hashicorp/terraform-json) type Path struct {