github.com/opentofu/opentofu@v1.7.1/internal/cloud/testing.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 "bytes" 10 "context" 11 "encoding/json" 12 "fmt" 13 "io" 14 "net/http" 15 "net/http/httptest" 16 "net/url" 17 "os" 18 "path" 19 "strconv" 20 "testing" 21 "time" 22 23 tfe "github.com/hashicorp/go-tfe" 24 svchost "github.com/hashicorp/terraform-svchost" 25 "github.com/hashicorp/terraform-svchost/auth" 26 "github.com/hashicorp/terraform-svchost/disco" 27 "github.com/mitchellh/cli" 28 "github.com/mitchellh/colorstring" 29 "github.com/zclconf/go-cty/cty" 30 31 "github.com/opentofu/opentofu/internal/backend" 32 "github.com/opentofu/opentofu/internal/configs" 33 "github.com/opentofu/opentofu/internal/configs/configschema" 34 "github.com/opentofu/opentofu/internal/encryption" 35 "github.com/opentofu/opentofu/internal/httpclient" 36 "github.com/opentofu/opentofu/internal/providers" 37 "github.com/opentofu/opentofu/internal/states" 38 "github.com/opentofu/opentofu/internal/states/statefile" 39 "github.com/opentofu/opentofu/internal/tfdiags" 40 "github.com/opentofu/opentofu/internal/tofu" 41 "github.com/opentofu/opentofu/version" 42 43 backendLocal "github.com/opentofu/opentofu/internal/backend/local" 44 ) 45 46 const ( 47 testCred = "test-auth-token" 48 ) 49 50 var ( 51 tfeHost = "app.terraform.io" 52 credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ 53 svchost.Hostname(tfeHost): {"token": testCred}, 54 }) 55 testBackendSingleWorkspaceName = "app-prod" 56 defaultTFCPing = map[string]func(http.ResponseWriter, *http.Request){ 57 "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { 58 w.Header().Set("Content-Type", "application/json") 59 w.Header().Set("TFP-API-Version", "2.5") 60 w.Header().Set("TFP-AppName", "Terraform Cloud") 61 }, 62 } 63 ) 64 65 func skipIfTFENotEnabled(t *testing.T) { 66 if os.Getenv("TF_TFC_TEST") == "" { 67 t.Skip("this test accesses " + tfeHost + "; set TF_TFC_TEST=1 to run it") 68 } 69 } 70 71 // mockInput is a mock implementation of tofu.UIInput. 72 type mockInput struct { 73 answers map[string]string 74 } 75 76 func (m *mockInput) Input(ctx context.Context, opts *tofu.InputOpts) (string, error) { 77 v, ok := m.answers[opts.Id] 78 if !ok { 79 return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) 80 } 81 if v == "wait-for-external-update" { 82 select { 83 case <-ctx.Done(): 84 case <-time.After(time.Minute): 85 } 86 } 87 delete(m.answers, opts.Id) 88 return v, nil 89 } 90 91 func testInput(t *testing.T, answers map[string]string) *mockInput { 92 skipIfTFENotEnabled(t) 93 return &mockInput{answers: answers} 94 } 95 96 func testBackendWithName(t *testing.T) (*Cloud, func()) { 97 b, _, c := testBackendAndMocksWithName(t) 98 return b, c 99 } 100 101 func testBackendAndMocksWithName(t *testing.T) (*Cloud, *MockClient, func()) { 102 obj := cty.ObjectVal(map[string]cty.Value{ 103 "hostname": cty.StringVal(tfeHost), 104 "organization": cty.StringVal("hashicorp"), 105 "token": cty.NullVal(cty.String), 106 "workspaces": cty.ObjectVal(map[string]cty.Value{ 107 "name": cty.StringVal(testBackendSingleWorkspaceName), 108 "tags": cty.NullVal(cty.Set(cty.String)), 109 "project": cty.NullVal(cty.String), 110 }), 111 }) 112 return testBackend(t, obj, defaultTFCPing) 113 } 114 115 func testBackendWithTags(t *testing.T) (*Cloud, func()) { 116 obj := cty.ObjectVal(map[string]cty.Value{ 117 "hostname": cty.StringVal(tfeHost), 118 "organization": cty.StringVal("hashicorp"), 119 "token": cty.NullVal(cty.String), 120 "workspaces": cty.ObjectVal(map[string]cty.Value{ 121 "name": cty.NullVal(cty.String), 122 "tags": cty.SetVal( 123 []cty.Value{ 124 cty.StringVal("billing"), 125 }, 126 ), 127 "project": cty.NullVal(cty.String), 128 }), 129 }) 130 b, _, c := testBackend(t, obj, nil) 131 return b, c 132 } 133 134 func testBackendNoOperations(t *testing.T) (*Cloud, func()) { 135 obj := cty.ObjectVal(map[string]cty.Value{ 136 "hostname": cty.StringVal(tfeHost), 137 "organization": cty.StringVal("no-operations"), 138 "token": cty.NullVal(cty.String), 139 "workspaces": cty.ObjectVal(map[string]cty.Value{ 140 "name": cty.StringVal(testBackendSingleWorkspaceName), 141 "tags": cty.NullVal(cty.Set(cty.String)), 142 "project": cty.NullVal(cty.String), 143 }), 144 }) 145 b, _, c := testBackend(t, obj, nil) 146 return b, c 147 } 148 149 func testBackendWithHandlers(t *testing.T, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, func()) { 150 obj := cty.ObjectVal(map[string]cty.Value{ 151 "hostname": cty.StringVal(tfeHost), 152 "organization": cty.StringVal("hashicorp"), 153 "token": cty.NullVal(cty.String), 154 "workspaces": cty.ObjectVal(map[string]cty.Value{ 155 "name": cty.StringVal(testBackendSingleWorkspaceName), 156 "tags": cty.NullVal(cty.Set(cty.String)), 157 "project": cty.NullVal(cty.String), 158 }), 159 }) 160 b, _, c := testBackend(t, obj, handlers) 161 return b, c 162 } 163 164 func testCloudState(t *testing.T) *State { 165 b, bCleanup := testBackendWithName(t) 166 defer bCleanup() 167 168 raw, err := b.StateMgr(testBackendSingleWorkspaceName) 169 if err != nil { 170 t.Fatalf("error: %v", err) 171 } 172 173 return raw.(*State) 174 } 175 176 func testBackendWithOutputs(t *testing.T) (*Cloud, func()) { 177 b, cleanup := testBackendWithName(t) 178 179 // Get a new mock client to use for adding outputs 180 mc := NewMockClient() 181 182 mc.StateVersionOutputs.create("svo-abcd", &tfe.StateVersionOutput{ 183 ID: "svo-abcd", 184 Value: "foobar", 185 Sensitive: true, 186 Type: "string", 187 Name: "sensitive_output", 188 DetailedType: "string", 189 }) 190 191 mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{ 192 ID: "svo-zyxw", 193 Value: "bazqux", 194 Type: "string", 195 Name: "nonsensitive_output", 196 DetailedType: "string", 197 }) 198 199 var dt interface{} 200 var val interface{} 201 err := json.Unmarshal([]byte(`["object", {"foo":"string"}]`), &dt) 202 if err != nil { 203 t.Fatalf("could not unmarshal detailed type: %s", err) 204 } 205 err = json.Unmarshal([]byte(`{"foo":"bar"}`), &val) 206 if err != nil { 207 t.Fatalf("could not unmarshal value: %s", err) 208 } 209 mc.StateVersionOutputs.create("svo-efgh", &tfe.StateVersionOutput{ 210 ID: "svo-efgh", 211 Value: val, 212 Type: "object", 213 Name: "object_output", 214 DetailedType: dt, 215 }) 216 217 err = json.Unmarshal([]byte(`["list", "bool"]`), &dt) 218 if err != nil { 219 t.Fatalf("could not unmarshal detailed type: %s", err) 220 } 221 err = json.Unmarshal([]byte(`[true, false, true, true]`), &val) 222 if err != nil { 223 t.Fatalf("could not unmarshal value: %s", err) 224 } 225 mc.StateVersionOutputs.create("svo-ijkl", &tfe.StateVersionOutput{ 226 ID: "svo-ijkl", 227 Value: val, 228 Type: "array", 229 Name: "list_output", 230 DetailedType: dt, 231 }) 232 233 b.client.StateVersionOutputs = mc.StateVersionOutputs 234 235 return b, cleanup 236 } 237 238 func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.ResponseWriter, *http.Request)) (*Cloud, *MockClient, func()) { 239 skipIfTFENotEnabled(t) 240 var s *httptest.Server 241 if handlers != nil { 242 s = testServerWithHandlers(handlers) 243 } else { 244 s = testServer(t) 245 } 246 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 247 248 // Configure the backend so the client is created. 249 newObj, valDiags := b.PrepareConfig(obj) 250 if len(valDiags) != 0 { 251 t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings()) 252 } 253 obj = newObj 254 255 confDiags := b.Configure(obj) 256 if len(confDiags) != 0 { 257 t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings()) 258 } 259 260 // Get a new mock client. 261 mc := NewMockClient() 262 263 // Replace the services we use with our mock services. 264 b.CLI = cli.NewMockUi() 265 b.client.Applies = mc.Applies 266 b.client.ConfigurationVersions = mc.ConfigurationVersions 267 b.client.CostEstimates = mc.CostEstimates 268 b.client.Organizations = mc.Organizations 269 b.client.Plans = mc.Plans 270 b.client.TaskStages = mc.TaskStages 271 b.client.PolicySetOutcomes = mc.PolicySetOutcomes 272 b.client.PolicyChecks = mc.PolicyChecks 273 b.client.Runs = mc.Runs 274 b.client.RunEvents = mc.RunEvents 275 b.client.StateVersions = mc.StateVersions 276 b.client.StateVersionOutputs = mc.StateVersionOutputs 277 b.client.Variables = mc.Variables 278 b.client.Workspaces = mc.Workspaces 279 280 // Set local to a local test backend. 281 b.local = testLocalBackend(t, b) 282 b.input = true 283 284 baseURL, err := url.Parse("https://" + tfeHost) 285 if err != nil { 286 t.Fatalf("testBackend: failed to parse base URL for client") 287 } 288 baseURL.Path = "/api/v2/" 289 290 readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) { 291 return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID) 292 } 293 294 ctx := context.Background() 295 296 // Create the organization. 297 _, err = b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ 298 Name: tfe.String(b.organization), 299 }) 300 if err != nil { 301 t.Fatalf("error: %v", err) 302 } 303 304 // Create the default workspace if required. 305 if b.WorkspaceMapping.Name != "" { 306 _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ 307 Name: tfe.String(b.WorkspaceMapping.Name), 308 }) 309 if err != nil { 310 t.Fatalf("error: %v", err) 311 } 312 } 313 314 return b, mc, s.Close 315 } 316 317 // testUnconfiguredBackend is used for testing the configuration of the backend 318 // with the mock client 319 func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) { 320 skipIfTFENotEnabled(t) 321 322 s := testServer(t) 323 b := New(testDisco(s), encryption.StateEncryptionDisabled()) 324 325 // Normally, the client is created during configuration, but the configuration uses the 326 // client to read entitlements. 327 var err error 328 b.client, err = tfe.NewClient(&tfe.Config{ 329 Token: "fake-token", 330 }) 331 if err != nil { 332 t.Fatal(err) 333 } 334 335 // Get a new mock client. 336 mc := NewMockClient() 337 338 // Replace the services we use with our mock services. 339 b.CLI = cli.NewMockUi() 340 b.client.Applies = mc.Applies 341 b.client.ConfigurationVersions = mc.ConfigurationVersions 342 b.client.CostEstimates = mc.CostEstimates 343 b.client.Organizations = mc.Organizations 344 b.client.Plans = mc.Plans 345 b.client.PolicySetOutcomes = mc.PolicySetOutcomes 346 b.client.PolicyChecks = mc.PolicyChecks 347 b.client.Runs = mc.Runs 348 b.client.RunEvents = mc.RunEvents 349 b.client.StateVersions = mc.StateVersions 350 b.client.StateVersionOutputs = mc.StateVersionOutputs 351 b.client.Variables = mc.Variables 352 b.client.Workspaces = mc.Workspaces 353 354 baseURL, err := url.Parse("https://" + tfeHost) 355 if err != nil { 356 t.Fatalf("testBackend: failed to parse base URL for client") 357 } 358 baseURL.Path = "/api/v2/" 359 360 readRedactedPlan = func(ctx context.Context, baseURL url.URL, token, planID string) ([]byte, error) { 361 return mc.RedactedPlans.Read(ctx, baseURL.Hostname(), token, planID) 362 } 363 364 // Set local to a local test backend. 365 b.local = testLocalBackend(t, b) 366 367 return b, s.Close 368 } 369 370 func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced { 371 skipIfTFENotEnabled(t) 372 373 b := backendLocal.NewWithBackend(cloud, nil) 374 375 // Add a test provider to the local backend. 376 p := backendLocal.TestLocalProvider(t, b, "null", providers.ProviderSchema{ 377 ResourceTypes: map[string]providers.Schema{ 378 "null_resource": { 379 Block: &configschema.Block{ 380 Attributes: map[string]*configschema.Attribute{ 381 "id": {Type: cty.String, Computed: true}, 382 }, 383 }, 384 }, 385 }, 386 }) 387 p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{ 388 "id": cty.StringVal("yes"), 389 })} 390 391 return b 392 } 393 394 // testServer returns a started *httptest.Server used for local testing with the default set of 395 // request handlers. 396 func testServer(t *testing.T) *httptest.Server { 397 skipIfTFENotEnabled(t) 398 399 return testServerWithHandlers(testDefaultRequestHandlers) 400 } 401 402 // testServerWithHandlers returns a started *httptest.Server with the given set of request handlers 403 // overriding any default request handlers (testDefaultRequestHandlers). 404 func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server { 405 mux := http.NewServeMux() 406 for route, handler := range handlers { 407 mux.HandleFunc(route, handler) 408 } 409 for route, handler := range testDefaultRequestHandlers { 410 if handlers[route] == nil { 411 mux.HandleFunc(route, handler) 412 } 413 } 414 415 return httptest.NewServer(mux) 416 } 417 418 func testServerWithSnapshotsEnabled(t *testing.T, enabled bool) *httptest.Server { 419 skipIfTFENotEnabled(t) 420 421 var serverURL string 422 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 423 t.Log(r.Method, r.URL.String()) 424 425 if r.URL.Path == "/state-json" { 426 t.Log("pretending to be Archivist") 427 fakeState := states.NewState() 428 fakeStateFile := statefile.New(fakeState, "boop", 1) 429 var buf bytes.Buffer 430 statefile.Write(fakeStateFile, &buf, encryption.StateEncryptionDisabled()) 431 respBody := buf.Bytes() 432 w.Header().Set("content-type", "application/json") 433 w.Header().Set("content-length", strconv.FormatInt(int64(len(respBody)), 10)) 434 w.WriteHeader(http.StatusOK) 435 w.Write(respBody) 436 return 437 } 438 439 if r.URL.Path == "/api/ping" { 440 t.Log("pretending to be Ping") 441 w.WriteHeader(http.StatusNoContent) 442 return 443 } 444 445 fakeBody := map[string]any{ 446 "data": map[string]any{ 447 "type": "state-versions", 448 "id": GenerateID("sv-"), 449 "attributes": map[string]any{ 450 "hosted-state-download-url": serverURL + "/state-json", 451 "hosted-state-upload-url": serverURL + "/state-json", 452 }, 453 }, 454 } 455 fakeBodyRaw, err := json.Marshal(fakeBody) 456 if err != nil { 457 t.Fatal(err) 458 } 459 460 w.Header().Set("content-type", tfe.ContentTypeJSONAPI) 461 w.Header().Set("content-length", strconv.FormatInt(int64(len(fakeBodyRaw)), 10)) 462 463 switch r.Method { 464 case "POST": 465 t.Log("pretending to be Create a State Version") 466 if enabled { 467 w.Header().Set("x-terraform-snapshot-interval", "300") 468 } 469 w.WriteHeader(http.StatusAccepted) 470 case "GET": 471 t.Log("pretending to be Fetch the Current State Version for a Workspace") 472 if enabled { 473 w.Header().Set("x-terraform-snapshot-interval", "300") 474 } 475 w.WriteHeader(http.StatusOK) 476 case "PUT": 477 t.Log("pretending to be Archivist") 478 default: 479 t.Fatal("don't know what API operation this was supposed to be") 480 } 481 482 w.WriteHeader(http.StatusOK) 483 w.Write(fakeBodyRaw) 484 })) 485 serverURL = server.URL 486 return server 487 } 488 489 // testDefaultRequestHandlers is a map of request handlers intended to be used in a request 490 // multiplexer for a test server. A caller may use testServerWithHandlers to start a server with 491 // this base set of routes, and override a particular route for whatever edge case is being tested. 492 var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){ 493 // Respond to service discovery calls. 494 "/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) { 495 w.Header().Set("Content-Type", "application/json") 496 io.WriteString(w, `{ 497 "tfe.v2": "/api/v2/", 498 }`) 499 }, 500 501 // Respond to service version constraints calls. 502 "/v1/versions/": func(w http.ResponseWriter, r *http.Request) { 503 w.Header().Set("Content-Type", "application/json") 504 io.WriteString(w, fmt.Sprintf(`{ 505 "service": "%s", 506 "product": "terraform", 507 "minimum": "0.1.0", 508 "maximum": "10.0.0" 509 }`, path.Base(r.URL.Path))) 510 }, 511 512 // Respond to pings to get the API version header. 513 "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { 514 w.Header().Set("Content-Type", "application/json") 515 w.Header().Set("TFP-API-Version", "2.5") 516 }, 517 518 // Respond to the initial query to read the hashicorp org entitlements. 519 "/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) { 520 w.Header().Set("Content-Type", "application/vnd.api+json") 521 io.WriteString(w, `{ 522 "data": { 523 "id": "org-GExadygjSbKP8hsY", 524 "type": "entitlement-sets", 525 "attributes": { 526 "operations": true, 527 "private-module-registry": true, 528 "sentinel": true, 529 "state-storage": true, 530 "teams": true, 531 "vcs-integrations": true 532 } 533 } 534 }`) 535 }, 536 537 // Respond to the initial query to read the no-operations org entitlements. 538 "/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) { 539 w.Header().Set("Content-Type", "application/vnd.api+json") 540 io.WriteString(w, `{ 541 "data": { 542 "id": "org-ufxa3y8jSbKP8hsT", 543 "type": "entitlement-sets", 544 "attributes": { 545 "operations": false, 546 "private-module-registry": true, 547 "sentinel": true, 548 "state-storage": true, 549 "teams": true, 550 "vcs-integrations": true 551 } 552 } 553 }`) 554 }, 555 556 // All tests that are assumed to pass will use the hashicorp organization, 557 // so for all other organization requests we will return a 404. 558 "/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) { 559 w.WriteHeader(404) 560 io.WriteString(w, `{ 561 "errors": [ 562 { 563 "status": "404", 564 "title": "not found" 565 } 566 ] 567 }`) 568 }, 569 } 570 571 func mockColorize() *colorstring.Colorize { 572 colors := make(map[string]string) 573 for k, v := range colorstring.DefaultColors { 574 colors[k] = v 575 } 576 colors["purple"] = "38;5;57" 577 578 return &colorstring.Colorize{ 579 Colors: colors, 580 Disable: false, 581 Reset: true, 582 } 583 } 584 585 func mockSROWorkspace(t *testing.T, b *Cloud, workspaceName string) { 586 _, err := b.client.Workspaces.Update(context.Background(), "hashicorp", workspaceName, tfe.WorkspaceUpdateOptions{ 587 StructuredRunOutputEnabled: tfe.Bool(true), 588 TerraformVersion: tfe.String("1.4.0"), 589 }) 590 if err != nil { 591 t.Fatalf("Error enabling SRO on workspace %s: %v", workspaceName, err) 592 } 593 } 594 595 // testDisco returns a *disco.Disco mapping app.terraform.io and 596 // localhost to a local test server. 597 func testDisco(s *httptest.Server) *disco.Disco { 598 services := map[string]interface{}{ 599 "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), 600 } 601 d := disco.NewWithCredentialsSource(credsSrc) 602 d.SetUserAgent(httpclient.OpenTofuUserAgent(version.String())) 603 604 d.ForceHostServices(svchost.Hostname(tfeHost), services) 605 d.ForceHostServices(svchost.Hostname("localhost"), services) 606 d.ForceHostServices(svchost.Hostname("nontfe.local"), nil) 607 return d 608 } 609 610 type unparsedVariableValue struct { 611 value string 612 source tofu.ValueSourceType 613 } 614 615 func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*tofu.InputValue, tfdiags.Diagnostics) { 616 return &tofu.InputValue{ 617 Value: cty.StringVal(v.value), 618 SourceType: v.source, 619 }, tfdiags.Diagnostics{} 620 } 621 622 // testVariable returns a backend.UnparsedVariableValue used for testing. 623 func testVariables(s tofu.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue { 624 vars := make(map[string]backend.UnparsedVariableValue, len(vs)) 625 for _, v := range vs { 626 vars[v] = &unparsedVariableValue{ 627 value: v, 628 source: s, 629 } 630 } 631 return vars 632 }