// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package workdir import ( "encoding/json" "strings" "testing" "github.com/google/go-cmp/cmp" tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform/internal/getproviders" "github.com/hashicorp/terraform/version" ) func TestParseBackendStateFile(t *testing.T) { tests := map[string]struct { Input string Want *BackendStateFile WantErr string }{ "empty": { Input: ``, WantErr: `invalid syntax: unexpected end of JSON input`, }, "empty but valid JSON syntax": { Input: `{}`, WantErr: `invalid syntax: no format version number`, }, "older version": { Input: `{ "version": 2, "terraform_version": "0.3.0" }`, WantErr: `unsupported backend state version 2; you may need to use Terraform CLI v0.3.0 to work in this directory`, }, "newer version": { Input: `{ "version": 4, "terraform_version": "54.23.9" }`, WantErr: `unsupported backend state version 4; you may need to use Terraform CLI v54.23.9 to work in this directory`, }, "legacy remote state is active": { Input: `{ "version": 3, "terraform_version": "0.8.0", "remote": { "anything": "goes" } }`, WantErr: `this working directory uses legacy remote state and so must first be upgraded using Terraform v0.9`, }, "active backend": { Input: `{ "version": 3, "terraform_version": "0.8.0", "backend": { "type": "treasure_chest_buried_on_a_remote_island", "config": {}, "hash" : 12345 } }`, Want: &BackendStateFile{ Version: 3, TFVersion: "0.8.0", Backend: &BackendConfigState{ Type: "treasure_chest_buried_on_a_remote_island", ConfigRaw: json.RawMessage("{}"), Hash: 12345, }, }, }, "active state_store": { Input: `{ "version": 3, "terraform_version": "9.9.9", "state_store": { "type": "foobar_baz", "config": { "bucket": "my-bucket", "region": "saturn" }, "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "config": { "credentials": "./creds.json" }, "hash" : 12345 }, "hash" : 12345, "provider_supply_mode": "managed_by_terraform" } }`, Want: &BackendStateFile{ Version: 3, TFVersion: "9.9.9", StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Watch out - the number of tabs in the last argument here are load-bearing Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", `{ "credentials": "./creds.json" }`), ConfigRaw: json.RawMessage(`{ "bucket": "my-bucket", "region": "saturn" }`), Hash: 12345, ProviderSupplyMode: getproviders.ManagedByTerraform, }, }, }, "detection of malformed state: conflicting 'backend' and 'state_store' sections": { Input: `{ "version": 3, "terraform_version": "9.9.9", "backend": { "type": "treasure_chest_buried_on_a_remote_island", "config": {}, "hash" : 12345 }, "state_store": { "type": "foobar_baz", "config": { "provider": "foobar", "bucket": "my-bucket" }, "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "hash" : 12345 }, "hash" : 12345 } }`, WantErr: `encountered a malformed backend state file that contains state for both a 'backend' and a 'state_store' block`, }, "detection of malformed state: missing 'provider_supply_mode' when state store is in use": { Input: `{ "version": 3, "terraform_version": "9.9.9", "state_store": { "type": "foobar_baz", "config": { "provider": "foobar", "bucket": "my-bucket" }, "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "hash" : 12345 }, "hash" : 12345 } }`, WantErr: `encountered a malformed backend state file with a 'state_store' block that is missing the required 'provider_supply_mode' property`, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { got, err := ParseBackendStateFile([]byte(test.Input)) if test.WantErr != "" { if err == nil { t.Fatalf("unexpected success\nwant error: %s", test.WantErr) } if got, want := err.Error(), test.WantErr; got != want { t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(test.Want, got); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestEncodeBackendStateFile(t *testing.T) { noVersionData := "" tfVersion := version.Version tests := map[string]struct { Input *BackendStateFile Want []byte WantErr string }{ "encoding a backend state file when state_store is in use": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", `{"foo": "bar"}`), ProviderSupplyMode: getproviders.ManagedByTerraform, ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte(`{ "version": 3, "terraform_version": "` + tfVersion + `", "state_store": { "type": "foobar_baz", "provider": { "version": "1.2.3", "source": "registry.terraform.io/my-org/foobar", "config": { "foo": "bar" } }, "config": { "foo": "bar" }, "hash": 123, "provider_supply_mode": "managed_by_terraform" } }`), }, "it's valid to record no version data when a builtin provider is used for state store": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Note - no version data in the provider description below Provider: getTestProviderState(t, noVersionData, string(tfaddr.BuiltInProviderHost), string(tfaddr.BuiltInProviderNamespace), "foobar", `{"foo": "bar"}`), ProviderSupplyMode: getproviders.BuiltIn, ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte(`{ "version": 3, "terraform_version": "` + tfVersion + `", "state_store": { "type": "foobar_baz", "provider": { "version": null, "source": "terraform.io/builtin/foobar", "config": { "foo": "bar" } }, "config": { "foo": "bar" }, "hash": 123, "provider_supply_mode": "built_in" } }`), }, "it's valid to record no version data when a re-attached provider is used for state store": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Note - no version data in the provider description below Provider: getTestProviderState(t, noVersionData, "registry.terraform.io", "hashicorp", "foobar", `{"foo": "bar"}`), ProviderSupplyMode: getproviders.Reattached, ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte(`{ "version": 3, "terraform_version": "` + tfVersion + `", "state_store": { "type": "foobar_baz", "provider": { "version": null, "source": "registry.terraform.io/hashicorp/foobar", "config": { "foo": "bar" } }, "config": { "foo": "bar" }, "hash": 123, "provider_supply_mode": "reattached" } }`), }, "it's valid to record no version data when a developer override provider is used for state store": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Note - no version data in the provider description below Provider: getTestProviderState(t, noVersionData, "registry.terraform.io", "hashicorp", "foobar", `{"foo": "bar"}`), ProviderSupplyMode: getproviders.DevOverride, ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, Want: []byte(`{ "version": 3, "terraform_version": "` + tfVersion + `", "state_store": { "type": "foobar_baz", "provider": { "version": null, "source": "registry.terraform.io/hashicorp/foobar", "config": { "foo": "bar" } }, "config": { "foo": "bar" }, "hash": 123, "provider_supply_mode": "dev_override" } }`), }, "error when neither backend nor state_store config state are present": { Input: &BackendStateFile{}, Want: []byte(`{ "version": 3, "terraform_version": "` + tfVersion + `" }`), }, "error when the provider is managed by Terraform and the provider version is missing": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", // Note - no version data in the provider description below Provider: getTestProviderState(t, noVersionData, "registry.terraform.io", "my-org", "foobar", ""), ProviderSupplyMode: getproviders.ManagedByTerraform, ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: provider version data is missing`, }, "error when the provider source's hostname is missing": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "my-org", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, }, "error when the provider source's hostname and namespace are missing ": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Unknown hostname: Expected hostname in the provider address to be set`, }, "error when the provider source is completely missing ": { Input: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "", "", "", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `state store is not valid: Empty provider address: Expected address composed of hostname, provider namespace and name`, }, "error when both backend and state_store config state are present": { Input: &BackendStateFile{ Backend: &BackendConfigState{ Type: "foobar", ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, StateStore: &StateStoreConfigState{ Type: "foobar_baz", Provider: getTestProviderState(t, "1.2.3", "registry.terraform.io", "my-org", "foobar", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, WantErr: `attempted to encode a malformed backend state file; it contains state for both a 'backend' and a 'state_store' block`, }, } for name, test := range tests { t.Run(name, func(t *testing.T) { got, err := EncodeBackendStateFile(test.Input) if test.WantErr != "" { if err == nil { t.Fatalf("unexpected success\nwant error: %s", test.WantErr) } if !strings.Contains(err.Error(), test.WantErr) { t.Errorf("wrong error\ngot: %s\nwant: %s", err.Error(), test.WantErr) } return } if err != nil { t.Fatalf("unexpected error: %s", err) } if diff := cmp.Diff(test.Want, got); diff != "" { t.Errorf("wrong result\n%s", diff) } }) } } func TestBackendStateFile_DeepCopy(t *testing.T) { tests := map[string]struct { file *BackendStateFile }{ "Deep copy preserves state_store data": { file: &BackendStateFile{ StateStore: &StateStoreConfigState{ Type: "foo_bar", Provider: getTestProviderState(t, "1.2.3", "A", "B", "C", ""), ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, }, "Deep copy preserves backend data": { file: &BackendStateFile{ Backend: &BackendConfigState{ Type: "foobar", ConfigRaw: json.RawMessage([]byte(`{"foo":"bar"}`)), Hash: 123, }, }, }, "Deep copy preserves version and Terraform version data": { file: &BackendStateFile{ Version: 3, TFVersion: "9.9.9", }, }, } for tn, tc := range tests { t.Run(tn, func(t *testing.T) { copy := tc.file.DeepCopy() if diff := cmp.Diff(copy, tc.file); diff != "" { t.Fatalf("unexpected difference in backend state data:\n %s", diff) } }) } }