github.com/kanishk98/terraform@v1.3.0-dev.0.20220917174235-661ca8088a6a/internal/cloud/backend_test.go (about)

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