github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_test.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cloud
     7  
     8  import (
     9  	"context"
    10  	"fmt"
    11  	"net/http"
    12  	"strings"
    13  	"testing"
    14  
    15  	tfe "github.com/hashicorp/go-tfe"
    16  	version "github.com/hashicorp/go-version"
    17  	"github.com/opentofu/opentofu/internal/backend"
    18  	"github.com/opentofu/opentofu/internal/encryption"
    19  	"github.com/opentofu/opentofu/internal/tfdiags"
    20  	tfversion "github.com/opentofu/opentofu/version"
    21  	"github.com/zclconf/go-cty/cty"
    22  
    23  	backendLocal "github.com/opentofu/opentofu/internal/backend/local"
    24  )
    25  
    26  func TestCloud(t *testing.T) {
    27  	var _ backend.Enhanced = New(nil, encryption.StateEncryptionDisabled())
    28  	var _ backend.CLI = New(nil, encryption.StateEncryptionDisabled())
    29  }
    30  
    31  func TestCloud_backendWithName(t *testing.T) {
    32  	b, bCleanup := testBackendWithName(t)
    33  	defer bCleanup()
    34  
    35  	workspaces, err := b.Workspaces()
    36  	if err != nil {
    37  		t.Fatalf("error: %v", err)
    38  	}
    39  
    40  	if len(workspaces) != 1 || workspaces[0] != testBackendSingleWorkspaceName {
    41  		t.Fatalf("should only have a single configured workspace matching the configured 'name' strategy, but got: %#v", workspaces)
    42  	}
    43  
    44  	if _, err := b.StateMgr("foo"); err != backend.ErrWorkspacesNotSupported {
    45  		t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err)
    46  	}
    47  
    48  	if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported {
    49  		t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err)
    50  	}
    51  
    52  	if err := b.DeleteWorkspace("foo", true); err != backend.ErrWorkspacesNotSupported {
    53  		t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err)
    54  	}
    55  }
    56  
    57  func TestCloud_backendWithoutHost(t *testing.T) {
    58  	s := testServer(t)
    59  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
    60  
    61  	obj := cty.ObjectVal(map[string]cty.Value{
    62  		"hostname":     cty.NullVal(cty.String),
    63  		"organization": cty.StringVal("hashicorp"),
    64  		"token":        cty.NullVal(cty.String),
    65  		"workspaces": cty.ObjectVal(map[string]cty.Value{
    66  			"name":    cty.StringVal(testBackendSingleWorkspaceName),
    67  			"tags":    cty.NullVal(cty.Set(cty.String)),
    68  			"project": cty.NullVal(cty.String),
    69  		}),
    70  	})
    71  
    72  	// Configure the backend so the client is created.
    73  	newObj, valDiags := b.PrepareConfig(obj)
    74  	if len(valDiags) != 0 {
    75  		t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings())
    76  	}
    77  	obj = newObj
    78  
    79  	confDiags := b.Configure(obj)
    80  
    81  	if !confDiags.HasErrors() {
    82  		t.Fatalf("testBackend: backend.Configure() should have failed")
    83  	}
    84  
    85  	if !strings.Contains(confDiags.Err().Error(), "Hostname is required for the cloud backend") {
    86  		t.Fatalf("testBackend: backend.Configure() should have failed with missing hostname error")
    87  	}
    88  }
    89  
    90  func TestCloud_backendWithTags(t *testing.T) {
    91  	b, bCleanup := testBackendWithTags(t)
    92  	defer bCleanup()
    93  
    94  	backend.TestBackendStates(t, b)
    95  
    96  	// Test pagination works
    97  	for i := 0; i < 25; i++ {
    98  		_, err := b.StateMgr(fmt.Sprintf("foo-%d", i+1))
    99  		if err != nil {
   100  			t.Fatalf("error: %s", err)
   101  		}
   102  	}
   103  
   104  	workspaces, err := b.Workspaces()
   105  	if err != nil {
   106  		t.Fatalf("error: %s", err)
   107  	}
   108  	actual := len(workspaces)
   109  	if actual != 26 {
   110  		t.Errorf("expected 26 workspaces (over one standard paginated response), got %d", actual)
   111  	}
   112  }
   113  
   114  func TestCloud_PrepareConfig(t *testing.T) {
   115  	cases := map[string]struct {
   116  		config      cty.Value
   117  		expectedErr string
   118  	}{
   119  		"null organization": {
   120  			config: cty.ObjectVal(map[string]cty.Value{
   121  				"organization": cty.NullVal(cty.String),
   122  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   123  					"name":    cty.StringVal("prod"),
   124  					"tags":    cty.NullVal(cty.Set(cty.String)),
   125  					"project": cty.NullVal(cty.String),
   126  				}),
   127  			}),
   128  			expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`,
   129  		},
   130  		"null workspace": {
   131  			config: cty.ObjectVal(map[string]cty.Value{
   132  				"organization": cty.StringVal("org"),
   133  				"workspaces":   cty.NullVal(cty.String),
   134  			}),
   135  			expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`,
   136  		},
   137  		"workspace: empty tags, name": {
   138  			config: cty.ObjectVal(map[string]cty.Value{
   139  				"organization": cty.StringVal("org"),
   140  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   141  					"name":    cty.NullVal(cty.String),
   142  					"tags":    cty.NullVal(cty.Set(cty.String)),
   143  					"project": cty.NullVal(cty.String),
   144  				}),
   145  			}),
   146  			expectedErr: `Invalid workspaces configuration: Missing workspace mapping strategy. Either workspace "tags" or "name" is required.`,
   147  		},
   148  		"workspace: name present": {
   149  			config: cty.ObjectVal(map[string]cty.Value{
   150  				"organization": cty.StringVal("org"),
   151  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   152  					"name":    cty.StringVal("prod"),
   153  					"tags":    cty.NullVal(cty.Set(cty.String)),
   154  					"project": cty.NullVal(cty.String),
   155  				}),
   156  			}),
   157  			expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`,
   158  		},
   159  		"workspace: name and tags present": {
   160  			config: cty.ObjectVal(map[string]cty.Value{
   161  				"organization": cty.StringVal("org"),
   162  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   163  					"name": cty.StringVal("prod"),
   164  					"tags": cty.SetVal(
   165  						[]cty.Value{
   166  							cty.StringVal("billing"),
   167  						},
   168  					),
   169  					"project": cty.NullVal(cty.String),
   170  				}),
   171  			}),
   172  			expectedErr: `Invalid workspaces configuration: Only one of workspace "tags" or "name" is allowed.`,
   173  		},
   174  	}
   175  
   176  	for name, tc := range cases {
   177  		t.Run(name, func(t *testing.T) {
   178  			s := testServer(t)
   179  			b := New(testDisco(s), encryption.StateEncryptionDisabled())
   180  
   181  			// Validate
   182  			_, valDiags := b.PrepareConfig(tc.config)
   183  			if valDiags.Err() != nil && tc.expectedErr != "" {
   184  				actualErr := valDiags.Err().Error()
   185  				if !strings.Contains(actualErr, tc.expectedErr) {
   186  					t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err())
   187  				}
   188  			}
   189  		})
   190  	}
   191  }
   192  
   193  func TestCloud_PrepareConfigWithEnvVars(t *testing.T) {
   194  	cases := map[string]struct {
   195  		config      cty.Value
   196  		vars        map[string]string
   197  		expectedErr string
   198  	}{
   199  		"with no organization": {
   200  			config: cty.ObjectVal(map[string]cty.Value{
   201  				"organization": cty.NullVal(cty.String),
   202  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   203  					"name":    cty.StringVal("prod"),
   204  					"tags":    cty.NullVal(cty.Set(cty.String)),
   205  					"project": cty.NullVal(cty.String),
   206  				}),
   207  			}),
   208  			vars: map[string]string{
   209  				"TF_CLOUD_ORGANIZATION": "example-org",
   210  			},
   211  		},
   212  		"with no organization attribute or env var": {
   213  			config: cty.ObjectVal(map[string]cty.Value{
   214  				"organization": cty.NullVal(cty.String),
   215  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   216  					"name":    cty.StringVal("prod"),
   217  					"tags":    cty.NullVal(cty.Set(cty.String)),
   218  					"project": cty.NullVal(cty.String),
   219  				}),
   220  			}),
   221  			vars:        map[string]string{},
   222  			expectedErr: `Invalid or missing required argument: "organization" must be set in the cloud configuration or as an environment variable: TF_CLOUD_ORGANIZATION.`,
   223  		},
   224  		"null workspace": {
   225  			config: cty.ObjectVal(map[string]cty.Value{
   226  				"organization": cty.StringVal("hashicorp"),
   227  				"workspaces":   cty.NullVal(cty.String),
   228  			}),
   229  			vars: map[string]string{
   230  				"TF_WORKSPACE": "my-workspace",
   231  			},
   232  		},
   233  		"organization and workspace and project env var": {
   234  			config: cty.ObjectVal(map[string]cty.Value{
   235  				"organization": cty.NullVal(cty.String),
   236  				"workspaces":   cty.NullVal(cty.String),
   237  			}),
   238  			vars: map[string]string{
   239  				"TF_CLOUD_ORGANIZATION": "hashicorp",
   240  				"TF_WORKSPACE":          "my-workspace",
   241  				"TF_CLOUD_PROJECT":      "example-project",
   242  			},
   243  		},
   244  		"with no project": {
   245  			config: cty.ObjectVal(map[string]cty.Value{
   246  				"organization": cty.StringVal("organization"),
   247  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   248  					"name":    cty.StringVal("prod"),
   249  					"tags":    cty.NullVal(cty.Set(cty.String)),
   250  					"project": cty.NullVal(cty.String),
   251  				}),
   252  			}),
   253  		},
   254  		"with null project": {
   255  			config: cty.ObjectVal(map[string]cty.Value{
   256  				"organization": cty.StringVal("organization"),
   257  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   258  					"name":    cty.StringVal("prod"),
   259  					"tags":    cty.NullVal(cty.Set(cty.String)),
   260  					"project": cty.NullVal(cty.String),
   261  				}),
   262  			}),
   263  			vars: map[string]string{
   264  				"TF_CLOUD_PROJECT": "example-project",
   265  			},
   266  		},
   267  		"with project env var overwrite config value": {
   268  			config: cty.ObjectVal(map[string]cty.Value{
   269  				"organization": cty.StringVal("organization"),
   270  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   271  					"name":    cty.StringVal("prod"),
   272  					"tags":    cty.NullVal(cty.Set(cty.String)),
   273  					"project": cty.StringVal("project-name"),
   274  				}),
   275  			}),
   276  			vars: map[string]string{
   277  				"TF_CLOUD_PROJECT": "example-project",
   278  			},
   279  		},
   280  		"with workspace defined by tags overwritten by TF_WORKSPACE": {
   281  			// see https://github.com/opentofu/opentofu/issues/814 for context
   282  			config: cty.ObjectVal(map[string]cty.Value{
   283  				"hostname":     cty.StringVal("foo"),
   284  				"organization": cty.StringVal("bar"),
   285  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   286  					"name":    cty.NullVal(cty.String),
   287  					"project": cty.NullVal(cty.String),
   288  					"tags":    cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}),
   289  				}),
   290  			}),
   291  			vars: map[string]string{
   292  				"TF_WORKSPACE": "qux",
   293  			},
   294  		},
   295  		"with TF_WORKSPACE value outside of the tags set": {
   296  			// see https://github.com/opentofu/opentofu/issues/814 for context
   297  			config: cty.ObjectVal(map[string]cty.Value{
   298  				"hostname":     cty.StringVal("foo"),
   299  				"organization": cty.StringVal("bar"),
   300  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   301  					"name":    cty.NullVal(cty.String),
   302  					"project": cty.NullVal(cty.String),
   303  					"tags":    cty.SetVal([]cty.Value{cty.StringVal("baz"), cty.StringVal("qux")}),
   304  				}),
   305  			}),
   306  			vars: map[string]string{
   307  				"TF_WORKSPACE": "quxx",
   308  			},
   309  			expectedErr: `Invalid workspaces configuration: The workspace defined using the environment variable "TF_WORKSPACE" does not belong to "tags".`,
   310  		},
   311  		"with workspace block w/o attributes, TF_WORKSPACE defined": {
   312  			config: cty.ObjectVal(map[string]cty.Value{
   313  				"hostname":     cty.StringVal("foo"),
   314  				"organization": cty.StringVal("bar"),
   315  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   316  					"name":    cty.NullVal(cty.String),
   317  					"tags":    cty.NullVal(cty.Set(cty.String)),
   318  					"project": cty.NullVal(cty.String),
   319  				}),
   320  			}),
   321  			vars: map[string]string{
   322  				"TF_WORKSPACE": "qux",
   323  			},
   324  		},
   325  	}
   326  
   327  	for name, tc := range cases {
   328  		t.Run(name, func(t *testing.T) {
   329  			s := testServer(t)
   330  			b := New(testDisco(s), encryption.StateEncryptionDisabled())
   331  
   332  			for k, v := range tc.vars {
   333  				t.Setenv(k, v)
   334  			}
   335  
   336  			_, valDiags := b.PrepareConfig(tc.config)
   337  			if (valDiags.Err() == nil) != (tc.expectedErr == "") {
   338  				t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err())
   339  			}
   340  			if valDiags.Err() != nil {
   341  				if !strings.Contains(valDiags.Err().Error(), tc.expectedErr) {
   342  					t.Fatalf("%s: unexpected validation result: %v", name, valDiags.Err())
   343  				}
   344  			}
   345  		})
   346  	}
   347  }
   348  
   349  func TestCloud_config(t *testing.T) {
   350  	cases := map[string]struct {
   351  		config  cty.Value
   352  		confErr string
   353  		valErr  string
   354  	}{
   355  		"with_a_non_tfe_host": {
   356  			config: cty.ObjectVal(map[string]cty.Value{
   357  				"hostname":     cty.StringVal("nontfe.local"),
   358  				"organization": cty.StringVal("hashicorp"),
   359  				"token":        cty.NullVal(cty.String),
   360  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   361  					"name":    cty.StringVal("prod"),
   362  					"tags":    cty.NullVal(cty.Set(cty.String)),
   363  					"project": cty.NullVal(cty.String),
   364  				}),
   365  			}),
   366  			confErr: "Host nontfe.local does not provide a tfe service",
   367  		},
   368  		// localhost advertises TFE services, but has no token in the credentials
   369  		"without_a_token": {
   370  			config: cty.ObjectVal(map[string]cty.Value{
   371  				"hostname":     cty.StringVal("localhost"),
   372  				"organization": cty.StringVal("hashicorp"),
   373  				"token":        cty.NullVal(cty.String),
   374  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   375  					"name":    cty.StringVal("prod"),
   376  					"tags":    cty.NullVal(cty.Set(cty.String)),
   377  					"project": cty.NullVal(cty.String),
   378  				}),
   379  			}),
   380  			confErr: "tofu login localhost",
   381  		},
   382  		"with_tags": {
   383  			config: cty.ObjectVal(map[string]cty.Value{
   384  				"hostname":     cty.NullVal(cty.String),
   385  				"organization": cty.StringVal("hashicorp"),
   386  				"token":        cty.NullVal(cty.String),
   387  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   388  					"name": cty.NullVal(cty.String),
   389  					"tags": cty.SetVal(
   390  						[]cty.Value{
   391  							cty.StringVal("billing"),
   392  						},
   393  					),
   394  					"project": cty.NullVal(cty.String),
   395  				}),
   396  			}),
   397  		},
   398  		"with_a_name": {
   399  			config: cty.ObjectVal(map[string]cty.Value{
   400  				"hostname":     cty.NullVal(cty.String),
   401  				"organization": cty.StringVal("hashicorp"),
   402  				"token":        cty.NullVal(cty.String),
   403  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   404  					"name":    cty.StringVal("prod"),
   405  					"tags":    cty.NullVal(cty.Set(cty.String)),
   406  					"project": cty.NullVal(cty.String),
   407  				}),
   408  			}),
   409  		},
   410  		"without_a_name_tags": {
   411  			config: cty.ObjectVal(map[string]cty.Value{
   412  				"hostname":     cty.NullVal(cty.String),
   413  				"organization": cty.StringVal("hashicorp"),
   414  				"token":        cty.NullVal(cty.String),
   415  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   416  					"name":    cty.NullVal(cty.String),
   417  					"tags":    cty.NullVal(cty.Set(cty.String)),
   418  					"project": cty.NullVal(cty.String),
   419  				}),
   420  			}),
   421  			valErr: `Missing workspace mapping strategy.`,
   422  		},
   423  		"with_both_a_name_and_tags": {
   424  			config: cty.ObjectVal(map[string]cty.Value{
   425  				"hostname":     cty.NullVal(cty.String),
   426  				"organization": cty.StringVal("hashicorp"),
   427  				"token":        cty.NullVal(cty.String),
   428  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   429  					"name": cty.StringVal("prod"),
   430  					"tags": cty.SetVal(
   431  						[]cty.Value{
   432  							cty.StringVal("billing"),
   433  						},
   434  					),
   435  					"project": cty.NullVal(cty.String),
   436  				}),
   437  			}),
   438  			valErr: `Only one of workspace "tags" or "name" is allowed.`,
   439  		},
   440  		"null config": {
   441  			config: cty.NullVal(cty.EmptyObject),
   442  		},
   443  	}
   444  
   445  	for name, tc := range cases {
   446  		t.Run(name, func(t *testing.T) {
   447  			b, cleanup := testUnconfiguredBackend(t)
   448  			t.Cleanup(cleanup)
   449  
   450  			// Validate
   451  			_, valDiags := b.PrepareConfig(tc.config)
   452  			if (valDiags.Err() != nil || tc.valErr != "") &&
   453  				(valDiags.Err() == nil || !strings.Contains(valDiags.Err().Error(), tc.valErr)) {
   454  				t.Fatalf("unexpected validation result: %v", valDiags.Err())
   455  			}
   456  
   457  			// Configure
   458  			confDiags := b.Configure(tc.config)
   459  			if (confDiags.Err() != nil || tc.confErr != "") &&
   460  				(confDiags.Err() == nil || !strings.Contains(confDiags.Err().Error(), tc.confErr)) {
   461  				t.Fatalf("unexpected configure result: %v", confDiags.Err())
   462  			}
   463  		})
   464  	}
   465  }
   466  
   467  func TestCloud_configVerifyMinimumTFEVersion(t *testing.T) {
   468  	config := cty.ObjectVal(map[string]cty.Value{
   469  		"hostname":     cty.StringVal(tfeHost),
   470  		"organization": cty.StringVal("hashicorp"),
   471  		"token":        cty.NullVal(cty.String),
   472  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   473  			"name": cty.NullVal(cty.String),
   474  			"tags": cty.SetVal(
   475  				[]cty.Value{
   476  					cty.StringVal("billing"),
   477  				},
   478  			),
   479  			"project": cty.NullVal(cty.String),
   480  		}),
   481  	})
   482  
   483  	handlers := map[string]func(http.ResponseWriter, *http.Request){
   484  		"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
   485  			w.Header().Set("Content-Type", "application/json")
   486  			w.Header().Set("TFP-API-Version", "2.4")
   487  		},
   488  	}
   489  	s := testServerWithHandlers(handlers)
   490  
   491  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
   492  
   493  	confDiags := b.Configure(config)
   494  	if confDiags.Err() == nil {
   495  		t.Fatalf("expected configure to error")
   496  	}
   497  
   498  	expected := `The 'cloud' option is not supported with this version of the cloud backend.`
   499  	if !strings.Contains(confDiags.Err().Error(), expected) {
   500  		t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error())
   501  	}
   502  }
   503  
   504  func TestCloud_configVerifyMinimumTFEVersionInAutomation(t *testing.T) {
   505  	config := cty.ObjectVal(map[string]cty.Value{
   506  		"hostname":     cty.StringVal(tfeHost),
   507  		"organization": cty.StringVal("hashicorp"),
   508  		"token":        cty.NullVal(cty.String),
   509  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   510  			"name": cty.NullVal(cty.String),
   511  			"tags": cty.SetVal(
   512  				[]cty.Value{
   513  					cty.StringVal("billing"),
   514  				},
   515  			),
   516  			"project": cty.NullVal(cty.String),
   517  		}),
   518  	})
   519  
   520  	handlers := map[string]func(http.ResponseWriter, *http.Request){
   521  		"/api/v2/ping": func(w http.ResponseWriter, r *http.Request) {
   522  			w.Header().Set("Content-Type", "application/json")
   523  			w.Header().Set("TFP-API-Version", "2.4")
   524  		},
   525  	}
   526  	s := testServerWithHandlers(handlers)
   527  
   528  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
   529  	b.runningInAutomation = true
   530  
   531  	confDiags := b.Configure(config)
   532  	if confDiags.Err() == nil {
   533  		t.Fatalf("expected configure to error")
   534  	}
   535  
   536  	expected := `This version of cloud backend does not support the state mechanism
   537  attempting to be used by the platform. This should never happen.`
   538  	if !strings.Contains(confDiags.Err().Error(), expected) {
   539  		t.Fatalf("expected configure to error with %q, got %q", expected, confDiags.Err().Error())
   540  	}
   541  }
   542  
   543  func TestCloud_setUnavailableTerraformVersion(t *testing.T) {
   544  	// go-tfe returns an error IRL if you try to set a Terraform version that's
   545  	// not available in your TFC instance. To test this, tfe_client_mock errors if
   546  	// you try to set any Terraform version for this specific workspace name.
   547  	workspaceName := "unavailable-terraform-version"
   548  
   549  	config := cty.ObjectVal(map[string]cty.Value{
   550  		"hostname":     cty.StringVal(tfeHost),
   551  		"organization": cty.StringVal("hashicorp"),
   552  		"token":        cty.NullVal(cty.String),
   553  		"workspaces": cty.ObjectVal(map[string]cty.Value{
   554  			"name": cty.NullVal(cty.String),
   555  			"tags": cty.SetVal(
   556  				[]cty.Value{
   557  					cty.StringVal("sometag"),
   558  				},
   559  			),
   560  			"project": cty.NullVal(cty.String),
   561  		}),
   562  	})
   563  
   564  	b, _, bCleanup := testBackend(t, config, nil)
   565  	defer bCleanup()
   566  
   567  	// Make sure the workspace doesn't exist yet -- otherwise, we can't test what
   568  	// happens when a workspace gets created. This is why we can't use "name" in
   569  	// the backend config above, btw: if you do, testBackend() creates the default
   570  	// workspace before we get a chance to do anything.
   571  	_, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName)
   572  	if err != tfe.ErrResourceNotFound {
   573  		t.Fatalf("the workspace we were about to try and create (%s/%s) already exists in the mocks somehow, so this test isn't trustworthy anymore", b.organization, workspaceName)
   574  	}
   575  
   576  	_, err = b.StateMgr(workspaceName)
   577  	if err != nil {
   578  		t.Fatalf("expected no error from StateMgr, despite not being able to set remote TF version: %#v", err)
   579  	}
   580  	// Make sure the workspace was created:
   581  	workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, workspaceName)
   582  	if err != nil {
   583  		t.Fatalf("b.StateMgr() didn't actually create the desired workspace")
   584  	}
   585  	// Make sure our mocks still error as expected, using the same update function b.StateMgr() would call:
   586  	_, err = b.client.Workspaces.UpdateByID(
   587  		context.Background(),
   588  		workspace.ID,
   589  		tfe.WorkspaceUpdateOptions{TerraformVersion: tfe.String("1.1.0")},
   590  	)
   591  	if err == nil {
   592  		t.Fatalf("the mocks aren't emulating a nonexistent remote TF version correctly, so this test isn't trustworthy anymore")
   593  	}
   594  }
   595  
   596  func TestCloud_setConfigurationFieldsHappyPath(t *testing.T) {
   597  	cases := map[string]struct {
   598  		obj                   cty.Value
   599  		envVars               map[string]string
   600  		expectedHostname      string
   601  		expectedOrganization  string
   602  		expectedWorkspaceName string
   603  		expectedProjectName   string
   604  		expectedWorkspaceTags map[string]struct{}
   605  		expectedForceLocal    bool
   606  	}{
   607  		"with hostname, organization and tags set": {
   608  			obj: cty.ObjectVal(map[string]cty.Value{
   609  				"organization": cty.StringVal("opentofu"),
   610  				"hostname":     cty.StringVal("opentofu.org"),
   611  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   612  					"name":    cty.NullVal(cty.String),
   613  					"tags":    cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}),
   614  					"project": cty.NullVal(cty.String),
   615  				}),
   616  			}),
   617  			expectedHostname:      "opentofu.org",
   618  			expectedOrganization:  "opentofu",
   619  			expectedWorkspaceTags: map[string]struct{}{"foo": {}, "bar": {}},
   620  		},
   621  		"with hostname and workspace name set": {
   622  			obj: cty.ObjectVal(map[string]cty.Value{
   623  				"organization": cty.NullVal(cty.String),
   624  				"hostname":     cty.StringVal("opentofu.org"),
   625  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   626  					"name":    cty.StringVal("prod"),
   627  					"tags":    cty.NullVal(cty.Set(cty.String)),
   628  					"project": cty.NullVal(cty.String),
   629  				}),
   630  			}),
   631  			expectedHostname:      "opentofu.org",
   632  			expectedWorkspaceName: "prod",
   633  		},
   634  		"with hostname and project name set": {
   635  			obj: cty.ObjectVal(map[string]cty.Value{
   636  				"organization": cty.NullVal(cty.String),
   637  				"hostname":     cty.StringVal("opentofu.org"),
   638  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   639  					"name":    cty.NullVal(cty.String),
   640  					"tags":    cty.NullVal(cty.Set(cty.String)),
   641  					"project": cty.StringVal("my-project"),
   642  				}),
   643  			}),
   644  			expectedHostname:    "opentofu.org",
   645  			expectedProjectName: "my-project",
   646  		},
   647  		"with hostname and force local set (env var)": {
   648  			obj: cty.ObjectVal(map[string]cty.Value{
   649  				"organization": cty.NullVal(cty.String),
   650  				"hostname":     cty.StringVal("opentofu.org"),
   651  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   652  					"name":    cty.NullVal(cty.String),
   653  					"tags":    cty.NullVal(cty.Set(cty.String)),
   654  					"project": cty.NullVal(cty.String),
   655  				}),
   656  			}),
   657  			expectedHostname: "opentofu.org",
   658  			envVars: map[string]string{
   659  				"TF_FORCE_LOCAL_BACKEND": "1",
   660  			},
   661  			expectedForceLocal: true,
   662  		},
   663  		"with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": {
   664  			// see: https://github.com/opentofu/opentofu/issues/814
   665  			obj: cty.ObjectVal(map[string]cty.Value{
   666  				"organization": cty.NullVal(cty.String),
   667  				"hostname":     cty.StringVal("opentofu.org"),
   668  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   669  					"name":    cty.NullVal(cty.String),
   670  					"tags":    cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}),
   671  					"project": cty.NullVal(cty.String),
   672  				}),
   673  			}),
   674  			envVars: map[string]string{
   675  				"TF_WORKSPACE": "foo",
   676  			},
   677  			expectedHostname:      "opentofu.org",
   678  			expectedWorkspaceName: "foo",
   679  			expectedWorkspaceTags: nil,
   680  		},
   681  		"with hostname and workspace name set, and TF_WORKSPACE specified": {
   682  			obj: cty.ObjectVal(map[string]cty.Value{
   683  				"organization": cty.NullVal(cty.String),
   684  				"hostname":     cty.StringVal("opentofu.org"),
   685  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   686  					"name":    cty.StringVal("old"),
   687  					"tags":    cty.NullVal(cty.Set(cty.String)),
   688  					"project": cty.NullVal(cty.String),
   689  				}),
   690  			}),
   691  			envVars: map[string]string{
   692  				"TF_WORKSPACE": "new",
   693  			},
   694  			expectedHostname:      "opentofu.org",
   695  			expectedWorkspaceName: "old",
   696  			expectedWorkspaceTags: nil,
   697  		},
   698  		"with hostname and project set, and project overwritten by TF_CLOUD_PROJECT": {
   699  			obj: cty.ObjectVal(map[string]cty.Value{
   700  				"organization": cty.NullVal(cty.String),
   701  				"hostname":     cty.StringVal("opentofu.org"),
   702  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   703  					"name":    cty.NullVal(cty.String),
   704  					"tags":    cty.NullVal(cty.Set(cty.String)),
   705  					"project": cty.StringVal("old"),
   706  				}),
   707  			}),
   708  			envVars: map[string]string{
   709  				"TF_CLOUD_PROJECT": "new",
   710  			},
   711  			expectedHostname:    "opentofu.org",
   712  			expectedProjectName: "old",
   713  		},
   714  		"with hostname set, and project specified by TF_CLOUD_PROJECT": {
   715  			obj: cty.ObjectVal(map[string]cty.Value{
   716  				"organization": cty.NullVal(cty.String),
   717  				"hostname":     cty.StringVal("opentofu.org"),
   718  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   719  					"name":    cty.NullVal(cty.String),
   720  					"tags":    cty.NullVal(cty.Set(cty.String)),
   721  					"project": cty.NullVal(cty.String),
   722  				}),
   723  			}),
   724  			envVars: map[string]string{
   725  				"TF_CLOUD_PROJECT": "new",
   726  			},
   727  			expectedHostname:    "opentofu.org",
   728  			expectedProjectName: "new",
   729  		},
   730  		"with hostname set, and organization specified by TF_CLOUD_ORGANIZATION": {
   731  			obj: cty.ObjectVal(map[string]cty.Value{
   732  				"hostname":     cty.StringVal("opentofu.org"),
   733  				"token":        cty.NullVal(cty.String),
   734  				"organization": cty.NullVal(cty.String),
   735  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   736  					"name":    cty.NullVal(cty.String),
   737  					"tags":    cty.NullVal(cty.Set(cty.String)),
   738  					"project": cty.NullVal(cty.String),
   739  				}),
   740  			}),
   741  			envVars: map[string]string{
   742  				"TF_CLOUD_ORGANIZATION": "my-org",
   743  			},
   744  			expectedHostname:     "opentofu.org",
   745  			expectedOrganization: "my-org",
   746  		},
   747  		"with hostname set, and TF_CLOUD_HOSTNAME defined": {
   748  			obj: cty.ObjectVal(map[string]cty.Value{
   749  				"hostname":     cty.StringVal("opentofu.org"),
   750  				"token":        cty.NullVal(cty.String),
   751  				"organization": cty.NullVal(cty.String),
   752  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   753  					"name":    cty.NullVal(cty.String),
   754  					"tags":    cty.NullVal(cty.Set(cty.String)),
   755  					"project": cty.NullVal(cty.String),
   756  				}),
   757  			}),
   758  			envVars: map[string]string{
   759  				"TF_CLOUD_HOSTNAME": "new",
   760  			},
   761  			expectedHostname: "opentofu.org",
   762  		},
   763  		"with hostname specified by TF_CLOUD_HOSTNAME": {
   764  			obj: cty.ObjectVal(map[string]cty.Value{
   765  				"hostname":     cty.NullVal(cty.String),
   766  				"token":        cty.NullVal(cty.String),
   767  				"organization": cty.NullVal(cty.String),
   768  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   769  					"name":    cty.NullVal(cty.String),
   770  					"tags":    cty.NullVal(cty.Set(cty.String)),
   771  					"project": cty.NullVal(cty.String),
   772  				}),
   773  			}),
   774  			envVars: map[string]string{
   775  				"TF_CLOUD_HOSTNAME": "new",
   776  			},
   777  			expectedHostname: "new",
   778  		},
   779  		"with nothing set, all configured using env vars": {
   780  			obj: cty.ObjectVal(map[string]cty.Value{
   781  				"hostname":     cty.NullVal(cty.String),
   782  				"organization": cty.NullVal(cty.String),
   783  				"workspaces":   cty.NullVal(cty.String),
   784  			}),
   785  			envVars: map[string]string{
   786  				"TF_CLOUD_HOSTNAME":     "opentofu.org",
   787  				"TF_CLOUD_ORGANIZATION": "opentofu",
   788  				"TF_WORKSPACE":          "foo",
   789  				"TF_CLOUD_PROJECT":      "bar",
   790  			},
   791  			expectedHostname:      "opentofu.org",
   792  			expectedOrganization:  "opentofu",
   793  			expectedWorkspaceName: "foo",
   794  			expectedProjectName:   "bar",
   795  		},
   796  	}
   797  
   798  	for name, tc := range cases {
   799  		t.Run(name, func(t *testing.T) {
   800  			for k, v := range tc.envVars {
   801  				t.Setenv(k, v)
   802  			}
   803  
   804  			b := &Cloud{}
   805  			errDiags := b.setConfigurationFields(tc.obj)
   806  
   807  			if errDiags.HasErrors() {
   808  				t.Fatalf("%s: unexpected validation result: %v", name, errDiags.Err())
   809  			}
   810  			if b.hostname != tc.expectedHostname {
   811  				t.Fatalf("%s: expected hostname %s to match configured hostname %s", name, b.hostname, tc.expectedHostname)
   812  			}
   813  			if b.organization != tc.expectedOrganization {
   814  				t.Fatalf("%s: expected organization (%s) to match configured organization (%s)", name, b.organization, tc.expectedOrganization)
   815  			}
   816  			if b.WorkspaceMapping.Name != tc.expectedWorkspaceName {
   817  				t.Fatalf("%s: expected workspace name mapping (%s) to match configured workspace name (%s)", name, b.WorkspaceMapping.Name, tc.expectedWorkspaceName)
   818  			}
   819  			if b.forceLocal != tc.expectedForceLocal {
   820  				t.Fatalf("%s: expected force local backend to be set to %v", name, tc.expectedForceLocal)
   821  			}
   822  			if b.WorkspaceMapping.Project != tc.expectedProjectName {
   823  				t.Fatalf("%s: expected project name mapping (%s) to match configured project name (%s)", name, b.WorkspaceMapping.Project, tc.expectedProjectName)
   824  			}
   825  
   826  			// read map of configured tags
   827  			gotTags := map[string]struct{}{}
   828  			for _, v := range b.WorkspaceMapping.Tags {
   829  				gotTags[v] = struct{}{}
   830  			}
   831  
   832  			if len(gotTags) != len(tc.expectedWorkspaceTags) {
   833  				t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags)
   834  			}
   835  
   836  			for k := range tc.expectedWorkspaceTags {
   837  				if _, ok := gotTags[k]; !ok {
   838  					t.Fatalf("%s: unordered workspace tags (%v) don't match configuration (%v)", name, gotTags, tc.expectedWorkspaceTags)
   839  				}
   840  			}
   841  		})
   842  	}
   843  }
   844  
   845  func TestCloud_setConfigurationFieldsUnhappyPath(t *testing.T) {
   846  	cases := map[string]struct {
   847  		obj         cty.Value
   848  		envVars     map[string]string
   849  		wantSummary string
   850  		wantDetail  string
   851  	}{
   852  		"cloud block is not configured": {
   853  			obj: cty.ObjectVal(map[string]cty.Value{
   854  				"organization": cty.NullVal(cty.String),
   855  				"hostname":     cty.NullVal(cty.String),
   856  				"workspaces":   cty.NullVal(cty.String),
   857  			}),
   858  			wantSummary: "Hostname is required for the cloud backend",
   859  			wantDetail:  `OpenTofu does not provide a default "hostname" attribute, so it must be set to the hostname of the cloud backend.`,
   860  		},
   861  		"with hostname and workspace tags set, and tags overwritten by TF_WORKSPACE": {
   862  			// see: https://github.com/opentofu/opentofu/issues/814
   863  			obj: cty.ObjectVal(map[string]cty.Value{
   864  				"organization": cty.NullVal(cty.String),
   865  				"hostname":     cty.StringVal("opentofu.org"),
   866  				"workspaces": cty.ObjectVal(map[string]cty.Value{
   867  					"name":    cty.NullVal(cty.String),
   868  					"tags":    cty.SetVal([]cty.Value{cty.StringVal("foo"), cty.StringVal("bar")}),
   869  					"project": cty.NullVal(cty.String),
   870  				}),
   871  			}),
   872  			envVars: map[string]string{
   873  				"TF_WORKSPACE": "qux",
   874  			},
   875  			wantSummary: invalidWorkspaceConfigMisconfigurationEnvVar.Description().Summary,
   876  			wantDetail:  invalidWorkspaceConfigMisconfigurationEnvVar.Description().Detail,
   877  		},
   878  	}
   879  
   880  	for name, tc := range cases {
   881  		t.Run(name, func(t *testing.T) {
   882  			for k, v := range tc.envVars {
   883  				t.Setenv(k, v)
   884  			}
   885  
   886  			b := &Cloud{}
   887  			errDiags := b.setConfigurationFields(tc.obj)
   888  			if (tc.wantDetail != "" || tc.wantSummary != "") != errDiags.HasErrors() {
   889  				t.Fatalf("%s error expected", name)
   890  			}
   891  
   892  			gotSummary := errDiags[0].Description().Summary
   893  			if gotSummary != tc.wantSummary {
   894  				t.Fatalf("%s diagnostic summary mismatch, want: %s, got: %s", name, tc.wantSummary, gotSummary)
   895  			}
   896  
   897  			gotDetail := errDiags[0].Description().Detail
   898  			if gotDetail != tc.wantDetail {
   899  				t.Fatalf("%s diagnostic details mismatch, want: %s, got: %s", name, tc.wantDetail, gotDetail)
   900  			}
   901  		})
   902  	}
   903  }
   904  
   905  func TestCloud_localBackend(t *testing.T) {
   906  	b, bCleanup := testBackendWithName(t)
   907  	defer bCleanup()
   908  
   909  	local, ok := b.local.(*backendLocal.Local)
   910  	if !ok {
   911  		t.Fatalf("expected b.local to be \"*local.Local\", got: %T", b.local)
   912  	}
   913  
   914  	cloud, ok := local.Backend.(*Cloud)
   915  	if !ok {
   916  		t.Fatalf("expected local.Backend to be *cloud.Cloud, got: %T", cloud)
   917  	}
   918  }
   919  
   920  func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) {
   921  	b, bCleanup := testBackendWithName(t)
   922  	defer bCleanup()
   923  
   924  	if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil {
   925  		t.Fatalf("expected no error, got %v", err)
   926  	}
   927  
   928  	if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported {
   929  		t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err)
   930  	}
   931  }
   932  
   933  func TestCloud_StateMgr_versionCheck(t *testing.T) {
   934  	b, bCleanup := testBackendWithName(t)
   935  	defer bCleanup()
   936  
   937  	// Some fixed versions for testing with. This logic is a simple string
   938  	// comparison, so we don't need many test cases.
   939  	v0135 := version.Must(version.NewSemver("0.13.5"))
   940  	v0140 := version.Must(version.NewSemver("0.14.0"))
   941  
   942  	// Save original local version state and restore afterwards
   943  	p := tfversion.Prerelease
   944  	v := tfversion.Version
   945  	s := tfversion.SemVer
   946  	defer func() {
   947  		tfversion.Prerelease = p
   948  		tfversion.Version = v
   949  		tfversion.SemVer = s
   950  	}()
   951  
   952  	// For this test, the local Terraform version is set to 0.14.0
   953  	tfversion.Prerelease = ""
   954  	tfversion.Version = v0140.String()
   955  	tfversion.SemVer = v0140
   956  
   957  	// Update the mock remote workspace Terraform version to match the local
   958  	// Terraform version
   959  	if _, err := b.client.Workspaces.Update(
   960  		context.Background(),
   961  		b.organization,
   962  		b.WorkspaceMapping.Name,
   963  		tfe.WorkspaceUpdateOptions{
   964  			TerraformVersion: tfe.String(v0140.String()),
   965  		},
   966  	); err != nil {
   967  		t.Fatalf("error: %v", err)
   968  	}
   969  
   970  	// This should succeed
   971  	if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil {
   972  		t.Fatalf("expected no error, got %v", err)
   973  	}
   974  
   975  	// Now change the remote workspace to a different Terraform version
   976  	if _, err := b.client.Workspaces.Update(
   977  		context.Background(),
   978  		b.organization,
   979  		b.WorkspaceMapping.Name,
   980  		tfe.WorkspaceUpdateOptions{
   981  			TerraformVersion: tfe.String(v0135.String()),
   982  		},
   983  	); err != nil {
   984  		t.Fatalf("error: %v", err)
   985  	}
   986  
   987  	// This should fail
   988  	want := `Remote workspace TF version "0.13.5" does not match local OpenTofu version "0.14.0"`
   989  	if _, err := b.StateMgr(testBackendSingleWorkspaceName); err.Error() != want {
   990  		t.Fatalf("wrong error\n got: %v\nwant: %v", err.Error(), want)
   991  	}
   992  }
   993  
   994  func TestCloud_StateMgr_versionCheckLatest(t *testing.T) {
   995  	b, bCleanup := testBackendWithName(t)
   996  	defer bCleanup()
   997  
   998  	v0140 := version.Must(version.NewSemver("0.14.0"))
   999  
  1000  	// Save original local version state and restore afterwards
  1001  	p := tfversion.Prerelease
  1002  	v := tfversion.Version
  1003  	s := tfversion.SemVer
  1004  	defer func() {
  1005  		tfversion.Prerelease = p
  1006  		tfversion.Version = v
  1007  		tfversion.SemVer = s
  1008  	}()
  1009  
  1010  	// For this test, the local Terraform version is set to 0.14.0
  1011  	tfversion.Prerelease = ""
  1012  	tfversion.Version = v0140.String()
  1013  	tfversion.SemVer = v0140
  1014  
  1015  	// Update the remote workspace to the pseudo-version "latest"
  1016  	if _, err := b.client.Workspaces.Update(
  1017  		context.Background(),
  1018  		b.organization,
  1019  		b.WorkspaceMapping.Name,
  1020  		tfe.WorkspaceUpdateOptions{
  1021  			TerraformVersion: tfe.String("latest"),
  1022  		},
  1023  	); err != nil {
  1024  		t.Fatalf("error: %v", err)
  1025  	}
  1026  
  1027  	// This should succeed despite not being a string match
  1028  	if _, err := b.StateMgr(testBackendSingleWorkspaceName); err != nil {
  1029  		t.Fatalf("expected no error, got %v", err)
  1030  	}
  1031  }
  1032  
  1033  func TestCloud_VerifyWorkspaceTerraformVersion(t *testing.T) {
  1034  	testCases := []struct {
  1035  		local         string
  1036  		remote        string
  1037  		executionMode string
  1038  		wantErr       bool
  1039  	}{
  1040  		{"0.13.5", "0.13.5", "agent", false},
  1041  		{"0.14.0", "0.13.5", "remote", true},
  1042  		{"0.14.0", "0.13.5", "local", false},
  1043  		{"0.14.0", "0.14.1", "remote", false},
  1044  		{"0.14.0", "1.0.99", "remote", false},
  1045  		{"0.14.0", "1.1.0", "remote", false},
  1046  		{"0.14.0", "1.3.0", "remote", true},
  1047  		{"1.2.0", "1.2.99", "remote", false},
  1048  		{"1.2.0", "1.3.0", "remote", true},
  1049  		{"0.15.0", "latest", "remote", false},
  1050  		{"1.1.5", "~> 1.1.1", "remote", false},
  1051  		{"1.1.5", "> 1.1.0, < 1.3.0", "remote", false},
  1052  		{"1.1.5", "~> 1.0.1", "remote", true},
  1053  		// pre-release versions are comparable within their pre-release stage (dev,
  1054  		// alpha, beta), but not comparable to different stages and not comparable
  1055  		// to final releases.
  1056  		{"1.1.0-beta1", "1.1.0-beta1", "remote", false},
  1057  		{"1.1.0-beta1", "~> 1.1.0-beta", "remote", false},
  1058  		{"1.1.0", "~> 1.1.0-beta", "remote", true},
  1059  		{"1.1.0-beta1", "~> 1.1.0-dev", "remote", true},
  1060  	}
  1061  	for _, tc := range testCases {
  1062  		t.Run(fmt.Sprintf("local %s, remote %s", tc.local, tc.remote), func(t *testing.T) {
  1063  			b, bCleanup := testBackendWithName(t)
  1064  			defer bCleanup()
  1065  
  1066  			local := version.Must(version.NewSemver(tc.local))
  1067  
  1068  			// Save original local version state and restore afterwards
  1069  			p := tfversion.Prerelease
  1070  			v := tfversion.Version
  1071  			s := tfversion.SemVer
  1072  			defer func() {
  1073  				tfversion.Prerelease = p
  1074  				tfversion.Version = v
  1075  				tfversion.SemVer = s
  1076  			}()
  1077  
  1078  			// Override local version as specified
  1079  			tfversion.Prerelease = ""
  1080  			tfversion.Version = local.String()
  1081  			tfversion.SemVer = local
  1082  
  1083  			// Update the mock remote workspace Terraform version to the
  1084  			// specified remote version
  1085  			if _, err := b.client.Workspaces.Update(
  1086  				context.Background(),
  1087  				b.organization,
  1088  				b.WorkspaceMapping.Name,
  1089  				tfe.WorkspaceUpdateOptions{
  1090  					ExecutionMode:    &tc.executionMode,
  1091  					TerraformVersion: tfe.String(tc.remote),
  1092  				},
  1093  			); err != nil {
  1094  				t.Fatalf("error: %v", err)
  1095  			}
  1096  
  1097  			diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName)
  1098  			if tc.wantErr {
  1099  				if len(diags) != 1 {
  1100  					t.Fatal("expected diag, but none returned")
  1101  				}
  1102  				if got := diags.Err().Error(); !strings.Contains(got, "Incompatible TF version") {
  1103  					t.Fatalf("unexpected error: %s", got)
  1104  				}
  1105  			} else {
  1106  				if len(diags) != 0 {
  1107  					t.Fatalf("unexpected diags: %s", diags.Err())
  1108  				}
  1109  			}
  1110  		})
  1111  	}
  1112  }
  1113  
  1114  func TestCloud_VerifyWorkspaceTerraformVersion_workspaceErrors(t *testing.T) {
  1115  	b, bCleanup := testBackendWithName(t)
  1116  	defer bCleanup()
  1117  
  1118  	// Attempting to check the version against a workspace which doesn't exist
  1119  	// should result in no errors
  1120  	diags := b.VerifyWorkspaceTerraformVersion("invalid-workspace")
  1121  	if len(diags) != 0 {
  1122  		t.Fatalf("unexpected error: %s", diags.Err())
  1123  	}
  1124  
  1125  	// Use a special workspace ID to trigger a 500 error, which should result
  1126  	// in a failed check
  1127  	diags = b.VerifyWorkspaceTerraformVersion("network-error")
  1128  	if len(diags) != 1 {
  1129  		t.Fatal("expected diag, but none returned")
  1130  	}
  1131  	if got := diags.Err().Error(); !strings.Contains(got, "Error looking up workspace: Workspace read failed") {
  1132  		t.Fatalf("unexpected error: %s", got)
  1133  	}
  1134  
  1135  	// Update the mock remote workspace Terraform version to an invalid version
  1136  	if _, err := b.client.Workspaces.Update(
  1137  		context.Background(),
  1138  		b.organization,
  1139  		b.WorkspaceMapping.Name,
  1140  		tfe.WorkspaceUpdateOptions{
  1141  			TerraformVersion: tfe.String("1.0.cheetarah"),
  1142  		},
  1143  	); err != nil {
  1144  		t.Fatalf("error: %v", err)
  1145  	}
  1146  	diags = b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName)
  1147  
  1148  	if len(diags) != 1 {
  1149  		t.Fatal("expected diag, but none returned")
  1150  	}
  1151  	if got := diags.Err().Error(); !strings.Contains(got, "Incompatible TF version: The remote workspace specified") {
  1152  		t.Fatalf("unexpected error: %s", got)
  1153  	}
  1154  }
  1155  
  1156  func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) {
  1157  	b, bCleanup := testBackendWithName(t)
  1158  	defer bCleanup()
  1159  
  1160  	// If the ignore flag is set, the behaviour changes
  1161  	b.IgnoreVersionConflict()
  1162  
  1163  	// Different local & remote versions to cause an error
  1164  	local := version.Must(version.NewSemver("0.14.0"))
  1165  	remote := version.Must(version.NewSemver("0.13.5"))
  1166  
  1167  	// Save original local version state and restore afterwards
  1168  	p := tfversion.Prerelease
  1169  	v := tfversion.Version
  1170  	s := tfversion.SemVer
  1171  	defer func() {
  1172  		tfversion.Prerelease = p
  1173  		tfversion.Version = v
  1174  		tfversion.SemVer = s
  1175  	}()
  1176  
  1177  	// Override local version as specified
  1178  	tfversion.Prerelease = ""
  1179  	tfversion.Version = local.String()
  1180  	tfversion.SemVer = local
  1181  
  1182  	// Update the mock remote workspace Terraform version to the
  1183  	// specified remote version
  1184  	if _, err := b.client.Workspaces.Update(
  1185  		context.Background(),
  1186  		b.organization,
  1187  		b.WorkspaceMapping.Name,
  1188  		tfe.WorkspaceUpdateOptions{
  1189  			TerraformVersion: tfe.String(remote.String()),
  1190  		},
  1191  	); err != nil {
  1192  		t.Fatalf("error: %v", err)
  1193  	}
  1194  
  1195  	diags := b.VerifyWorkspaceTerraformVersion(backend.DefaultStateName)
  1196  	if len(diags) != 1 {
  1197  		t.Fatal("expected diag, but none returned")
  1198  	}
  1199  
  1200  	if got, want := diags[0].Severity(), tfdiags.Warning; got != want {
  1201  		t.Errorf("wrong severity: got %#v, want %#v", got, want)
  1202  	}
  1203  	if got, want := diags[0].Description().Summary, "Incompatible TF version"; got != want {
  1204  		t.Errorf("wrong summary: got %s, want %s", got, want)
  1205  	}
  1206  	wantDetail := "The local OpenTofu version (0.14.0) does not meet the version requirements for remote workspace hashicorp/app-prod (0.13.5)."
  1207  	if got := diags[0].Description().Detail; got != wantDetail {
  1208  		t.Errorf("wrong summary: got %s, want %s", got, wantDetail)
  1209  	}
  1210  }
  1211  
  1212  func TestCloudBackend_DeleteWorkspace_SafeAndForce(t *testing.T) {
  1213  	b, bCleanup := testBackendWithTags(t)
  1214  	defer bCleanup()
  1215  	safeDeleteWorkspaceName := "safe-delete-workspace"
  1216  	forceDeleteWorkspaceName := "force-delete-workspace"
  1217  
  1218  	_, err := b.StateMgr(safeDeleteWorkspaceName)
  1219  	if err != nil {
  1220  		t.Fatalf("error: %s", err)
  1221  	}
  1222  
  1223  	_, err = b.StateMgr(forceDeleteWorkspaceName)
  1224  	if err != nil {
  1225  		t.Fatalf("error: %s", err)
  1226  	}
  1227  
  1228  	// sanity check that the mock now contains two workspaces
  1229  	wl, err := b.Workspaces()
  1230  	if err != nil {
  1231  		t.Fatalf("error fetching workspace names: %v", err)
  1232  	}
  1233  	if len(wl) != 2 {
  1234  		t.Fatalf("expected 2 workspaced but got %d", len(wl))
  1235  	}
  1236  
  1237  	c := context.Background()
  1238  	safeDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, safeDeleteWorkspaceName)
  1239  	if err != nil {
  1240  		t.Fatalf("error fetching workspace: %v", err)
  1241  	}
  1242  
  1243  	// Lock a workspace so that it should fail to be safe deleted
  1244  	_, err = b.client.Workspaces.Lock(context.Background(), safeDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")})
  1245  	if err != nil {
  1246  		t.Fatalf("error locking workspace: %v", err)
  1247  	}
  1248  	err = b.DeleteWorkspace(safeDeleteWorkspaceName, false)
  1249  	if err == nil {
  1250  		t.Fatalf("workspace should have failed to safe delete")
  1251  	}
  1252  
  1253  	// unlock the workspace and confirm that safe-delete now works
  1254  	_, err = b.client.Workspaces.Unlock(context.Background(), safeDeleteWorkspace.ID)
  1255  	if err != nil {
  1256  		t.Fatalf("error unlocking workspace: %v", err)
  1257  	}
  1258  	err = b.DeleteWorkspace(safeDeleteWorkspaceName, false)
  1259  	if err != nil {
  1260  		t.Fatalf("error safe deleting workspace: %v", err)
  1261  	}
  1262  
  1263  	// lock a workspace and then confirm that force deleting it works
  1264  	forceDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, forceDeleteWorkspaceName)
  1265  	if err != nil {
  1266  		t.Fatalf("error fetching workspace: %v", err)
  1267  	}
  1268  	_, err = b.client.Workspaces.Lock(context.Background(), forceDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")})
  1269  	if err != nil {
  1270  		t.Fatalf("error locking workspace: %v", err)
  1271  	}
  1272  	err = b.DeleteWorkspace(forceDeleteWorkspaceName, true)
  1273  	if err != nil {
  1274  		t.Fatalf("error force deleting workspace: %v", err)
  1275  	}
  1276  }
  1277  
  1278  func TestCloudBackend_DeleteWorkspace_DoesNotExist(t *testing.T) {
  1279  	b, bCleanup := testBackendWithTags(t)
  1280  	defer bCleanup()
  1281  
  1282  	err := b.DeleteWorkspace("non-existent-workspace", false)
  1283  	if err != nil {
  1284  		t.Fatalf("expected deleting a workspace which does not exist to succeed")
  1285  	}
  1286  }
  1287  
  1288  func TestCloud_ServiceDiscoveryAliases(t *testing.T) {
  1289  	s := testServer(t)
  1290  	b := New(testDisco(s), encryption.StateEncryptionDisabled())
  1291  
  1292  	diag := b.Configure(cty.ObjectVal(map[string]cty.Value{
  1293  		"hostname":     cty.StringVal(tfeHost),
  1294  		"organization": cty.StringVal("hashicorp"),
  1295  		"token":        cty.NullVal(cty.String),
  1296  		"workspaces": cty.ObjectVal(map[string]cty.Value{
  1297  			"name":    cty.StringVal("prod"),
  1298  			"tags":    cty.NullVal(cty.Set(cty.String)),
  1299  			"project": cty.NullVal(cty.String),
  1300  		}),
  1301  	}))
  1302  	if diag.HasErrors() {
  1303  		t.Fatalf("expected no diagnostic errors, got %s", diag.Err())
  1304  	}
  1305  
  1306  	aliases, err := b.ServiceDiscoveryAliases()
  1307  	if err != nil {
  1308  		t.Fatalf("expected no errors, got %s", err)
  1309  	}
  1310  	if len(aliases) != 1 {
  1311  		t.Fatalf("expected 1 alias but got %d", len(aliases))
  1312  	}
  1313  }