github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/states/remote/state_test.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package remote
     5  
     6  import (
     7  	"log"
     8  	"sync"
     9  	"testing"
    10  
    11  	"github.com/google/go-cmp/cmp"
    12  	"github.com/google/go-cmp/cmp/cmpopts"
    13  	"github.com/zclconf/go-cty/cty"
    14  
    15  	tfaddr "github.com/hashicorp/terraform-registry-address"
    16  	"github.com/terramate-io/tf/addrs"
    17  	"github.com/terramate-io/tf/states"
    18  	"github.com/terramate-io/tf/states/statefile"
    19  	"github.com/terramate-io/tf/states/statemgr"
    20  	"github.com/terramate-io/tf/version"
    21  )
    22  
    23  func TestState_impl(t *testing.T) {
    24  	var _ statemgr.Reader = new(State)
    25  	var _ statemgr.Writer = new(State)
    26  	var _ statemgr.Persister = new(State)
    27  	var _ statemgr.Refresher = new(State)
    28  	var _ statemgr.OutputReader = new(State)
    29  	var _ statemgr.Locker = new(State)
    30  }
    31  
    32  func TestStateRace(t *testing.T) {
    33  	s := &State{
    34  		Client: nilClient{},
    35  	}
    36  
    37  	current := states.NewState()
    38  
    39  	var wg sync.WaitGroup
    40  
    41  	for i := 0; i < 100; i++ {
    42  		wg.Add(1)
    43  		go func() {
    44  			defer wg.Done()
    45  			s.WriteState(current)
    46  			s.PersistState(nil)
    47  			s.RefreshState()
    48  		}()
    49  	}
    50  	wg.Wait()
    51  }
    52  
    53  // testCase encapsulates a test state test
    54  type testCase struct {
    55  	name string
    56  	// A function to mutate state and return a cleanup function
    57  	mutationFunc func(*State) (*states.State, func())
    58  	// The expected requests to have taken place
    59  	expectedRequests []mockClientRequest
    60  	// Mark this case as not having a request
    61  	noRequest bool
    62  }
    63  
    64  // isRequested ensures a test that is specified as not having
    65  // a request doesn't have one by checking if a method exists
    66  // on the expectedRequest.
    67  func (tc testCase) isRequested(t *testing.T) bool {
    68  	for _, expectedMethod := range tc.expectedRequests {
    69  		hasMethod := expectedMethod.Method != ""
    70  		if tc.noRequest && hasMethod {
    71  			t.Fatalf("expected no content for %q but got: %v", tc.name, expectedMethod)
    72  		}
    73  	}
    74  	return !tc.noRequest
    75  }
    76  
    77  func TestStatePersist(t *testing.T) {
    78  	testCases := []testCase{
    79  		{
    80  			name: "first state persistence",
    81  			mutationFunc: func(mgr *State) (*states.State, func()) {
    82  				mgr.state = &states.State{
    83  					Modules: map[string]*states.Module{"": {}},
    84  				}
    85  				s := mgr.State()
    86  				s.RootModule().SetResourceInstanceCurrent(
    87  					addrs.Resource{
    88  						Mode: addrs.ManagedResourceMode,
    89  						Name: "myfile",
    90  						Type: "local_file",
    91  					}.Instance(addrs.NoKey),
    92  					&states.ResourceInstanceObjectSrc{
    93  						AttrsFlat: map[string]string{
    94  							"filename": "file.txt",
    95  						},
    96  						Status: states.ObjectReady,
    97  					},
    98  					addrs.AbsProviderConfig{
    99  						Provider: tfaddr.Provider{Namespace: "local"},
   100  					},
   101  				)
   102  				return s, func() {}
   103  			},
   104  			expectedRequests: []mockClientRequest{
   105  				// Expect an initial refresh, which returns nothing since there is no remote state.
   106  				{
   107  					Method:  "Get",
   108  					Content: nil,
   109  				},
   110  				// Expect a second refresh, since the read state is nil
   111  				{
   112  					Method:  "Get",
   113  					Content: nil,
   114  				},
   115  				// Expect an initial push with values and a serial of 1
   116  				{
   117  					Method: "Put",
   118  					Content: map[string]interface{}{
   119  						"version":           4.0, // encoding/json decodes this as float64 by default
   120  						"lineage":           "some meaningless value",
   121  						"serial":            1.0, // encoding/json decodes this as float64 by default
   122  						"terraform_version": version.Version,
   123  						"outputs":           map[string]interface{}{},
   124  						"resources": []interface{}{
   125  							map[string]interface{}{
   126  								"instances": []interface{}{
   127  									map[string]interface{}{
   128  										"attributes_flat": map[string]interface{}{
   129  											"filename": "file.txt",
   130  										},
   131  										"schema_version":       0.0,
   132  										"sensitive_attributes": []interface{}{},
   133  									},
   134  								},
   135  								"mode":     "managed",
   136  								"name":     "myfile",
   137  								"provider": `provider["/local/"]`,
   138  								"type":     "local_file",
   139  							},
   140  						},
   141  						"check_results": nil,
   142  					},
   143  				},
   144  			},
   145  		},
   146  		// If lineage changes, expect the serial to increment
   147  		{
   148  			name: "change lineage",
   149  			mutationFunc: func(mgr *State) (*states.State, func()) {
   150  				mgr.lineage = "mock-lineage"
   151  				return mgr.State(), func() {}
   152  			},
   153  			expectedRequests: []mockClientRequest{
   154  				{
   155  					Method: "Put",
   156  					Content: map[string]interface{}{
   157  						"version":           4.0, // encoding/json decodes this as float64 by default
   158  						"lineage":           "mock-lineage",
   159  						"serial":            2.0, // encoding/json decodes this as float64 by default
   160  						"terraform_version": version.Version,
   161  						"outputs":           map[string]interface{}{},
   162  						"resources": []interface{}{
   163  							map[string]interface{}{
   164  								"instances": []interface{}{
   165  									map[string]interface{}{
   166  										"attributes_flat": map[string]interface{}{
   167  											"filename": "file.txt",
   168  										},
   169  										"schema_version":       0.0,
   170  										"sensitive_attributes": []interface{}{},
   171  									},
   172  								},
   173  								"mode":     "managed",
   174  								"name":     "myfile",
   175  								"provider": `provider["/local/"]`,
   176  								"type":     "local_file",
   177  							},
   178  						},
   179  						"check_results": nil,
   180  					},
   181  				},
   182  			},
   183  		},
   184  		// removing resources should increment the serial
   185  		{
   186  			name: "remove resources",
   187  			mutationFunc: func(mgr *State) (*states.State, func()) {
   188  				mgr.state.RootModule().Resources = map[string]*states.Resource{}
   189  				return mgr.State(), func() {}
   190  			},
   191  			expectedRequests: []mockClientRequest{
   192  				{
   193  					Method: "Put",
   194  					Content: map[string]interface{}{
   195  						"version":           4.0, // encoding/json decodes this as float64 by default
   196  						"lineage":           "mock-lineage",
   197  						"serial":            3.0, // encoding/json decodes this as float64 by default
   198  						"terraform_version": version.Version,
   199  						"outputs":           map[string]interface{}{},
   200  						"resources":         []interface{}{},
   201  						"check_results":     nil,
   202  					},
   203  				},
   204  			},
   205  		},
   206  		// If the remote serial is incremented, then we increment it once more.
   207  		{
   208  			name: "change serial",
   209  			mutationFunc: func(mgr *State) (*states.State, func()) {
   210  				originalSerial := mgr.serial
   211  				mgr.serial++
   212  				return mgr.State(), func() {
   213  					mgr.serial = originalSerial
   214  				}
   215  			},
   216  			expectedRequests: []mockClientRequest{
   217  				{
   218  					Method: "Put",
   219  					Content: map[string]interface{}{
   220  						"version":           4.0, // encoding/json decodes this as float64 by default
   221  						"lineage":           "mock-lineage",
   222  						"serial":            5.0, // encoding/json decodes this as float64 by default
   223  						"terraform_version": version.Version,
   224  						"outputs":           map[string]interface{}{},
   225  						"resources":         []interface{}{},
   226  						"check_results":     nil,
   227  					},
   228  				},
   229  			},
   230  		},
   231  		// Adding an output should cause the serial to increment as well.
   232  		{
   233  			name: "add output to state",
   234  			mutationFunc: func(mgr *State) (*states.State, func()) {
   235  				s := mgr.State()
   236  				s.RootModule().SetOutputValue("foo", cty.StringVal("bar"), false)
   237  				return s, func() {}
   238  			},
   239  			expectedRequests: []mockClientRequest{
   240  				{
   241  					Method: "Put",
   242  					Content: map[string]interface{}{
   243  						"version":           4.0, // encoding/json decodes this as float64 by default
   244  						"lineage":           "mock-lineage",
   245  						"serial":            4.0, // encoding/json decodes this as float64 by default
   246  						"terraform_version": version.Version,
   247  						"outputs": map[string]interface{}{
   248  							"foo": map[string]interface{}{
   249  								"type":  "string",
   250  								"value": "bar",
   251  							},
   252  						},
   253  						"resources":     []interface{}{},
   254  						"check_results": nil,
   255  					},
   256  				},
   257  			},
   258  		},
   259  		// ...as should changing an output
   260  		{
   261  			name: "mutate state bar -> baz",
   262  			mutationFunc: func(mgr *State) (*states.State, func()) {
   263  				s := mgr.State()
   264  				s.RootModule().SetOutputValue("foo", cty.StringVal("baz"), false)
   265  				return s, func() {}
   266  			},
   267  			expectedRequests: []mockClientRequest{
   268  				{
   269  					Method: "Put",
   270  					Content: map[string]interface{}{
   271  						"version":           4.0, // encoding/json decodes this as float64 by default
   272  						"lineage":           "mock-lineage",
   273  						"serial":            5.0, // encoding/json decodes this as float64 by default
   274  						"terraform_version": version.Version,
   275  						"outputs": map[string]interface{}{
   276  							"foo": map[string]interface{}{
   277  								"type":  "string",
   278  								"value": "baz",
   279  							},
   280  						},
   281  						"resources":     []interface{}{},
   282  						"check_results": nil,
   283  					},
   284  				},
   285  			},
   286  		},
   287  		{
   288  			name: "nothing changed",
   289  			mutationFunc: func(mgr *State) (*states.State, func()) {
   290  				s := mgr.State()
   291  				return s, func() {}
   292  			},
   293  			noRequest: true,
   294  		},
   295  		// If the remote state's serial is less (force push), then we
   296  		// increment it once from there.
   297  		{
   298  			name: "reset serial (force push style)",
   299  			mutationFunc: func(mgr *State) (*states.State, func()) {
   300  				mgr.serial = 2
   301  				return mgr.State(), func() {}
   302  			},
   303  			expectedRequests: []mockClientRequest{
   304  				{
   305  					Method: "Put",
   306  					Content: map[string]interface{}{
   307  						"version":           4.0, // encoding/json decodes this as float64 by default
   308  						"lineage":           "mock-lineage",
   309  						"serial":            3.0, // encoding/json decodes this as float64 by default
   310  						"terraform_version": version.Version,
   311  						"outputs": map[string]interface{}{
   312  							"foo": map[string]interface{}{
   313  								"type":  "string",
   314  								"value": "baz",
   315  							},
   316  						},
   317  						"resources":     []interface{}{},
   318  						"check_results": nil,
   319  					},
   320  				},
   321  			},
   322  		},
   323  	}
   324  
   325  	// Initial setup of state just to give us a fixed starting point for our
   326  	// test assertions below, or else we'd need to deal with
   327  	// random lineage.
   328  	mgr := &State{
   329  		Client: &mockClient{},
   330  	}
   331  
   332  	// In normal use (during a Terraform operation) we always refresh and read
   333  	// before any writes would happen, so we'll mimic that here for realism.
   334  	// NB This causes a GET to be logged so the first item in the test cases
   335  	// must account for this
   336  	if err := mgr.RefreshState(); err != nil {
   337  		t.Fatalf("failed to RefreshState: %s", err)
   338  	}
   339  
   340  	// Our client is a mockClient which has a log we
   341  	// use to check that operations generate expected requests
   342  	mockClient := mgr.Client.(*mockClient)
   343  
   344  	// logIdx tracks the current index of the log separate from
   345  	// the loop iteration so we can check operations that don't
   346  	// cause any requests to be generated
   347  	logIdx := 0
   348  
   349  	// Run tests in order.
   350  	for _, tc := range testCases {
   351  		t.Run(tc.name, func(t *testing.T) {
   352  			s, cleanup := tc.mutationFunc(mgr)
   353  
   354  			if err := mgr.WriteState(s); err != nil {
   355  				t.Fatalf("failed to WriteState for %q: %s", tc.name, err)
   356  			}
   357  			if err := mgr.PersistState(nil); err != nil {
   358  				t.Fatalf("failed to PersistState for %q: %s", tc.name, err)
   359  			}
   360  
   361  			if tc.isRequested(t) {
   362  				// Get captured request from the mock client log
   363  				// based on the index of the current test
   364  				if logIdx >= len(mockClient.log) {
   365  					t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log))
   366  				}
   367  				for expectedRequestIdx := 0; expectedRequestIdx < len(tc.expectedRequests); expectedRequestIdx++ {
   368  					loggedRequest := mockClient.log[logIdx]
   369  					logIdx++
   370  					if diff := cmp.Diff(tc.expectedRequests[expectedRequestIdx], loggedRequest, cmpopts.IgnoreMapEntries(func(key string, value interface{}) bool {
   371  						// This is required since the initial state creation causes the lineage to be a UUID that is not known at test time.
   372  						return tc.name == "first state persistence" && key == "lineage"
   373  					})); len(diff) > 0 {
   374  						t.Logf("incorrect client requests for %q:\n%s", tc.name, diff)
   375  						t.Fail()
   376  					}
   377  				}
   378  			}
   379  			cleanup()
   380  		})
   381  	}
   382  	logCnt := len(mockClient.log)
   383  	if logIdx != logCnt {
   384  		t.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx)
   385  	}
   386  }
   387  
   388  func TestState_GetRootOutputValues(t *testing.T) {
   389  	// Initial setup of state with outputs already defined
   390  	mgr := &State{
   391  		Client: &mockClient{
   392  			current: []byte(`
   393  				{
   394  					"version": 4,
   395  					"lineage": "mock-lineage",
   396  					"serial": 1,
   397  					"terraform_version":"0.0.0",
   398  					"outputs": {"foo": {"value":"bar", "type": "string"}},
   399  					"resources": []
   400  				}
   401  			`),
   402  		},
   403  	}
   404  
   405  	outputs, err := mgr.GetRootOutputValues()
   406  	if err != nil {
   407  		t.Errorf("Expected GetRootOutputValues to not return an error, but it returned %v", err)
   408  	}
   409  
   410  	if len(outputs) != 1 {
   411  		t.Errorf("Expected %d outputs, but received %d", 1, len(outputs))
   412  	}
   413  }
   414  
   415  type migrationTestCase struct {
   416  	name string
   417  	// A function to generate a statefile
   418  	stateFile func(*State) *statefile.File
   419  	// The expected request to have taken place
   420  	expectedRequest mockClientRequest
   421  	// Mark this case as not having a request
   422  	expectedError string
   423  	// force flag passed to client
   424  	force bool
   425  }
   426  
   427  func TestWriteStateForMigration(t *testing.T) {
   428  	mgr := &State{
   429  		Client: &mockClient{
   430  			current: []byte(`
   431  				{
   432  					"version": 4,
   433  					"lineage": "mock-lineage",
   434  					"serial": 3,
   435  					"terraform_version":"0.0.0",
   436  					"outputs": {"foo": {"value":"bar", "type": "string"}},
   437  					"resources": []
   438  				}
   439  			`),
   440  		},
   441  	}
   442  
   443  	testCases := []migrationTestCase{
   444  		// Refreshing state before we run the test loop causes a GET
   445  		{
   446  			name: "refresh state",
   447  			stateFile: func(mgr *State) *statefile.File {
   448  				return mgr.StateForMigration()
   449  			},
   450  			expectedRequest: mockClientRequest{
   451  				Method: "Get",
   452  				Content: map[string]interface{}{
   453  					"version":           4.0,
   454  					"lineage":           "mock-lineage",
   455  					"serial":            3.0,
   456  					"terraform_version": "0.0.0",
   457  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   458  					"resources":         []interface{}{},
   459  				},
   460  			},
   461  		},
   462  		{
   463  			name: "cannot import lesser serial without force",
   464  			stateFile: func(mgr *State) *statefile.File {
   465  				return statefile.New(mgr.state, mgr.lineage, 1)
   466  			},
   467  			expectedError: "cannot import state with serial 1 over newer state with serial 3",
   468  		},
   469  		{
   470  			name: "cannot import differing lineage without force",
   471  			stateFile: func(mgr *State) *statefile.File {
   472  				return statefile.New(mgr.state, "different-lineage", mgr.serial)
   473  			},
   474  			expectedError: `cannot import state with lineage "different-lineage" over unrelated state with lineage "mock-lineage"`,
   475  		},
   476  		{
   477  			name: "can import lesser serial with force",
   478  			stateFile: func(mgr *State) *statefile.File {
   479  				return statefile.New(mgr.state, mgr.lineage, 1)
   480  			},
   481  			expectedRequest: mockClientRequest{
   482  				Method: "Put",
   483  				Content: map[string]interface{}{
   484  					"version":           4.0,
   485  					"lineage":           "mock-lineage",
   486  					"serial":            2.0,
   487  					"terraform_version": version.Version,
   488  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   489  					"resources":         []interface{}{},
   490  					"check_results":     nil,
   491  				},
   492  			},
   493  			force: true,
   494  		},
   495  		{
   496  			name: "cannot import differing lineage without force",
   497  			stateFile: func(mgr *State) *statefile.File {
   498  				return statefile.New(mgr.state, "different-lineage", mgr.serial)
   499  			},
   500  			expectedRequest: mockClientRequest{
   501  				Method: "Put",
   502  				Content: map[string]interface{}{
   503  					"version":           4.0,
   504  					"lineage":           "different-lineage",
   505  					"serial":            3.0,
   506  					"terraform_version": version.Version,
   507  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   508  					"resources":         []interface{}{},
   509  					"check_results":     nil,
   510  				},
   511  			},
   512  			force: true,
   513  		},
   514  	}
   515  
   516  	// In normal use (during a Terraform operation) we always refresh and read
   517  	// before any writes would happen, so we'll mimic that here for realism.
   518  	// NB This causes a GET to be logged so the first item in the test cases
   519  	// must account for this
   520  	if err := mgr.RefreshState(); err != nil {
   521  		t.Fatalf("failed to RefreshState: %s", err)
   522  	}
   523  
   524  	if err := mgr.WriteState(mgr.State()); err != nil {
   525  		t.Fatalf("failed to write initial state: %s", err)
   526  	}
   527  
   528  	// Our client is a mockClient which has a log we
   529  	// use to check that operations generate expected requests
   530  	mockClient := mgr.Client.(*mockClient)
   531  
   532  	// logIdx tracks the current index of the log separate from
   533  	// the loop iteration so we can check operations that don't
   534  	// cause any requests to be generated
   535  	logIdx := 0
   536  
   537  	for _, tc := range testCases {
   538  		t.Run(tc.name, func(t *testing.T) {
   539  			sf := tc.stateFile(mgr)
   540  			err := mgr.WriteStateForMigration(sf, tc.force)
   541  			shouldError := tc.expectedError != ""
   542  
   543  			// If we are expecting and error check it and move on
   544  			if shouldError {
   545  				if err == nil {
   546  					t.Fatalf("test case %q should have failed with error %q", tc.name, tc.expectedError)
   547  				} else if err.Error() != tc.expectedError {
   548  					t.Fatalf("test case %q expected error %q but got %q", tc.name, tc.expectedError, err)
   549  				}
   550  				return
   551  			}
   552  
   553  			if err != nil {
   554  				t.Fatalf("test case %q failed: %v", tc.name, err)
   555  			}
   556  
   557  			// At this point we should just do a normal write and persist
   558  			// as would happen from the CLI
   559  			mgr.WriteState(mgr.State())
   560  			mgr.PersistState(nil)
   561  
   562  			if logIdx >= len(mockClient.log) {
   563  				t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log))
   564  			}
   565  			loggedRequest := mockClient.log[logIdx]
   566  			logIdx++
   567  			if diff := cmp.Diff(tc.expectedRequest, loggedRequest); len(diff) > 0 {
   568  				t.Fatalf("incorrect client requests for %q:\n%s", tc.name, diff)
   569  			}
   570  		})
   571  	}
   572  
   573  	logCnt := len(mockClient.log)
   574  	if logIdx != logCnt {
   575  		log.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx)
   576  	}
   577  }
   578  
   579  // This test runs the same test cases as above, but with
   580  // a client that implements EnableForcePush -- this allows
   581  // us to test that -force continues to work for backends without
   582  // this interface, but that this interface works for those that do.
   583  func TestWriteStateForMigrationWithForcePushClient(t *testing.T) {
   584  	mgr := &State{
   585  		Client: &mockClientForcePusher{
   586  			current: []byte(`
   587  				{
   588  					"version": 4,
   589  					"lineage": "mock-lineage",
   590  					"serial": 3,
   591  					"terraform_version":"0.0.0",
   592  					"outputs": {"foo": {"value":"bar", "type": "string"}},
   593  					"resources": []
   594  				}
   595  			`),
   596  		},
   597  	}
   598  
   599  	testCases := []migrationTestCase{
   600  		// Refreshing state before we run the test loop causes a GET
   601  		{
   602  			name: "refresh state",
   603  			stateFile: func(mgr *State) *statefile.File {
   604  				return mgr.StateForMigration()
   605  			},
   606  			expectedRequest: mockClientRequest{
   607  				Method: "Get",
   608  				Content: map[string]interface{}{
   609  					"version":           4.0,
   610  					"lineage":           "mock-lineage",
   611  					"serial":            3.0,
   612  					"terraform_version": "0.0.0",
   613  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   614  					"resources":         []interface{}{},
   615  				},
   616  			},
   617  		},
   618  		{
   619  			name: "cannot import lesser serial without force",
   620  			stateFile: func(mgr *State) *statefile.File {
   621  				return statefile.New(mgr.state, mgr.lineage, 1)
   622  			},
   623  			expectedError: "cannot import state with serial 1 over newer state with serial 3",
   624  		},
   625  		{
   626  			name: "cannot import differing lineage without force",
   627  			stateFile: func(mgr *State) *statefile.File {
   628  				return statefile.New(mgr.state, "different-lineage", mgr.serial)
   629  			},
   630  			expectedError: `cannot import state with lineage "different-lineage" over unrelated state with lineage "mock-lineage"`,
   631  		},
   632  		{
   633  			name: "can import lesser serial with force",
   634  			stateFile: func(mgr *State) *statefile.File {
   635  				return statefile.New(mgr.state, mgr.lineage, 1)
   636  			},
   637  			expectedRequest: mockClientRequest{
   638  				Method: "Force Put",
   639  				Content: map[string]interface{}{
   640  					"version":           4.0,
   641  					"lineage":           "mock-lineage",
   642  					"serial":            2.0,
   643  					"terraform_version": version.Version,
   644  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   645  					"resources":         []interface{}{},
   646  					"check_results":     nil,
   647  				},
   648  			},
   649  			force: true,
   650  		},
   651  		{
   652  			name: "cannot import differing lineage without force",
   653  			stateFile: func(mgr *State) *statefile.File {
   654  				return statefile.New(mgr.state, "different-lineage", mgr.serial)
   655  			},
   656  			expectedRequest: mockClientRequest{
   657  				Method: "Force Put",
   658  				Content: map[string]interface{}{
   659  					"version":           4.0,
   660  					"lineage":           "different-lineage",
   661  					"serial":            3.0,
   662  					"terraform_version": version.Version,
   663  					"outputs":           map[string]interface{}{"foo": map[string]interface{}{"type": string("string"), "value": string("bar")}},
   664  					"resources":         []interface{}{},
   665  					"check_results":     nil,
   666  				},
   667  			},
   668  			force: true,
   669  		},
   670  	}
   671  
   672  	// In normal use (during a Terraform operation) we always refresh and read
   673  	// before any writes would happen, so we'll mimic that here for realism.
   674  	// NB This causes a GET to be logged so the first item in the test cases
   675  	// must account for this
   676  	if err := mgr.RefreshState(); err != nil {
   677  		t.Fatalf("failed to RefreshState: %s", err)
   678  	}
   679  
   680  	if err := mgr.WriteState(mgr.State()); err != nil {
   681  		t.Fatalf("failed to write initial state: %s", err)
   682  	}
   683  
   684  	// Our client is a mockClientForcePusher which has a log we
   685  	// use to check that operations generate expected requests
   686  	mockClient := mgr.Client.(*mockClientForcePusher)
   687  
   688  	if mockClient.force {
   689  		t.Fatalf("client should not default to force")
   690  	}
   691  
   692  	// logIdx tracks the current index of the log separate from
   693  	// the loop iteration so we can check operations that don't
   694  	// cause any requests to be generated
   695  	logIdx := 0
   696  
   697  	for _, tc := range testCases {
   698  		t.Run(tc.name, func(t *testing.T) {
   699  			// Always reset client to not be force pushing
   700  			mockClient.force = false
   701  			sf := tc.stateFile(mgr)
   702  			err := mgr.WriteStateForMigration(sf, tc.force)
   703  			shouldError := tc.expectedError != ""
   704  
   705  			// If we are expecting and error check it and move on
   706  			if shouldError {
   707  				if err == nil {
   708  					t.Fatalf("test case %q should have failed with error %q", tc.name, tc.expectedError)
   709  				} else if err.Error() != tc.expectedError {
   710  					t.Fatalf("test case %q expected error %q but got %q", tc.name, tc.expectedError, err)
   711  				}
   712  				return
   713  			}
   714  
   715  			if err != nil {
   716  				t.Fatalf("test case %q failed: %v", tc.name, err)
   717  			}
   718  
   719  			if tc.force && !mockClient.force {
   720  				t.Fatalf("test case %q should have enabled force push", tc.name)
   721  			}
   722  
   723  			// At this point we should just do a normal write and persist
   724  			// as would happen from the CLI
   725  			mgr.WriteState(mgr.State())
   726  			mgr.PersistState(nil)
   727  
   728  			if logIdx >= len(mockClient.log) {
   729  				t.Fatalf("request lock and index are out of sync on %q: idx=%d len=%d", tc.name, logIdx, len(mockClient.log))
   730  			}
   731  			loggedRequest := mockClient.log[logIdx]
   732  			logIdx++
   733  			if diff := cmp.Diff(tc.expectedRequest, loggedRequest); len(diff) > 0 {
   734  				t.Fatalf("incorrect client requests for %q:\n%s", tc.name, diff)
   735  			}
   736  		})
   737  	}
   738  
   739  	logCnt := len(mockClient.log)
   740  	if logIdx != logCnt {
   741  		log.Fatalf("not all requests were read. Expected logIdx to be %d but got %d", logCnt, logIdx)
   742  	}
   743  }