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  }