github.com/opentofu/opentofu@v1.7.1/internal/command/cliconfig/credentials_test.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 cliconfig
     7  
     8  import (
     9  	"net/http"
    10  	"path/filepath"
    11  	"testing"
    12  
    13  	"github.com/google/go-cmp/cmp"
    14  	"github.com/zclconf/go-cty/cty"
    15  
    16  	svchost "github.com/hashicorp/terraform-svchost"
    17  	svcauth "github.com/hashicorp/terraform-svchost/auth"
    18  )
    19  
    20  func TestCredentialsForHost(t *testing.T) {
    21  	credSrc := &CredentialsSource{
    22  		configured: map[svchost.Hostname]cty.Value{
    23  			"configured.example.com": cty.ObjectVal(map[string]cty.Value{
    24  				"token": cty.StringVal("configured"),
    25  			}),
    26  			"unused.example.com": cty.ObjectVal(map[string]cty.Value{
    27  				"token": cty.StringVal("incorrectly-configured"),
    28  			}),
    29  		},
    30  
    31  		// We'll use a static source to stand in for what would normally be
    32  		// a credentials helper program, since we're only testing the logic
    33  		// for choosing when to delegate to the helper here. The logic for
    34  		// interacting with a helper program is tested in the svcauth package.
    35  		helper: svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{
    36  			"from-helper.example.com": {
    37  				"token": "from-helper",
    38  			},
    39  
    40  			// This should be shadowed by the "configured" entry with the same
    41  			// hostname above.
    42  			"configured.example.com": {
    43  				"token": "incorrectly-from-helper",
    44  			},
    45  		}),
    46  		helperType: "fake",
    47  	}
    48  
    49  	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
    50  		t.Helper()
    51  
    52  		if creds == nil {
    53  			return ""
    54  		}
    55  
    56  		req, err := http.NewRequest("GET", "http://example.com/", nil)
    57  		if err != nil {
    58  			t.Fatalf("cannot construct HTTP request: %s", err)
    59  		}
    60  		creds.PrepareRequest(req)
    61  		return req.Header.Get("Authorization")
    62  	}
    63  
    64  	t.Run("configured", func(t *testing.T) {
    65  		creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
    66  		if err != nil {
    67  			t.Fatalf("unexpected error: %s", err)
    68  		}
    69  		if got, want := testReqAuthHeader(t, creds), "Bearer configured"; got != want {
    70  			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
    71  		}
    72  	})
    73  	t.Run("from helper", func(t *testing.T) {
    74  		creds, err := credSrc.ForHost(svchost.Hostname("from-helper.example.com"))
    75  		if err != nil {
    76  			t.Fatalf("unexpected error: %s", err)
    77  		}
    78  		if got, want := testReqAuthHeader(t, creds), "Bearer from-helper"; got != want {
    79  			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
    80  		}
    81  	})
    82  	t.Run("not available", func(t *testing.T) {
    83  		creds, err := credSrc.ForHost(svchost.Hostname("unavailable.example.com"))
    84  		if err != nil {
    85  			t.Fatalf("unexpected error: %s", err)
    86  		}
    87  		if got, want := testReqAuthHeader(t, creds), ""; got != want {
    88  			t.Errorf("wrong result\ngot:  %s\nwant: %s", got, want)
    89  		}
    90  	})
    91  	t.Run("set in environment", func(t *testing.T) {
    92  		envName := "TF_TOKEN_configured_example_com"
    93  
    94  		expectedToken := "configured-by-env"
    95  		t.Setenv(envName, expectedToken)
    96  
    97  		creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com"))
    98  		if err != nil {
    99  			t.Fatalf("unexpected error: %s", err)
   100  		}
   101  
   102  		if creds == nil {
   103  			t.Fatal("no credentials found")
   104  		}
   105  
   106  		if got := creds.Token(); got != expectedToken {
   107  			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
   108  		}
   109  	})
   110  
   111  	t.Run("punycode name set in environment", func(t *testing.T) {
   112  		envName := "TF_TOKEN_env_xn--eckwd4c7cu47r2wf_com"
   113  
   114  		expectedToken := "configured-by-env"
   115  		t.Setenv(envName, expectedToken)
   116  
   117  		hostname, _ := svchost.ForComparison("env.ドメイン名例.com")
   118  		creds, err := credSrc.ForHost(hostname)
   119  
   120  		if err != nil {
   121  			t.Fatalf("unexpected error: %s", err)
   122  		}
   123  
   124  		if creds == nil {
   125  			t.Fatal("no credentials found")
   126  		}
   127  
   128  		if got := creds.Token(); got != expectedToken {
   129  			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
   130  		}
   131  	})
   132  
   133  	t.Run("hyphens can be encoded as double underscores", func(t *testing.T) {
   134  		envName := "TF_TOKEN_env_xn____caf__dma_fr"
   135  		expectedToken := "configured-by-fallback"
   136  
   137  		t.Setenv(envName, expectedToken)
   138  
   139  		hostname, _ := svchost.ForComparison("env.café.fr")
   140  		creds, err := credSrc.ForHost(hostname)
   141  
   142  		if err != nil {
   143  			t.Fatalf("unexpected error: %s", err)
   144  		}
   145  
   146  		if creds == nil {
   147  			t.Fatal("no credentials found")
   148  		}
   149  
   150  		if got := creds.Token(); got != expectedToken {
   151  			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
   152  		}
   153  	})
   154  
   155  	t.Run("periods are ok", func(t *testing.T) {
   156  		envName := "TF_TOKEN_configured.example.com"
   157  		expectedToken := "configured-by-env"
   158  
   159  		t.Setenv(envName, expectedToken)
   160  
   161  		hostname, _ := svchost.ForComparison("configured.example.com")
   162  		creds, err := credSrc.ForHost(hostname)
   163  
   164  		if err != nil {
   165  			t.Fatalf("unexpected error: %s", err)
   166  		}
   167  
   168  		if creds == nil {
   169  			t.Fatal("no credentials found")
   170  		}
   171  
   172  		if got := creds.Token(); got != expectedToken {
   173  			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
   174  		}
   175  	})
   176  
   177  	t.Run("casing is insensitive", func(t *testing.T) {
   178  		envName := "TF_TOKEN_CONFIGUREDUPPERCASE_EXAMPLE_COM"
   179  		expectedToken := "configured-by-env"
   180  
   181  		t.Setenv(envName, expectedToken)
   182  
   183  		hostname, _ := svchost.ForComparison("configureduppercase.example.com")
   184  		creds, err := credSrc.ForHost(hostname)
   185  
   186  		if err != nil {
   187  			t.Fatalf("unexpected error: %s", err)
   188  		}
   189  
   190  		if creds == nil {
   191  			t.Fatal("no credentials found")
   192  		}
   193  
   194  		if got := creds.Token(); got != expectedToken {
   195  			t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken)
   196  		}
   197  	})
   198  }
   199  
   200  func TestCredentialsStoreForget(t *testing.T) {
   201  	d := t.TempDir()
   202  
   203  	mockCredsFilename := filepath.Join(d, "credentials.tfrc.json")
   204  
   205  	cfg := &Config{
   206  		// This simulates there being a credentials block manually configured
   207  		// in some file _other than_ credentials.tfrc.json.
   208  		Credentials: map[string]map[string]interface{}{
   209  			"manually-configured.example.com": {
   210  				"token": "manually-configured",
   211  			},
   212  		},
   213  	}
   214  
   215  	// We'll initially use a credentials source with no credentials helper at
   216  	// all, and thus with credentials stored in the credentials file.
   217  	credSrc := cfg.credentialsSource(
   218  		"", nil,
   219  		mockCredsFilename,
   220  	)
   221  
   222  	testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string {
   223  		t.Helper()
   224  
   225  		if creds == nil {
   226  			return ""
   227  		}
   228  
   229  		req, err := http.NewRequest("GET", "http://example.com/", nil)
   230  		if err != nil {
   231  			t.Fatalf("cannot construct HTTP request: %s", err)
   232  		}
   233  		creds.PrepareRequest(req)
   234  		return req.Header.Get("Authorization")
   235  	}
   236  
   237  	// Because these store/forget calls have side-effects, we'll bail out with
   238  	// t.Fatal (or equivalent) as soon as anything unexpected happens.
   239  	// Otherwise downstream tests might fail in confusing ways.
   240  	{
   241  		err := credSrc.StoreForHost(
   242  			svchost.Hostname("manually-configured.example.com"),
   243  			svcauth.HostCredentialsToken("not-manually-configured"),
   244  		)
   245  		if err == nil {
   246  			t.Fatalf("successfully stored for manually-configured; want error")
   247  		}
   248  		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
   249  			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
   250  		}
   251  	}
   252  	{
   253  		err := credSrc.ForgetForHost(
   254  			svchost.Hostname("manually-configured.example.com"),
   255  		)
   256  		if err == nil {
   257  			t.Fatalf("successfully forgot for manually-configured; want error")
   258  		}
   259  		if _, ok := err.(ErrUnwritableHostCredentials); !ok {
   260  			t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err)
   261  		}
   262  	}
   263  	{
   264  		// We don't have a credentials file at all yet, so this first call
   265  		// must create it.
   266  		err := credSrc.StoreForHost(
   267  			svchost.Hostname("stored-locally.example.com"),
   268  			svcauth.HostCredentialsToken("stored-locally"),
   269  		)
   270  		if err != nil {
   271  			t.Fatalf("unexpected error storing locally: %s", err)
   272  		}
   273  
   274  		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
   275  		if err != nil {
   276  			t.Fatalf("failed to read back stored-locally credentials: %s", err)
   277  		}
   278  
   279  		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally"; got != want {
   280  			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
   281  		}
   282  
   283  		got := readHostsInCredentialsFile(mockCredsFilename)
   284  		want := map[svchost.Hostname]struct{}{
   285  			svchost.Hostname("stored-locally.example.com"): struct{}{},
   286  		}
   287  		if diff := cmp.Diff(want, got); diff != "" {
   288  			t.Fatalf("wrong credentials file content\n%s", diff)
   289  		}
   290  	}
   291  
   292  	// Now we'll switch to having a credential helper active.
   293  	// If we were loading the real CLI config from disk here then this
   294  	// entry would already be in cfg.Credentials, but we need to fake that
   295  	// in the test because we're constructing this *Config value directly.
   296  	cfg.Credentials["stored-locally.example.com"] = map[string]interface{}{
   297  		"token": "stored-locally",
   298  	}
   299  	mockHelper := &mockCredentialsHelper{current: make(map[svchost.Hostname]cty.Value)}
   300  	credSrc = cfg.credentialsSource(
   301  		"mock", mockHelper,
   302  		mockCredsFilename,
   303  	)
   304  	{
   305  		err := credSrc.StoreForHost(
   306  			svchost.Hostname("manually-configured.example.com"),
   307  			svcauth.HostCredentialsToken("not-manually-configured"),
   308  		)
   309  		if err == nil {
   310  			t.Fatalf("successfully stored for manually-configured with helper active; want error")
   311  		}
   312  	}
   313  	{
   314  		err := credSrc.StoreForHost(
   315  			svchost.Hostname("stored-in-helper.example.com"),
   316  			svcauth.HostCredentialsToken("stored-in-helper"),
   317  		)
   318  		if err != nil {
   319  			t.Fatalf("unexpected error storing in helper: %s", err)
   320  		}
   321  
   322  		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
   323  		if err != nil {
   324  			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
   325  		}
   326  
   327  		if got, want := testReqAuthHeader(t, creds), "Bearer stored-in-helper"; got != want {
   328  			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
   329  		}
   330  
   331  		// Nothing should have changed in the saved credentials file
   332  		got := readHostsInCredentialsFile(mockCredsFilename)
   333  		want := map[svchost.Hostname]struct{}{
   334  			svchost.Hostname("stored-locally.example.com"): struct{}{},
   335  		}
   336  		if diff := cmp.Diff(want, got); diff != "" {
   337  			t.Fatalf("wrong credentials file content\n%s", diff)
   338  		}
   339  	}
   340  	{
   341  		// Because stored-locally is already in the credentials file, a new
   342  		// store should be sent there rather than to the credentials helper.
   343  		err := credSrc.StoreForHost(
   344  			svchost.Hostname("stored-locally.example.com"),
   345  			svcauth.HostCredentialsToken("stored-locally-again"),
   346  		)
   347  		if err != nil {
   348  			t.Fatalf("unexpected error storing locally again: %s", err)
   349  		}
   350  
   351  		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
   352  		if err != nil {
   353  			t.Fatalf("failed to read back stored-locally credentials: %s", err)
   354  		}
   355  
   356  		if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally-again"; got != want {
   357  			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
   358  		}
   359  	}
   360  	{
   361  		// Forgetting a host already in the credentials file should remove it
   362  		// from the credentials file, not from the helper.
   363  		err := credSrc.ForgetForHost(
   364  			svchost.Hostname("stored-locally.example.com"),
   365  		)
   366  		if err != nil {
   367  			t.Fatalf("unexpected error forgetting locally: %s", err)
   368  		}
   369  
   370  		creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com"))
   371  		if err != nil {
   372  			t.Fatalf("failed to read back stored-locally credentials: %s", err)
   373  		}
   374  
   375  		if got, want := testReqAuthHeader(t, creds), ""; got != want {
   376  			t.Fatalf("wrong header value for stored-locally\ngot:  %s\nwant: %s", got, want)
   377  		}
   378  
   379  		// Should not be present in the credentials file anymore
   380  		got := readHostsInCredentialsFile(mockCredsFilename)
   381  		want := map[svchost.Hostname]struct{}{}
   382  		if diff := cmp.Diff(want, got); diff != "" {
   383  			t.Fatalf("wrong credentials file content\n%s", diff)
   384  		}
   385  	}
   386  	{
   387  		err := credSrc.ForgetForHost(
   388  			svchost.Hostname("stored-in-helper.example.com"),
   389  		)
   390  		if err != nil {
   391  			t.Fatalf("unexpected error forgetting in helper: %s", err)
   392  		}
   393  
   394  		creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com"))
   395  		if err != nil {
   396  			t.Fatalf("failed to read back stored-in-helper credentials: %s", err)
   397  		}
   398  
   399  		if got, want := testReqAuthHeader(t, creds), ""; got != want {
   400  			t.Fatalf("wrong header value for stored-in-helper\ngot:  %s\nwant: %s", got, want)
   401  		}
   402  	}
   403  
   404  	{
   405  		// Finally, the log in our mock helper should show that it was only
   406  		// asked to deal with stored-in-helper, not stored-locally.
   407  		got := mockHelper.log
   408  		want := []mockCredentialsHelperChange{
   409  			{
   410  				Host:   svchost.Hostname("stored-in-helper.example.com"),
   411  				Action: "store",
   412  			},
   413  			{
   414  				Host:   svchost.Hostname("stored-in-helper.example.com"),
   415  				Action: "forget",
   416  			},
   417  		}
   418  		if diff := cmp.Diff(want, got); diff != "" {
   419  			t.Errorf("unexpected credentials helper operation log\n%s", diff)
   420  		}
   421  	}
   422  }
   423  
   424  type mockCredentialsHelperChange struct {
   425  	Host   svchost.Hostname
   426  	Action string
   427  }
   428  
   429  type mockCredentialsHelper struct {
   430  	current map[svchost.Hostname]cty.Value
   431  	log     []mockCredentialsHelperChange
   432  }
   433  
   434  // Assertion that mockCredentialsHelper implements svcauth.CredentialsSource
   435  var _ svcauth.CredentialsSource = (*mockCredentialsHelper)(nil)
   436  
   437  func (s *mockCredentialsHelper) ForHost(hostname svchost.Hostname) (svcauth.HostCredentials, error) {
   438  	v, ok := s.current[hostname]
   439  	if !ok {
   440  		return nil, nil
   441  	}
   442  	return svcauth.HostCredentialsFromObject(v), nil
   443  }
   444  
   445  func (s *mockCredentialsHelper) StoreForHost(hostname svchost.Hostname, new svcauth.HostCredentialsWritable) error {
   446  	s.log = append(s.log, mockCredentialsHelperChange{
   447  		Host:   hostname,
   448  		Action: "store",
   449  	})
   450  	s.current[hostname] = new.ToStore()
   451  	return nil
   452  }
   453  
   454  func (s *mockCredentialsHelper) ForgetForHost(hostname svchost.Hostname) error {
   455  	s.log = append(s.log, mockCredentialsHelperChange{
   456  		Host:   hostname,
   457  		Action: "forget",
   458  	})
   459  	delete(s.current, hostname)
   460  	return nil
   461  }