diff --git a/.changes/v1.15/ENHANCEMENTS-20260223-130341.yaml b/.changes/v1.15/ENHANCEMENTS-20260223-130341.yaml new file mode 100644 index 000000000000..1393968cfc27 --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20260223-130341.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: "mocking: computed values are now correctly filled for nested list, set, and map attributes when using mock and override data in tests." +time: 2026-02-23T13:03:41.913383+01:00 +custom: + Issue: "37939" diff --git a/internal/command/test_test.go b/internal/command/test_test.go index 92adc8573874..9dabeb4381d5 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -292,7 +292,7 @@ func TestTest_Runs(t *testing.T) { code: 0, }, "mocking": { - expectedOut: []string{"10 passed, 0 failed."}, + expectedOut: []string{"11 passed, 0 failed."}, code: 0, }, "mocking-invalid": { @@ -377,12 +377,14 @@ func TestTest_Runs(t *testing.T) { code: 0, }, "write-only-attributes-mocked": { - expectedOut: []string{"1 passed, 0 failed."}, - code: 0, + expectedOut: []string{"0 passed, 1 failed."}, + expectedErr: []string{"Test assertion failed", "wrong value"}, + code: 1, }, "write-only-attributes-overridden": { - expectedOut: []string{"1 passed, 0 failed."}, - code: 0, + expectedOut: []string{"0 passed, 1 failed."}, + expectedErr: []string{"Test assertion failed", "wrong value"}, + code: 1, }, "with-default-variables": { args: []string{"-var=input_two=universe"}, @@ -5674,6 +5676,118 @@ func TestTest_TeardownOrder(t *testing.T) { } } +func TestTest_OverrideDataMocking(t *testing.T) { + tcs := map[string]struct { + dir string + expectedCode int + expectedStdout string + expectedStderr string + }{ + "plain_list_attribute": { + dir: "override_data_list_attribute", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "nested_list_attribute": { + dir: "override_data_nested_list_attribute", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "nested_list_attribute_with_object_value": { + dir: "override_data_nested_list_attribute_object", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "nested_list_attribute_with_invalid_type_value": { + dir: "override_data_nested_list_attribute_invalid_type", + expectedCode: 1, + expectedStderr: "incompatible types; expected list of object, found", + }, + "list_attribute_with_partial_element_values": { + dir: "override_data_list_attribute_partial_elements", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "set_attribute_with_partial_element_values": { + dir: "override_data_complex_set_attribute_partial_elements", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "nested_set_attribute_with_object_value": { + dir: "override_data_complex_nested_set_attribute_object", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + "nested_list_attribute_with_partial_element_values": { + dir: "override_data_nested_list_attribute_partial_elements", + expectedCode: 0, + expectedStdout: "1 passed, 0 failed.", + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("test", tc.dir)), td) + t.Chdir(td) + + provider := testing_command.NewProvider(nil) + providerSource, closeFn := newMockProviderSource(t, map[string][]string{ + "test": {"1.0.0"}, + }) + defer closeFn() + + streams, done := terminal.StreamsForTesting(t) + view := views.NewView(streams) + ui := new(cli.MockUi) + + meta := Meta{ + testingOverrides: metaOverridesForProvider(provider.Provider), + Ui: ui, + View: view, + Streams: streams, + ProviderSource: providerSource, + } + + init := &InitCommand{ + Meta: meta, + } + + if code := init.Run(nil); code != 0 { + output := done(t) + t.Fatalf("expected init status code 0 but got %d: %s", code, output.All()) + } + + streams, done = terminal.StreamsForTesting(t) + meta.Streams = streams + meta.View = views.NewView(streams) + + c := &TestCommand{ + Meta: meta, + } + + code := c.Run([]string{"-no-color"}) + output := done(t) + + if code != tc.expectedCode { + t.Fatalf("expected status code %d but got %d:\n\n%s", tc.expectedCode, code, output.All()) + } + + if tc.expectedStdout != "" && !strings.Contains(output.Stdout(), tc.expectedStdout) { + t.Errorf("expected stdout to contain %q but got:\n\nstdout:\n%s\nstderr:\n%s", tc.expectedStdout, output.Stdout(), output.Stderr()) + } + + if tc.expectedStderr != "" && !strings.Contains(output.Stderr(), tc.expectedStderr) { + t.Errorf("expected stderr to contain %q but got:\n\nstdout:\n%s\nstderr:\n%s", tc.expectedStderr, output.Stdout(), output.Stderr()) + } + + if tc.expectedCode == 0 && output.Stderr() != "" { + t.Errorf("unexpected stderr output:\n%s", output.Stderr()) + } + }) + } +} + // testModuleInline takes a map of path -> config strings and yields a config // structure with those files loaded from disk func testModuleInline(t *testing.T, sources map[string]string) (*configs.Config, string, func()) { diff --git a/internal/command/testdata/test/mocking/main.tf b/internal/command/testdata/test/mocking/main.tf index 49506e06c38f..9d0180ca1b24 100644 --- a/internal/command/testdata/test/mocking/main.tf +++ b/internal/command/testdata/test/mocking/main.tf @@ -24,12 +24,17 @@ variable "child_instances" { resource "test_resource" "primary" { provider = test.primary - count = var.instances + count = var.instances +} + +resource "test_complex_resource" "primary" { + provider = test.primary + count = var.instances } resource "test_resource" "secondary" { provider = test.secondary - count = var.instances + count = var.instances } module "child" { @@ -38,7 +43,7 @@ module "child" { source = "./child" providers = { - test.primary = test.primary + test.primary = test.primary test.secondary = test.secondary } diff --git a/internal/command/testdata/test/mocking/tests/nested_mocked.tftest.hcl b/internal/command/testdata/test/mocking/tests/nested_mocked.tftest.hcl new file mode 100644 index 000000000000..a59ba8e3c350 --- /dev/null +++ b/internal/command/testdata/test/mocking/tests/nested_mocked.tftest.hcl @@ -0,0 +1,63 @@ +mock_provider "test" { + alias = "primary" + + mock_resource "test_complex_resource" { + defaults = { + id = "aaaa" + list_value = [{name = "first"}, {name = "second"}] + nested_list_value = [{name = "first"}, {name = "second"}] + set_value = [{name = "first"}, {name = "second"}] + nested_set_value = [{name = "first"}, {name = "second"}] + map_value = tomap({ + "key1": { + name = "first" + }, + "key2": { + name = "third" + } + }) + nested_map_value = tomap({ + "key1": { + name = "first" + }, + "key2": { + name = "third" + } + }) + } + } +} + +variables { + instances = 1 + child_instances = 0 +} + + +run "test" { + + assert { + condition = test_complex_resource.primary[0].id == "aaaa" + error_message = "did not apply mocks" + } + + assert { + condition = test_complex_resource.primary[0].list_value[0].name == "first" + error_message = "did not apply mocks" + } + + assert { + condition = test_complex_resource.primary[0].nested_list_value[0].name == "first" + error_message = "did not apply mocks" + } + + assert { + condition = test_complex_resource.primary[0].map_value["key1"].name == "first" + error_message = "did not apply mocks" + } + + assert { + condition = test_complex_resource.primary[0].nested_map_value["key1"].name == "first" + error_message = "did not apply mocks" + } +} diff --git a/internal/command/testdata/test/override_data_attribute/list.tftest.hcl b/internal/command/testdata/test/override_data_attribute/list.tftest.hcl new file mode 100644 index 000000000000..a0518942621c --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/list.tftest.hcl @@ -0,0 +1,31 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + list_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_list_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.list_value) == 2 + error_message = "Expected list_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.list_value)}" + } + + assert { + condition = data.test_complex_data_source.datasource.list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } +} diff --git a/internal/command/testdata/test/override_data_attribute/main.tf b/internal/command/testdata/test/override_data_attribute/main.tf new file mode 100644 index 000000000000..bbea87bd356d --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_complex_data_source" "datasource" { + id = "resource" +} + +output "set_value" { + value = data.test_complex_data_source.datasource.set_value +} diff --git a/internal/command/testdata/test/override_data_attribute/map.tftest.hcl b/internal/command/testdata/test/override_data_attribute/map.tftest.hcl new file mode 100644 index 000000000000..02d7b8311751 --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/map.tftest.hcl @@ -0,0 +1,31 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + map_value = { + "key1" = { + name = "first" + value = "one" + } + "key2" = { + name = "second" + value = "two" + } + } + } +} + +run "test_override_data_map_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.map_value) == 2 + error_message = "Expected map_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.map_value)}" + } + + assert { + condition = data.test_complex_data_source.datasource.map_value["key1"].name == "first" + error_message = "Expected key1 name to be 'first'" + } +} diff --git a/internal/command/testdata/test/override_data_attribute/nested_list.tftest.hcl b/internal/command/testdata/test/override_data_attribute/nested_list.tftest.hcl new file mode 100644 index 000000000000..a02327798074 --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/nested_list.tftest.hcl @@ -0,0 +1,31 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + nested_list_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_nested_list_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.nested_list_value) == 2 + error_message = "Expected nested_list_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.nested_list_value)}" + } + + assert { + condition = data.test_complex_data_source.datasource.nested_list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } +} diff --git a/internal/command/testdata/test/override_data_attribute/nested_map.tftest.hcl b/internal/command/testdata/test/override_data_attribute/nested_map.tftest.hcl new file mode 100644 index 000000000000..844f18adf686 --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/nested_map.tftest.hcl @@ -0,0 +1,32 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + nested_map_value = { + "key1" = { + name = "first" + value = "one" + } + "key2" = { + name = "second" + value = "two" + } + } + } +} + +run "test_override_data_nested_map_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.nested_map_value) == 2 + error_message = "Expected nested_map_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.nested_map_value)}" + } + + assert { + condition = data.test_complex_data_source.datasource.nested_map_value["key1"].name == "first" + error_message = "Expected key1 name to be 'first'" + } + +} diff --git a/internal/command/testdata/test/override_data_attribute/nested_set.tftest.hcl b/internal/command/testdata/test/override_data_attribute/nested_set.tftest.hcl new file mode 100644 index 000000000000..ef4dc0638749 --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/nested_set.tftest.hcl @@ -0,0 +1,31 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + nested_set_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_nested_set_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.nested_set_value) == 2 + error_message = "Expected nested_set_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.nested_set_value)}" + } + + assert { + condition = contains([for item in data.test_complex_data_source.datasource.nested_set_value : item.name], "first") + error_message = "Expected nested_set_value to contain an element with name 'first'" + } +} diff --git a/internal/command/testdata/test/override_data_attribute/set.tftest.hcl b/internal/command/testdata/test/override_data_attribute/set.tftest.hcl new file mode 100644 index 000000000000..cac115aba854 --- /dev/null +++ b/internal/command/testdata/test/override_data_attribute/set.tftest.hcl @@ -0,0 +1,31 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + set_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_set_attribute" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.set_value) == 2 + error_message = "Expected set_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.set_value)}" + } + + assert { + condition = contains([for item in data.test_complex_data_source.datasource.set_value : item.name], "first") + error_message = "Expected set_value to contain an element with name 'first'" + } +} diff --git a/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tf b/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tf new file mode 100644 index 000000000000..5174b78b954f --- /dev/null +++ b/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_complex_data_source" "datasource" { + id = "resource" +} + +output "nested_set_value" { + value = data.test_complex_data_source.datasource.nested_set_value +} diff --git a/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tftest.hcl b/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tftest.hcl new file mode 100644 index 000000000000..f18d29348530 --- /dev/null +++ b/internal/command/testdata/test/override_data_complex_nested_set_attribute_object/main.tftest.hcl @@ -0,0 +1,19 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + nested_set_value = { + name = "shared" + } + } +} + +run "test_override_data_complex_nested_set_attribute_object" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.nested_set_value) == 0 + error_message = "Expected nested_set_value to be empty when overridden with an object" + } +} diff --git a/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tf b/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tf new file mode 100644 index 000000000000..bbea87bd356d --- /dev/null +++ b/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_complex_data_source" "datasource" { + id = "resource" +} + +output "set_value" { + value = data.test_complex_data_source.datasource.set_value +} diff --git a/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tftest.hcl b/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tftest.hcl new file mode 100644 index 000000000000..14f89f2de39c --- /dev/null +++ b/internal/command/testdata/test/override_data_complex_set_attribute_partial_elements/main.tftest.hcl @@ -0,0 +1,40 @@ +provider "test" {} + +override_data { + target = data.test_complex_data_source.datasource + values = { + set_value = [ + { + name = "first" + }, + { + value = "two" + }, + ] + } +} + +run "test_override_data_complex_set_attribute_partial_elements" { + command = plan + + assert { + condition = length(data.test_complex_data_source.datasource.set_value) == 2 + error_message = "Expected set_value to have 2 elements, got ${length(data.test_complex_data_source.datasource.set_value)}" + } + + assert { + condition = length([ + for item in data.test_complex_data_source.datasource.set_value : item + if item.name == "first" && item.value != null + ]) == 1 + error_message = "Expected one set_value element with name 'first' and a filled-in value" + } + + assert { + condition = length([ + for item in data.test_complex_data_source.datasource.set_value : item + if item.value == "two" && item.name != null + ]) == 1 + error_message = "Expected one set_value element with value 'two' and a filled-in name" + } +} diff --git a/internal/command/testdata/test/override_data_list_attribute/main.tf b/internal/command/testdata/test/override_data_list_attribute/main.tf new file mode 100644 index 000000000000..f9d19bfd7435 --- /dev/null +++ b/internal/command/testdata/test/override_data_list_attribute/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "list_value" { + value = data.test_data_source.datasource.list_value +} diff --git a/internal/command/testdata/test/override_data_list_attribute/main.tftest.hcl b/internal/command/testdata/test/override_data_list_attribute/main.tftest.hcl new file mode 100644 index 000000000000..597e98ac4f9f --- /dev/null +++ b/internal/command/testdata/test/override_data_list_attribute/main.tftest.hcl @@ -0,0 +1,46 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + list_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_list_attribute" { + command = plan + + assert { + condition = length(data.test_data_source.datasource.list_value) == 2 + error_message = "Expected list_value to have 2 elements, got ${length(data.test_data_source.datasource.list_value)}" + } + + assert { + condition = data.test_data_source.datasource.list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } + + assert { + condition = data.test_data_source.datasource.list_value[0].value == "one" + error_message = "Expected first element value to be 'one'" + } + + assert { + condition = data.test_data_source.datasource.list_value[1].name == "second" + error_message = "Expected second element name to be 'second'" + } + + assert { + condition = data.test_data_source.datasource.list_value[1].value == "two" + error_message = "Expected second element value to be 'two'" + } +} diff --git a/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tf b/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tf new file mode 100644 index 000000000000..f9d19bfd7435 --- /dev/null +++ b/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "list_value" { + value = data.test_data_source.datasource.list_value +} diff --git a/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tftest.hcl b/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tftest.hcl new file mode 100644 index 000000000000..66bc4929342a --- /dev/null +++ b/internal/command/testdata/test/override_data_list_attribute_partial_elements/main.tftest.hcl @@ -0,0 +1,44 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + list_value = [ + { + name = "first" + }, + { + value = "two" + }, + ] + } +} + +run "test_override_data_list_attribute_partial_elements" { + command = plan + + assert { + condition = length(data.test_data_source.datasource.list_value) == 2 + error_message = "Expected list_value to have 2 elements, got ${length(data.test_data_source.datasource.list_value)}" + } + + assert { + condition = data.test_data_source.datasource.list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } + + assert { + condition = data.test_data_source.datasource.list_value[0].value != null + error_message = "Expected first element value to be filled in" + } + + assert { + condition = data.test_data_source.datasource.list_value[1].value == "two" + error_message = "Expected second element value to be 'two'" + } + + assert { + condition = data.test_data_source.datasource.list_value[1].name != null + error_message = "Expected second element name to be filled in" + } +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute/main.tf b/internal/command/testdata/test/override_data_nested_list_attribute/main.tf new file mode 100644 index 000000000000..52cfeb5fa44a --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "nested_list_value" { + value = data.test_data_source.datasource.nested_list_value +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute/main.tftest.hcl b/internal/command/testdata/test/override_data_nested_list_attribute/main.tftest.hcl new file mode 100644 index 000000000000..fe5a85019600 --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute/main.tftest.hcl @@ -0,0 +1,46 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + nested_list_value = [ + { + name = "first" + value = "one" + }, + { + name = "second" + value = "two" + }, + ] + } +} + +run "test_override_data_nested_list_attribute" { + command = plan + + assert { + condition = length(data.test_data_source.datasource.nested_list_value) == 2 + error_message = "Expected nested_list_value to have 2 elements, got ${length(data.test_data_source.datasource.nested_list_value)}" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[0].value == "one" + error_message = "Expected first element value to be 'one'" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[1].name == "second" + error_message = "Expected second element name to be 'second'" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[1].value == "two" + error_message = "Expected second element value to be 'two'" + } +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tf b/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tf new file mode 100644 index 000000000000..52cfeb5fa44a --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "nested_list_value" { + value = data.test_data_source.datasource.nested_list_value +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tftest.hcl b/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tftest.hcl new file mode 100644 index 000000000000..cb3ffaee8884 --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_invalid_type/main.tftest.hcl @@ -0,0 +1,12 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + nested_list_value = "wrong type" + } +} + +run "test_override_data_nested_list_attribute_invalid_type" { + command = plan +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tf b/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tf new file mode 100644 index 000000000000..52cfeb5fa44a --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "nested_list_value" { + value = data.test_data_source.datasource.nested_list_value +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tftest.hcl b/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tftest.hcl new file mode 100644 index 000000000000..125cbc3b94c0 --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_object/main.tftest.hcl @@ -0,0 +1,19 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + nested_list_value = { + name = "shared" + } + } +} + +run "test_override_data_nested_list_attribute_object" { + command = plan + + assert { + condition = length(data.test_data_source.datasource.nested_list_value) == 0 + error_message = "Expected nested_list_value to be empty when overridden with an object" + } +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tf b/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tf new file mode 100644 index 000000000000..52cfeb5fa44a --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tf @@ -0,0 +1,15 @@ +terraform { + required_providers { + test = { + source = "hashicorp/test" + } + } +} + +data "test_data_source" "datasource" { + id = "resource" +} + +output "nested_list_value" { + value = data.test_data_source.datasource.nested_list_value +} diff --git a/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tftest.hcl b/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tftest.hcl new file mode 100644 index 000000000000..ad5c19f05232 --- /dev/null +++ b/internal/command/testdata/test/override_data_nested_list_attribute_partial_elements/main.tftest.hcl @@ -0,0 +1,44 @@ +provider "test" {} + +override_data { + target = data.test_data_source.datasource + values = { + nested_list_value = [ + { + name = "first" + }, + { + value = "two" + }, + ] + } +} + +run "test_override_data_nested_list_attribute_partial_elements" { + command = plan + + assert { + condition = length(data.test_data_source.datasource.nested_list_value) == 2 + error_message = "Expected nested_list_value to have 2 elements, got ${length(data.test_data_source.datasource.nested_list_value)}" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[0].name == "first" + error_message = "Expected first element name to be 'first'" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[0].value != null + error_message = "Expected first element value to be filled in" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[1].value == "two" + error_message = "Expected second element value to be 'two'" + } + + assert { + condition = data.test_data_source.datasource.nested_list_value[1].name != null + error_message = "Expected second element name to be filled in" + } +} diff --git a/internal/command/testing/test_provider.go b/internal/command/testing/test_provider.go index f1a8e95f9751..d60ceab07ce2 100644 --- a/internal/command/testing/test_provider.go +++ b/internal/command/testing/test_provider.go @@ -5,6 +5,7 @@ package testing import ( "fmt" + "maps" "path" "strings" "sync" @@ -20,6 +21,76 @@ import ( ) var ( + // withBlockTypeAttributes contains additional block type attributes that are used across + // different resources and types, so this function serves as a place to store the shared attributes. + withBlockTypeAttributes = func(original map[string]*configschema.Attribute) map[string]*configschema.Attribute { + attrs := map[string]*configschema.Attribute{ + "list_value": { + Type: cty.List(cty.Object(map[string]cty.Type{ + "name": cty.String, + "value": cty.String, + })), + Computed: true, + Optional: true, + }, + "set_value": { + Type: cty.Set(cty.Object(map[string]cty.Type{ + "name": cty.String, + "value": cty.String, + })), + Computed: true, + Optional: true, + }, + "map_value": { + Type: cty.Map(cty.Object(map[string]cty.Type{ + "name": cty.String, + "value": cty.String, + })), + Computed: true, + Optional: true, + }, + + // The below nested_* attributes represent config supporting + // attributes as nested blocks, where terraform interprets the nested blocks + // as attributes within the schema + "nested_list_value": { + Computed: true, + Optional: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingList, + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "value": {Type: cty.String, Optional: true}, + }, + }, + }, + "nested_set_value": { + Computed: true, + Optional: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingSet, + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "value": {Type: cty.String, Optional: true}, + }, + }, + }, + "nested_map_value": { + Computed: true, + Optional: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingMap, + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "value": {Type: cty.String, Optional: true}, + }, + }, + }, + } + maps.Copy(original, attrs) + return original + } + ProviderSchema = &providers.GetProviderSchemaResponse{ Provider: providers.Schema{ Body: &configschema.Block{ @@ -44,26 +115,61 @@ var ( }, }, }, + "test_complex_resource": { + Body: &configschema.Block{ + Attributes: withBlockTypeAttributes(map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "value": {Type: cty.String, Optional: true}, + "interrupt_count": {Type: cty.Number, Optional: true}, + "destroy_fail": {Type: cty.Bool, Optional: true, Computed: true}, + "create_wait_seconds": {Type: cty.Number, Optional: true}, + "destroy_wait_seconds": {Type: cty.Number, Optional: true}, + "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, + "defer": {Type: cty.Bool, Optional: true}, + }), + }, + }, }, DataSources: map[string]providers.Schema{ "test_data_source": { Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Required: true}, - "value": {Type: cty.String, Computed: true}, - "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, + Attributes: withBlockTypeAttributes(map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "value": {Type: cty.String, Optional: true}, // We never actually reference these values from a data // source, but we have tests that use the same cty.Value // to represent a test_resource and a test_data_source // so the schemas have to match. + // + "interrupt_count": {Type: cty.Number, Optional: true}, + "destroy_fail": {Type: cty.Bool, Optional: true, Computed: true}, + "create_wait_seconds": {Type: cty.Number, Optional: true}, + "destroy_wait_seconds": {Type: cty.Number, Optional: true}, + "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, + "defer": {Type: cty.Bool, Optional: true}, + }), + }, + }, - "interrupt_count": {Type: cty.Number, Computed: true}, - "destroy_fail": {Type: cty.Bool, Computed: true}, - "create_wait_seconds": {Type: cty.Number, Computed: true}, - "destroy_wait_seconds": {Type: cty.Number, Computed: true}, - "defer": {Type: cty.Bool, Computed: true}, - }, + "test_complex_data_source": { + Body: &configschema.Block{ + Attributes: withBlockTypeAttributes(map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "value": {Type: cty.String, Optional: true}, + + // We never actually reference these values from a data + // source, but we have tests that use the same cty.Value + // to represent a test_resource and a test_data_source + // so the schemas have to match. + // + "interrupt_count": {Type: cty.Number, Optional: true}, + "destroy_fail": {Type: cty.Bool, Optional: true, Computed: true}, + "create_wait_seconds": {Type: cty.Number, Optional: true}, + "destroy_wait_seconds": {Type: cty.Number, Optional: true}, + "write_only": {Type: cty.String, Optional: true, WriteOnly: true}, + "defer": {Type: cty.Bool, Optional: true}, + }), }, }, }, @@ -365,14 +471,41 @@ func (provider *TestProvider) ReadDataSource(request providers.ReadDataSourceReq resource := provider.Store.Get(provider.GetDataKey(id)) if resource == cty.NilVal { diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "not found", fmt.Sprintf("%s does not exist", id))) + resource = cty.EmptyObjectVal } - if writeOnly := resource.GetAttr("write_only"); !writeOnly.IsNull() { - vals := resource.AsValueMap() + vals := resource.AsValueMap() + + if writeOnly := request.Config.GetAttr("write_only"); !writeOnly.IsNull() { vals["write_only"] = cty.NullVal(cty.String) - resource = cty.ObjectVal(vals) } + sharedObjectType := cty.Object(map[string]cty.Type{ + "name": cty.String, + "value": cty.String, + }) + + if _, exists := vals["list_value"]; !exists { + vals["list_value"] = cty.ListValEmpty(sharedObjectType) + } + if _, exists := vals["set_value"]; !exists { + vals["set_value"] = cty.SetValEmpty(sharedObjectType) + } + if _, exists := vals["map_value"]; !exists { + vals["map_value"] = cty.MapValEmpty(sharedObjectType) + } + if _, exists := vals["nested_list_value"]; !exists { + vals["nested_list_value"] = cty.ListValEmpty(sharedObjectType) + } + if _, exists := vals["nested_set_value"]; !exists { + vals["nested_set_value"] = cty.SetValEmpty(sharedObjectType) + } + if _, exists := vals["nested_map_value"]; !exists { + vals["nested_map_value"] = cty.MapValEmpty(sharedObjectType) + } + + resource = cty.ObjectVal(vals) + return providers.ReadDataSourceResponse{ State: resource, Diagnostics: diags, diff --git a/internal/moduletest/mocking/fill.go b/internal/moduletest/mocking/fill.go index f8643aa10b02..1c468fe2680c 100644 --- a/internal/moduletest/mocking/fill.go +++ b/internal/moduletest/mocking/fill.go @@ -17,58 +17,88 @@ import ( // attributes and/or performing conversions to make the input value correct. // // It is similar to FillType, except it accepts attributes instead of types. -func FillAttribute(in cty.Value, attribute *configschema.Attribute) (cty.Value, error) { - return fillAttribute(in, attribute, cty.Path{}) +func FillAttribute(providedMock cty.Value, attribute *configschema.Attribute) (cty.Value, error) { + return fillAttribute(providedMock, attribute, cty.Path{}) } -func fillAttribute(in cty.Value, attribute *configschema.Attribute, path cty.Path) (cty.Value, error) { +func fillAttribute(providedMock cty.Value, attribute *configschema.Attribute, path cty.Path) (cty.Value, error) { + ty := attribute.Type if attribute.NestedType != nil { - - // Then the in value must be an object. - if !in.Type().IsObjectType() { - return cty.NilVal, path.NewErrorf("incompatible types; expected object type, found %s", in.Type().FriendlyName()) - } + // For nested types, the providedMock value is interpreted in two ways: + // - If it's an object, it's treated as a single instance of the nested type, + // and because we can't know how many instances are needed, we return an empty collection. + // - If it's already a collection, it's treated as the whole nested type + // collection, and then we update each element of the collection with + // generated values where possible. + // Note: The collection type must match the attribute's nested type. switch attribute.NestedType.Nesting { case configschema.NestingSingle, configschema.NestingGroup: - var names []string - for name := range attribute.NestedType.Attributes { - names = append(names, name) - } - if len(names) == 0 { - return cty.EmptyObjectVal, nil - } - - // Make the order we iterate through the attributes deterministic. We - // are generating random strings in here so it's worth making the - // operation repeatable. - sort.Strings(names) - - children := make(map[string]cty.Value) - for _, name := range names { - if in.Type().HasAttribute(name) { - child, err := fillAttribute(in.GetAttr(name), attribute.NestedType.Attributes[name], path.GetAttr(name)) - if err != nil { - return cty.NilVal, err - } - children[name] = child - continue - } - children[name] = GenerateValueForAttribute(attribute.NestedType.Attributes[name]) - } - return cty.ObjectVal(children), nil + return fillObject(providedMock, attribute, path) case configschema.NestingSet: - return cty.SetValEmpty(attribute.ImpliedType().ElementType()), nil + if providedMock.Type().IsObjectType() { + return cty.SetValEmpty(attribute.ImpliedType().ElementType()), nil + } + return fillIterable(providedMock, attribute, path) case configschema.NestingList: - return cty.ListValEmpty(attribute.ImpliedType().ElementType()), nil + if providedMock.Type().IsObjectType() { + return cty.ListValEmpty(attribute.ImpliedType().ElementType()), nil + } + return fillIterable(providedMock, attribute, path) case configschema.NestingMap: - return cty.MapValEmpty(attribute.ImpliedType().ElementType()), nil + if providedMock.Type().IsObjectType() { + return cty.MapValEmpty(attribute.ImpliedType().ElementType()), nil + } + return fillIterable(providedMock, attribute, path) default: panic(fmt.Errorf("unknown nesting mode: %d", attribute.NestedType.Nesting)) } } - return fillType(in, attribute.Type, path) + return fillType(providedMock, ty, path) +} + +func fillObject(providedMock cty.Value, attribute *configschema.Attribute, path cty.Path) (cty.Value, error) { + // Then the providedMock value must be an object. + if !providedMock.Type().IsObjectType() { + return cty.NilVal, path.NewErrorf("incompatible types; expected object type, found %s", providedMock.Type().FriendlyName()) + } + + var names []string + for name := range attribute.NestedType.Attributes { + names = append(names, name) + } + if len(names) == 0 { + return cty.EmptyObjectVal, nil + } + + // Make the order we iterate through the attributes deterministic. We + // are generating random strings in here so it's worth making the + // operation repeatable. + sort.Strings(names) + + children := make(map[string]cty.Value) + for _, name := range names { + if providedMock.Type().HasAttribute(name) { + child, err := fillAttribute(providedMock.GetAttr(name), attribute.NestedType.Attributes[name], path.GetAttr(name)) + if err != nil { + return cty.NilVal, err + } + children[name] = child + continue + } + children[name] = GenerateValueForAttribute(attribute.NestedType.Attributes[name]) + } + return cty.ObjectVal(children), nil +} + +func fillIterable(providedMock cty.Value, attribute *configschema.Attribute, path cty.Path) (cty.Value, error) { + ty := attribute.NestedType.ConfigType() + out, err := fillType(providedMock, ty, path) + if err != nil { + return cty.NilVal, err + } + return out, err } // FillType makes the input value match the target type by adding attributes diff --git a/internal/moduletest/mocking/values.go b/internal/moduletest/mocking/values.go index 6048aee35888..2b8b77618667 100644 --- a/internal/moduletest/mocking/values.go +++ b/internal/moduletest/mocking/values.go @@ -165,6 +165,21 @@ func populateComputedValues(target cty.Value, with MockedData, schema *configsch }) } + // If there are no errors, we coerce the value to ensure it matches the schema. + // Any errors here would be because we generated an invalid value. + if !diags.HasErrors() { + var err error + value, err = schema.CoerceValue(value) + if err != nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Detail: "Failed to coerce value", + Summary: fmt.Sprintf("Terraform failed to coerce a value for a mocked object: %s. This is a bug in Terraform - please report it.", err), + Subject: with.Range.Ptr(), + }) + } + } + return value, diags } diff --git a/internal/moduletest/mocking/values_test.go b/internal/moduletest/mocking/values_test.go index 7ec12162b72d..a75ff15302a1 100644 --- a/internal/moduletest/mocking/values_test.go +++ b/internal/moduletest/mocking/values_test.go @@ -537,6 +537,40 @@ func TestComputedValuesForDataSource(t *testing.T) { }), }, "nested_list_attribute_computed": { + target: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.NullVal(cty.List(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + }))), + }), + with: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + }), + }), + }), + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "nested": { + NestedType: &configschema.Object{ + Attributes: computedAttributes, + Nesting: configschema.NestingList, + }, + Computed: true, + }, + }, + }, + expected: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + "value": cty.StringVal("ssnk9qhr"), + }), + }), + }), + }, + "nested_list_attribute_computed_empty": { target: cty.ObjectVal(map[string]cty.Value{ "nested": cty.NullVal(cty.List(cty.Object(map[string]cty.Type{ "id": cty.String, @@ -565,6 +599,7 @@ func TestComputedValuesForDataSource(t *testing.T) { "value": cty.String, })), }), + expectedFailures: nil, }, "nested_set_attribute": { target: cty.ObjectVal(map[string]cty.Value{ @@ -634,6 +669,40 @@ func TestComputedValuesForDataSource(t *testing.T) { }), }, "nested_set_attribute_computed": { + target: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.NullVal(cty.Set(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + }))), + }), + with: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + }), + }), + }), + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "nested": { + NestedType: &configschema.Object{ + Attributes: computedAttributes, + Nesting: configschema.NestingSet, + }, + Computed: true, + }, + }, + }, + expected: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + "value": cty.StringVal("ssnk9qhr"), + }), + }), + }), + }, + "nested_set_attribute_computed_empty": { target: cty.ObjectVal(map[string]cty.Value{ "nested": cty.NullVal(cty.Set(cty.Object(map[string]cty.Type{ "id": cty.String, @@ -662,6 +731,7 @@ func TestComputedValuesForDataSource(t *testing.T) { "value": cty.String, })), }), + expectedFailures: nil, }, "nested_map_attribute": { target: cty.ObjectVal(map[string]cty.Value{ @@ -731,6 +801,40 @@ func TestComputedValuesForDataSource(t *testing.T) { }), }, "nested_map_attribute_computed": { + target: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.NullVal(cty.Map(cty.Object(map[string]cty.Type{ + "id": cty.String, + "value": cty.String, + }))), + }), + with: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.MapVal(map[string]cty.Value{ + "key": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + }), + }), + }), + schema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "nested": { + NestedType: &configschema.Object{ + Attributes: computedAttributes, + Nesting: configschema.NestingMap, + }, + Computed: true, + }, + }, + }, + expected: cty.ObjectVal(map[string]cty.Value{ + "nested": cty.MapVal(map[string]cty.Value{ + "key": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("myvalue"), + "value": cty.StringVal("ssnk9qhr"), + }), + }), + }), + }, + "nested_map_attribute_computed_empty": { target: cty.ObjectVal(map[string]cty.Value{ "nested": cty.NullVal(cty.Map(cty.Object(map[string]cty.Type{ "id": cty.String, @@ -759,6 +863,7 @@ func TestComputedValuesForDataSource(t *testing.T) { "value": cty.String, })), }), + expectedFailures: nil, }, "invalid_replacement_path": { target: cty.ObjectVal(map[string]cty.Value{ @@ -936,6 +1041,41 @@ func TestComputedValuesForDataSource(t *testing.T) { "Terraform could not compute a value for the target type string with the mocked data defined at :0,0-0 with the attribute \".block[0].id\": string required, but have object.", }, }, + + "invalid_replacement_path_nested_list_block": { + target: cty.ObjectVal(map[string]cty.Value{ + "block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "value": cty.StringVal("one"), + }), + }), + }), + with: cty.ObjectVal(map[string]cty.Value{ + "block": cty.ListVal([]cty.Value{ + cty.StringVal("Hello, world!"), + }), + }), + schema: &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "block": { + Block: computedBlock, + Nesting: configschema.NestingList, + }, + }, + }, + expected: cty.ObjectVal(map[string]cty.Value{ + "block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("ssnk9qhr"), + "value": cty.StringVal("one"), + }), + }), + }), + expectedFailures: []string{ + "Terraform expected an object type for attribute \".block[0]\" defined within the mocked data at :0,0-0, but found list of string.", + }, + }, "dynamic_attribute_unset": { target: cty.ObjectVal(map[string]cty.Value{ "dynamic_attribute": cty.NullVal(cty.DynamicPseudoType),