| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package cliconfig | |
| import ( | |
| "net/http" | |
| "os" | |
| "path/filepath" | |
| "testing" | |
| "github.com/google/go-cmp/cmp" | |
| "github.com/zclconf/go-cty/cty" | |
| svchost "github.com/hashicorp/terraform-svchost" | |
| svcauth "github.com/hashicorp/terraform-svchost/auth" | |
| ) | |
| func TestCredentialsForHost(t *testing.T) { | |
| credSrc := &CredentialsSource{ | |
| configured: map[svchost.Hostname]cty.Value{ | |
| "configured.example.com": cty.ObjectVal(map[string]cty.Value{ | |
| "token": cty.StringVal("configured"), | |
| }), | |
| "unused.example.com": cty.ObjectVal(map[string]cty.Value{ | |
| "token": cty.StringVal("incorrectly-configured"), | |
| }), | |
| }, | |
| // We'll use a static source to stand in for what would normally be | |
| // a credentials helper program, since we're only testing the logic | |
| // for choosing when to delegate to the helper here. The logic for | |
| // interacting with a helper program is tested in the svcauth package. | |
| helper: svcauth.StaticCredentialsSource(map[svchost.Hostname]map[string]interface{}{ | |
| "from-helper.example.com": { | |
| "token": "from-helper", | |
| }, | |
| // This should be shadowed by the "configured" entry with the same | |
| // hostname above. | |
| "configured.example.com": { | |
| "token": "incorrectly-from-helper", | |
| }, | |
| }), | |
| helperType: "fake", | |
| } | |
| testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string { | |
| t.Helper() | |
| if creds == nil { | |
| return "" | |
| } | |
| req, err := http.NewRequest("GET", "http://example.com/", nil) | |
| if err != nil { | |
| t.Fatalf("cannot construct HTTP request: %s", err) | |
| } | |
| creds.PrepareRequest(req) | |
| return req.Header.Get("Authorization") | |
| } | |
| t.Run("configured", func(t *testing.T) { | |
| creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com")) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), "Bearer configured"; got != want { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, want) | |
| } | |
| }) | |
| t.Run("from helper", func(t *testing.T) { | |
| creds, err := credSrc.ForHost(svchost.Hostname("from-helper.example.com")) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), "Bearer from-helper"; got != want { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, want) | |
| } | |
| }) | |
| t.Run("not available", func(t *testing.T) { | |
| creds, err := credSrc.ForHost(svchost.Hostname("unavailable.example.com")) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), ""; got != want { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, want) | |
| } | |
| }) | |
| t.Run("set in environment", func(t *testing.T) { | |
| envName := "TF_TOKEN_configured_example_com" | |
| t.Cleanup(func() { | |
| os.Unsetenv(envName) | |
| }) | |
| expectedToken := "configured-by-env" | |
| os.Setenv(envName, expectedToken) | |
| creds, err := credSrc.ForHost(svchost.Hostname("configured.example.com")) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if creds == nil { | |
| t.Fatal("no credentials found") | |
| } | |
| if got := creds.Token(); got != expectedToken { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken) | |
| } | |
| }) | |
| t.Run("punycode name set in environment", func(t *testing.T) { | |
| envName := "TF_TOKEN_env_xn--eckwd4c7cu47r2wf_com" | |
| t.Cleanup(func() { | |
| os.Unsetenv(envName) | |
| }) | |
| expectedToken := "configured-by-env" | |
| os.Setenv(envName, expectedToken) | |
| hostname, _ := svchost.ForComparison("env.ドメイン名例.com") | |
| creds, err := credSrc.ForHost(hostname) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if creds == nil { | |
| t.Fatal("no credentials found") | |
| } | |
| if got := creds.Token(); got != expectedToken { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken) | |
| } | |
| }) | |
| t.Run("hyphens can be encoded as double underscores", func(t *testing.T) { | |
| envName := "TF_TOKEN_env_xn____caf__dma_fr" | |
| expectedToken := "configured-by-fallback" | |
| t.Cleanup(func() { | |
| os.Unsetenv(envName) | |
| }) | |
| os.Setenv(envName, expectedToken) | |
| hostname, _ := svchost.ForComparison("env.café.fr") | |
| creds, err := credSrc.ForHost(hostname) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if creds == nil { | |
| t.Fatal("no credentials found") | |
| } | |
| if got := creds.Token(); got != expectedToken { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken) | |
| } | |
| }) | |
| t.Run("periods are ok", func(t *testing.T) { | |
| envName := "TF_TOKEN_configured.example.com" | |
| expectedToken := "configured-by-env" | |
| t.Cleanup(func() { | |
| os.Unsetenv(envName) | |
| }) | |
| os.Setenv(envName, expectedToken) | |
| hostname, _ := svchost.ForComparison("configured.example.com") | |
| creds, err := credSrc.ForHost(hostname) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if creds == nil { | |
| t.Fatal("no credentials found") | |
| } | |
| if got := creds.Token(); got != expectedToken { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken) | |
| } | |
| }) | |
| t.Run("casing is insensitive", func(t *testing.T) { | |
| envName := "TF_TOKEN_CONFIGUREDUPPERCASE_EXAMPLE_COM" | |
| expectedToken := "configured-by-env" | |
| os.Setenv(envName, expectedToken) | |
| t.Cleanup(func() { | |
| os.Unsetenv(envName) | |
| }) | |
| hostname, _ := svchost.ForComparison("configureduppercase.example.com") | |
| creds, err := credSrc.ForHost(hostname) | |
| if err != nil { | |
| t.Fatalf("unexpected error: %s", err) | |
| } | |
| if creds == nil { | |
| t.Fatal("no credentials found") | |
| } | |
| if got := creds.Token(); got != expectedToken { | |
| t.Errorf("wrong result\ngot: %s\nwant: %s", got, expectedToken) | |
| } | |
| }) | |
| } | |
| func TestCredentialsStoreForget(t *testing.T) { | |
| d := t.TempDir() | |
| mockCredsFilename := filepath.Join(d, "credentials.tfrc.json") | |
| cfg := &Config{ | |
| // This simulates there being a credentials block manually configured | |
| // in some file _other than_ credentials.tfrc.json. | |
| Credentials: map[string]map[string]interface{}{ | |
| "manually-configured.example.com": { | |
| "token": "manually-configured", | |
| }, | |
| }, | |
| } | |
| // We'll initially use a credentials source with no credentials helper at | |
| // all, and thus with credentials stored in the credentials file. | |
| credSrc := cfg.credentialsSource( | |
| "", nil, | |
| mockCredsFilename, | |
| ) | |
| testReqAuthHeader := func(t *testing.T, creds svcauth.HostCredentials) string { | |
| t.Helper() | |
| if creds == nil { | |
| return "" | |
| } | |
| req, err := http.NewRequest("GET", "http://example.com/", nil) | |
| if err != nil { | |
| t.Fatalf("cannot construct HTTP request: %s", err) | |
| } | |
| creds.PrepareRequest(req) | |
| return req.Header.Get("Authorization") | |
| } | |
| // Because these store/forget calls have side-effects, we'll bail out with | |
| // t.Fatal (or equivalent) as soon as anything unexpected happens. | |
| // Otherwise downstream tests might fail in confusing ways. | |
| { | |
| err := credSrc.StoreForHost( | |
| svchost.Hostname("manually-configured.example.com"), | |
| svcauth.HostCredentialsToken("not-manually-configured"), | |
| ) | |
| if err == nil { | |
| t.Fatalf("successfully stored for manually-configured; want error") | |
| } | |
| if _, ok := err.(ErrUnwritableHostCredentials); !ok { | |
| t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err) | |
| } | |
| } | |
| { | |
| err := credSrc.ForgetForHost( | |
| svchost.Hostname("manually-configured.example.com"), | |
| ) | |
| if err == nil { | |
| t.Fatalf("successfully forgot for manually-configured; want error") | |
| } | |
| if _, ok := err.(ErrUnwritableHostCredentials); !ok { | |
| t.Fatalf("wrong error type %T; want ErrUnwritableHostCredentials", err) | |
| } | |
| } | |
| { | |
| // We don't have a credentials file at all yet, so this first call | |
| // must create it. | |
| err := credSrc.StoreForHost( | |
| svchost.Hostname("stored-locally.example.com"), | |
| svcauth.HostCredentialsToken("stored-locally"), | |
| ) | |
| if err != nil { | |
| t.Fatalf("unexpected error storing locally: %s", err) | |
| } | |
| creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com")) | |
| if err != nil { | |
| t.Fatalf("failed to read back stored-locally credentials: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally"; got != want { | |
| t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want) | |
| } | |
| got := ReadHostsInCredentialsFile(mockCredsFilename) | |
| want := map[svchost.Hostname]struct{}{ | |
| svchost.Hostname("stored-locally.example.com"): struct{}{}, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong credentials file content\n%s", diff) | |
| } | |
| } | |
| // Now we'll switch to having a credential helper active. | |
| // If we were loading the real CLI config from disk here then this | |
| // entry would already be in cfg.Credentials, but we need to fake that | |
| // in the test because we're constructing this *Config value directly. | |
| cfg.Credentials["stored-locally.example.com"] = map[string]interface{}{ | |
| "token": "stored-locally", | |
| } | |
| mockHelper := &mockCredentialsHelper{current: make(map[svchost.Hostname]cty.Value)} | |
| credSrc = cfg.credentialsSource( | |
| "mock", mockHelper, | |
| mockCredsFilename, | |
| ) | |
| { | |
| err := credSrc.StoreForHost( | |
| svchost.Hostname("manually-configured.example.com"), | |
| svcauth.HostCredentialsToken("not-manually-configured"), | |
| ) | |
| if err == nil { | |
| t.Fatalf("successfully stored for manually-configured with helper active; want error") | |
| } | |
| } | |
| { | |
| err := credSrc.StoreForHost( | |
| svchost.Hostname("stored-in-helper.example.com"), | |
| svcauth.HostCredentialsToken("stored-in-helper"), | |
| ) | |
| if err != nil { | |
| t.Fatalf("unexpected error storing in helper: %s", err) | |
| } | |
| creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com")) | |
| if err != nil { | |
| t.Fatalf("failed to read back stored-in-helper credentials: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), "Bearer stored-in-helper"; got != want { | |
| t.Fatalf("wrong header value for stored-in-helper\ngot: %s\nwant: %s", got, want) | |
| } | |
| // Nothing should have changed in the saved credentials file | |
| got := ReadHostsInCredentialsFile(mockCredsFilename) | |
| want := map[svchost.Hostname]struct{}{ | |
| svchost.Hostname("stored-locally.example.com"): struct{}{}, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong credentials file content\n%s", diff) | |
| } | |
| } | |
| { | |
| // Because stored-locally is already in the credentials file, a new | |
| // store should be sent there rather than to the credentials helper. | |
| err := credSrc.StoreForHost( | |
| svchost.Hostname("stored-locally.example.com"), | |
| svcauth.HostCredentialsToken("stored-locally-again"), | |
| ) | |
| if err != nil { | |
| t.Fatalf("unexpected error storing locally again: %s", err) | |
| } | |
| creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com")) | |
| if err != nil { | |
| t.Fatalf("failed to read back stored-locally credentials: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), "Bearer stored-locally-again"; got != want { | |
| t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want) | |
| } | |
| } | |
| { | |
| // Forgetting a host already in the credentials file should remove it | |
| // from the credentials file, not from the helper. | |
| err := credSrc.ForgetForHost( | |
| svchost.Hostname("stored-locally.example.com"), | |
| ) | |
| if err != nil { | |
| t.Fatalf("unexpected error forgetting locally: %s", err) | |
| } | |
| creds, err := credSrc.ForHost(svchost.Hostname("stored-locally.example.com")) | |
| if err != nil { | |
| t.Fatalf("failed to read back stored-locally credentials: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), ""; got != want { | |
| t.Fatalf("wrong header value for stored-locally\ngot: %s\nwant: %s", got, want) | |
| } | |
| // Should not be present in the credentials file anymore | |
| got := ReadHostsInCredentialsFile(mockCredsFilename) | |
| want := map[svchost.Hostname]struct{}{} | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong credentials file content\n%s", diff) | |
| } | |
| } | |
| { | |
| err := credSrc.ForgetForHost( | |
| svchost.Hostname("stored-in-helper.example.com"), | |
| ) | |
| if err != nil { | |
| t.Fatalf("unexpected error forgetting in helper: %s", err) | |
| } | |
| creds, err := credSrc.ForHost(svchost.Hostname("stored-in-helper.example.com")) | |
| if err != nil { | |
| t.Fatalf("failed to read back stored-in-helper credentials: %s", err) | |
| } | |
| if got, want := testReqAuthHeader(t, creds), ""; got != want { | |
| t.Fatalf("wrong header value for stored-in-helper\ngot: %s\nwant: %s", got, want) | |
| } | |
| } | |
| { | |
| // Finally, the log in our mock helper should show that it was only | |
| // asked to deal with stored-in-helper, not stored-locally. | |
| got := mockHelper.log | |
| want := []mockCredentialsHelperChange{ | |
| { | |
| Host: svchost.Hostname("stored-in-helper.example.com"), | |
| Action: "store", | |
| }, | |
| { | |
| Host: svchost.Hostname("stored-in-helper.example.com"), | |
| Action: "forget", | |
| }, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Errorf("unexpected credentials helper operation log\n%s", diff) | |
| } | |
| } | |
| } | |
| func TestReadHostsInCredentialsFile(t *testing.T) { | |
| t.Run("missing credentials file", func(t *testing.T) { | |
| dir := t.TempDir() | |
| filename := filepath.Join(dir, "credentials.tfrc.json") | |
| got := ReadHostsInCredentialsFile(filename) | |
| if diff := cmp.Diff(map[svchost.Hostname]struct{}(nil), got); diff != "" { | |
| t.Fatalf("wrong hosts returned\n%s", diff) | |
| } | |
| }) | |
| t.Run("empty credentials object", func(t *testing.T) { | |
| dir := t.TempDir() | |
| filename := filepath.Join(dir, "credentials.tfrc.json") | |
| if err := os.WriteFile(filename, []byte(`{"credentials":{}}`), 0600); err != nil { | |
| t.Fatalf("failed to write credentials file: %s", err) | |
| } | |
| got := ReadHostsInCredentialsFile(filename) | |
| if diff := cmp.Diff(map[svchost.Hostname]struct{}{}, got); diff != "" { | |
| t.Fatalf("wrong hosts returned\n%s", diff) | |
| } | |
| }) | |
| t.Run("one host", func(t *testing.T) { | |
| dir := t.TempDir() | |
| filename := filepath.Join(dir, "credentials.tfrc.json") | |
| if err := os.WriteFile(filename, []byte(`{"credentials":{"tfe.company.com":{"token":"x"}}}`), 0600); err != nil { | |
| t.Fatalf("failed to write credentials file: %s", err) | |
| } | |
| got := ReadHostsInCredentialsFile(filename) | |
| want := map[svchost.Hostname]struct{}{ | |
| svchost.Hostname("tfe.company.com"): {}, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong hosts returned\n%s", diff) | |
| } | |
| }) | |
| t.Run("multiple hosts", func(t *testing.T) { | |
| dir := t.TempDir() | |
| filename := filepath.Join(dir, "credentials.tfrc.json") | |
| contents := `{ | |
| "credentials": { | |
| "third.example.com": {"token":"1"}, | |
| "first.example.com": {"token":"2"}, | |
| "second.example.com": {"token":"3"} | |
| } | |
| }` | |
| if err := os.WriteFile(filename, []byte(contents), 0600); err != nil { | |
| t.Fatalf("failed to write credentials file: %s", err) | |
| } | |
| got := ReadHostsInCredentialsFile(filename) | |
| want := map[svchost.Hostname]struct{}{ | |
| svchost.Hostname("third.example.com"): {}, | |
| svchost.Hostname("first.example.com"): {}, | |
| svchost.Hostname("second.example.com"): {}, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong hosts returned\n%s", diff) | |
| } | |
| }) | |
| t.Run("invalid hosts are ignored", func(t *testing.T) { | |
| dir := t.TempDir() | |
| filename := filepath.Join(dir, "credentials.tfrc.json") | |
| contents := `{ | |
| "credentials": { | |
| "valid.example.com": {"token":"1"}, | |
| "not a hostname": {"token":"2"}, | |
| "another.valid.example.com": {"token":"3"} | |
| } | |
| }` | |
| if err := os.WriteFile(filename, []byte(contents), 0600); err != nil { | |
| t.Fatalf("failed to write credentials file: %s", err) | |
| } | |
| got := ReadHostsInCredentialsFile(filename) | |
| want := map[svchost.Hostname]struct{}{ | |
| svchost.Hostname("valid.example.com"): {}, | |
| svchost.Hostname("another.valid.example.com"): {}, | |
| } | |
| if diff := cmp.Diff(want, got); diff != "" { | |
| t.Fatalf("wrong hosts returned\n%s", diff) | |
| } | |
| }) | |
| } | |
| type mockCredentialsHelperChange struct { | |
| Host svchost.Hostname | |
| Action string | |
| } | |
| type mockCredentialsHelper struct { | |
| current map[svchost.Hostname]cty.Value | |
| log []mockCredentialsHelperChange | |
| } | |
| // Assertion that mockCredentialsHelper implements svcauth.CredentialsSource | |
| var _ svcauth.CredentialsSource = (*mockCredentialsHelper)(nil) | |
| func (s *mockCredentialsHelper) ForHost(hostname svchost.Hostname) (svcauth.HostCredentials, error) { | |
| v, ok := s.current[hostname] | |
| if !ok { | |
| return nil, nil | |
| } | |
| return svcauth.HostCredentialsFromObject(v), nil | |
| } | |
| func (s *mockCredentialsHelper) StoreForHost(hostname svchost.Hostname, new svcauth.HostCredentialsWritable) error { | |
| s.log = append(s.log, mockCredentialsHelperChange{ | |
| Host: hostname, | |
| Action: "store", | |
| }) | |
| s.current[hostname] = new.ToStore() | |
| return nil | |
| } | |
| func (s *mockCredentialsHelper) ForgetForHost(hostname svchost.Hostname) error { | |
| s.log = append(s.log, mockCredentialsHelperChange{ | |
| Host: hostname, | |
| Action: "forget", | |
| }) | |
| delete(s.current, hostname) | |
| return nil | |
| } |