github.com/kevinklinger/open_terraform@v1.3.6/noninternal/cloud/testing.go (about) 1 package cloud 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "net/http" 9 "net/http/httptest" 10 "path" 11 "testing" 12 "time" 13 14 tfe "github.com/hashicorp/go-tfe" 15 svchost "github.com/hashicorp/terraform-svchost" 16 "github.com/hashicorp/terraform-svchost/auth" 17 "github.com/hashicorp/terraform-svchost/disco" 18 "github.com/mitchellh/cli" 19 "github.com/zclconf/go-cty/cty" 20 21 "github.com/kevinklinger/open_terraform/noninternal/backend" 22 "github.com/kevinklinger/open_terraform/noninternal/configs" 23 "github.com/kevinklinger/open_terraform/noninternal/configs/configschema" 24 "github.com/kevinklinger/open_terraform/noninternal/httpclient" 25 "github.com/kevinklinger/open_terraform/noninternal/providers" 26 "github.com/kevinklinger/open_terraform/noninternal/terraform" 27 "github.com/kevinklinger/open_terraform/noninternal/tfdiags" 28 "github.com/kevinklinger/open_terraform/version" 29 30 backendLocal "github.com/kevinklinger/open_terraform/noninternal/backend/local" 31 ) 32 33 const ( 34 testCred = "test-auth-token" 35 ) 36 37 var ( 38 tfeHost = svchost.Hostname(defaultHostname) 39 credsSrc = auth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ 40 tfeHost: {"token": testCred}, 41 }) 42 testBackendSingleWorkspaceName = "app-prod" 43 ) 44 45 // mockInput is a mock implementation of terraform.UIInput. 46 type mockInput struct { 47 answers map[string]string 48 } 49 50 func (m *mockInput) Input(ctx context.Context, opts *terraform.InputOpts) (string, error) { 51 v, ok := m.answers[opts.Id] 52 if !ok { 53 return "", fmt.Errorf("unexpected input request in test: %s", opts.Id) 54 } 55 if v == "wait-for-external-update" { 56 select { 57 case <-ctx.Done(): 58 case <-time.After(time.Minute): 59 } 60 } 61 delete(m.answers, opts.Id) 62 return v, nil 63 } 64 65 func testInput(t *testing.T, answers map[string]string) *mockInput { 66 return &mockInput{answers: answers} 67 } 68 69 func testBackendWithName(t *testing.T) (*Cloud, func()) { 70 obj := cty.ObjectVal(map[string]cty.Value{ 71 "hostname": cty.NullVal(cty.String), 72 "organization": cty.StringVal("hashicorp"), 73 "token": cty.NullVal(cty.String), 74 "workspaces": cty.ObjectVal(map[string]cty.Value{ 75 "name": cty.StringVal(testBackendSingleWorkspaceName), 76 "tags": cty.NullVal(cty.Set(cty.String)), 77 }), 78 }) 79 return testBackend(t, obj) 80 } 81 82 func testBackendWithTags(t *testing.T) (*Cloud, func()) { 83 obj := cty.ObjectVal(map[string]cty.Value{ 84 "hostname": cty.NullVal(cty.String), 85 "organization": cty.StringVal("hashicorp"), 86 "token": cty.NullVal(cty.String), 87 "workspaces": cty.ObjectVal(map[string]cty.Value{ 88 "name": cty.NullVal(cty.String), 89 "tags": cty.SetVal( 90 []cty.Value{ 91 cty.StringVal("billing"), 92 }, 93 ), 94 }), 95 }) 96 return testBackend(t, obj) 97 } 98 99 func testBackendNoOperations(t *testing.T) (*Cloud, func()) { 100 obj := cty.ObjectVal(map[string]cty.Value{ 101 "hostname": cty.NullVal(cty.String), 102 "organization": cty.StringVal("no-operations"), 103 "token": cty.NullVal(cty.String), 104 "workspaces": cty.ObjectVal(map[string]cty.Value{ 105 "name": cty.StringVal(testBackendSingleWorkspaceName), 106 "tags": cty.NullVal(cty.Set(cty.String)), 107 }), 108 }) 109 return testBackend(t, obj) 110 } 111 112 func testCloudState(t *testing.T) *State { 113 b, bCleanup := testBackendWithName(t) 114 defer bCleanup() 115 116 raw, err := b.StateMgr(testBackendSingleWorkspaceName) 117 if err != nil { 118 t.Fatalf("error: %v", err) 119 } 120 121 return raw.(*State) 122 } 123 124 func testBackendWithOutputs(t *testing.T) (*Cloud, func()) { 125 b, cleanup := testBackendWithName(t) 126 127 // Get a new mock client to use for adding outputs 128 mc := NewMockClient() 129 130 mc.StateVersionOutputs.create("svo-abcd", &tfe.StateVersionOutput{ 131 ID: "svo-abcd", 132 Value: "foobar", 133 Sensitive: true, 134 Type: "string", 135 Name: "sensitive_output", 136 DetailedType: "string", 137 }) 138 139 mc.StateVersionOutputs.create("svo-zyxw", &tfe.StateVersionOutput{ 140 ID: "svo-zyxw", 141 Value: "bazqux", 142 Type: "string", 143 Name: "nonsensitive_output", 144 DetailedType: "string", 145 }) 146 147 var dt interface{} 148 var val interface{} 149 err := json.Unmarshal([]byte(`["object", {"foo":"string"}]`), &dt) 150 if err != nil { 151 t.Fatalf("could not unmarshal detailed type: %s", err) 152 } 153 err = json.Unmarshal([]byte(`{"foo":"bar"}`), &val) 154 if err != nil { 155 t.Fatalf("could not unmarshal value: %s", err) 156 } 157 mc.StateVersionOutputs.create("svo-efgh", &tfe.StateVersionOutput{ 158 ID: "svo-efgh", 159 Value: val, 160 Type: "object", 161 Name: "object_output", 162 DetailedType: dt, 163 }) 164 165 err = json.Unmarshal([]byte(`["list", "bool"]`), &dt) 166 if err != nil { 167 t.Fatalf("could not unmarshal detailed type: %s", err) 168 } 169 err = json.Unmarshal([]byte(`[true, false, true, true]`), &val) 170 if err != nil { 171 t.Fatalf("could not unmarshal value: %s", err) 172 } 173 mc.StateVersionOutputs.create("svo-ijkl", &tfe.StateVersionOutput{ 174 ID: "svo-ijkl", 175 Value: val, 176 Type: "array", 177 Name: "list_output", 178 DetailedType: dt, 179 }) 180 181 b.client.StateVersionOutputs = mc.StateVersionOutputs 182 183 return b, cleanup 184 } 185 186 func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { 187 s := testServer(t) 188 b := New(testDisco(s)) 189 190 // Configure the backend so the client is created. 191 newObj, valDiags := b.PrepareConfig(obj) 192 if len(valDiags) != 0 { 193 t.Fatalf("testBackend: backend.PrepareConfig() failed: %s", valDiags.ErrWithWarnings()) 194 } 195 obj = newObj 196 197 confDiags := b.Configure(obj) 198 if len(confDiags) != 0 { 199 t.Fatalf("testBackend: backend.Configure() failed: %s", confDiags.ErrWithWarnings()) 200 } 201 202 // Get a new mock client. 203 mc := NewMockClient() 204 205 // Replace the services we use with our mock services. 206 b.CLI = cli.NewMockUi() 207 b.client.Applies = mc.Applies 208 b.client.ConfigurationVersions = mc.ConfigurationVersions 209 b.client.CostEstimates = mc.CostEstimates 210 b.client.Organizations = mc.Organizations 211 b.client.Plans = mc.Plans 212 b.client.PolicyChecks = mc.PolicyChecks 213 b.client.Runs = mc.Runs 214 b.client.StateVersions = mc.StateVersions 215 b.client.StateVersionOutputs = mc.StateVersionOutputs 216 b.client.Variables = mc.Variables 217 b.client.Workspaces = mc.Workspaces 218 219 // Set local to a local test backend. 220 b.local = testLocalBackend(t, b) 221 b.input = true 222 223 ctx := context.Background() 224 225 // Create the organization. 226 _, err := b.client.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ 227 Name: tfe.String(b.organization), 228 }) 229 if err != nil { 230 t.Fatalf("error: %v", err) 231 } 232 233 // Create the default workspace if required. 234 if b.WorkspaceMapping.Name != "" { 235 _, err = b.client.Workspaces.Create(ctx, b.organization, tfe.WorkspaceCreateOptions{ 236 Name: tfe.String(b.WorkspaceMapping.Name), 237 }) 238 if err != nil { 239 t.Fatalf("error: %v", err) 240 } 241 } 242 243 return b, s.Close 244 } 245 246 // testUnconfiguredBackend is used for testing the configuration of the backend 247 // with the mock client 248 func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) { 249 s := testServer(t) 250 b := New(testDisco(s)) 251 252 // Normally, the client is created during configuration, but the configuration uses the 253 // client to read entitlements. 254 var err error 255 b.client, err = tfe.NewClient(&tfe.Config{ 256 Token: "fake-token", 257 }) 258 if err != nil { 259 t.Fatal(err) 260 } 261 262 // Get a new mock client. 263 mc := NewMockClient() 264 265 // Replace the services we use with our mock services. 266 b.CLI = cli.NewMockUi() 267 b.client.Applies = mc.Applies 268 b.client.ConfigurationVersions = mc.ConfigurationVersions 269 b.client.CostEstimates = mc.CostEstimates 270 b.client.Organizations = mc.Organizations 271 b.client.Plans = mc.Plans 272 b.client.PolicyChecks = mc.PolicyChecks 273 b.client.Runs = mc.Runs 274 b.client.StateVersions = mc.StateVersions 275 b.client.Variables = mc.Variables 276 b.client.Workspaces = mc.Workspaces 277 278 // Set local to a local test backend. 279 b.local = testLocalBackend(t, b) 280 281 return b, s.Close 282 } 283 284 func testLocalBackend(t *testing.T, cloud *Cloud) backend.Enhanced { 285 b := backendLocal.NewWithBackend(cloud) 286 287 // Add a test provider to the local backend. 288 p := backendLocal.TestLocalProvider(t, b, "null", &terraform.ProviderSchema{ 289 ResourceTypes: map[string]*configschema.Block{ 290 "null_resource": { 291 Attributes: map[string]*configschema.Attribute{ 292 "id": {Type: cty.String, Computed: true}, 293 }, 294 }, 295 }, 296 }) 297 p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{NewState: cty.ObjectVal(map[string]cty.Value{ 298 "id": cty.StringVal("yes"), 299 })} 300 301 return b 302 } 303 304 // testServer returns a started *httptest.Server used for local testing with the default set of 305 // request handlers. 306 func testServer(t *testing.T) *httptest.Server { 307 return testServerWithHandlers(testDefaultRequestHandlers) 308 } 309 310 // testServerWithHandlers returns a started *httptest.Server with the given set of request handlers 311 // overriding any default request handlers (testDefaultRequestHandlers). 312 func testServerWithHandlers(handlers map[string]func(http.ResponseWriter, *http.Request)) *httptest.Server { 313 mux := http.NewServeMux() 314 for route, handler := range handlers { 315 mux.HandleFunc(route, handler) 316 } 317 for route, handler := range testDefaultRequestHandlers { 318 if handlers[route] == nil { 319 mux.HandleFunc(route, handler) 320 } 321 } 322 323 return httptest.NewServer(mux) 324 } 325 326 // testDefaultRequestHandlers is a map of request handlers intended to be used in a request 327 // multiplexer for a test server. A caller may use testServerWithHandlers to start a server with 328 // this base set of routes, and override a particular route for whatever edge case is being tested. 329 var testDefaultRequestHandlers = map[string]func(http.ResponseWriter, *http.Request){ 330 // Respond to service discovery calls. 331 "/well-known/terraform.json": func(w http.ResponseWriter, r *http.Request) { 332 w.Header().Set("Content-Type", "application/json") 333 io.WriteString(w, `{ 334 "tfe.v2": "/api/v2/", 335 }`) 336 }, 337 338 // Respond to service version constraints calls. 339 "/v1/versions/": func(w http.ResponseWriter, r *http.Request) { 340 w.Header().Set("Content-Type", "application/json") 341 io.WriteString(w, fmt.Sprintf(`{ 342 "service": "%s", 343 "product": "terraform", 344 "minimum": "0.1.0", 345 "maximum": "10.0.0" 346 }`, path.Base(r.URL.Path))) 347 }, 348 349 // Respond to pings to get the API version header. 350 "/api/v2/ping": func(w http.ResponseWriter, r *http.Request) { 351 w.Header().Set("Content-Type", "application/json") 352 w.Header().Set("TFP-API-Version", "2.5") 353 }, 354 355 // Respond to the initial query to read the hashicorp org entitlements. 356 "/api/v2/organizations/hashicorp/entitlement-set": func(w http.ResponseWriter, r *http.Request) { 357 w.Header().Set("Content-Type", "application/vnd.api+json") 358 io.WriteString(w, `{ 359 "data": { 360 "id": "org-GExadygjSbKP8hsY", 361 "type": "entitlement-sets", 362 "attributes": { 363 "operations": true, 364 "private-module-registry": true, 365 "sentinel": true, 366 "state-storage": true, 367 "teams": true, 368 "vcs-integrations": true 369 } 370 } 371 }`) 372 }, 373 374 // Respond to the initial query to read the no-operations org entitlements. 375 "/api/v2/organizations/no-operations/entitlement-set": func(w http.ResponseWriter, r *http.Request) { 376 w.Header().Set("Content-Type", "application/vnd.api+json") 377 io.WriteString(w, `{ 378 "data": { 379 "id": "org-ufxa3y8jSbKP8hsT", 380 "type": "entitlement-sets", 381 "attributes": { 382 "operations": false, 383 "private-module-registry": true, 384 "sentinel": true, 385 "state-storage": true, 386 "teams": true, 387 "vcs-integrations": true 388 } 389 } 390 }`) 391 }, 392 393 // All tests that are assumed to pass will use the hashicorp organization, 394 // so for all other organization requests we will return a 404. 395 "/api/v2/organizations/": func(w http.ResponseWriter, r *http.Request) { 396 w.WriteHeader(404) 397 io.WriteString(w, `{ 398 "errors": [ 399 { 400 "status": "404", 401 "title": "not found" 402 } 403 ] 404 }`) 405 }, 406 } 407 408 // testDisco returns a *disco.Disco mapping app.terraform.io and 409 // localhost to a local test server. 410 func testDisco(s *httptest.Server) *disco.Disco { 411 services := map[string]interface{}{ 412 "tfe.v2": fmt.Sprintf("%s/api/v2/", s.URL), 413 } 414 d := disco.NewWithCredentialsSource(credsSrc) 415 d.SetUserAgent(httpclient.TerraformUserAgent(version.String())) 416 417 d.ForceHostServices(svchost.Hostname(defaultHostname), services) 418 d.ForceHostServices(svchost.Hostname("localhost"), services) 419 return d 420 } 421 422 type unparsedVariableValue struct { 423 value string 424 source terraform.ValueSourceType 425 } 426 427 func (v *unparsedVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { 428 return &terraform.InputValue{ 429 Value: cty.StringVal(v.value), 430 SourceType: v.source, 431 }, tfdiags.Diagnostics{} 432 } 433 434 // testVariable returns a backend.UnparsedVariableValue used for testing. 435 func testVariables(s terraform.ValueSourceType, vs ...string) map[string]backend.UnparsedVariableValue { 436 vars := make(map[string]backend.UnparsedVariableValue, len(vs)) 437 for _, v := range vs { 438 vars[v] = &unparsedVariableValue{ 439 value: v, 440 source: s, 441 } 442 } 443 return vars 444 }