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