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 }