k8s.io/client-go@v0.22.2/plugin/pkg/client/auth/gcp/gcp_test.go (about) 1 /* 2 Copyright 2016 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package gcp 18 19 import ( 20 "fmt" 21 "io/ioutil" 22 "net/http" 23 "os" 24 "os/exec" 25 "reflect" 26 "strings" 27 "sync" 28 "testing" 29 "time" 30 31 "golang.org/x/oauth2" 32 ) 33 34 type fakeOutput struct { 35 args []string 36 output string 37 } 38 39 var ( 40 wantCmd []string 41 // Output for fakeExec, keyed by command 42 execOutputs = map[string]fakeOutput{ 43 "/default/no/args": { 44 args: []string{}, 45 output: `{ 46 "access_token": "faketoken", 47 "token_expiry": "2016-10-31T22:31:09.123000000Z" 48 }`}, 49 "/default/legacy/args": { 50 args: []string{"arg1", "arg2", "arg3"}, 51 output: `{ 52 "access_token": "faketoken", 53 "token_expiry": "2016-10-31T22:31:09.123000000Z" 54 }`}, 55 "/space in path/customkeys": { 56 args: []string{"can", "haz", "auth"}, 57 output: `{ 58 "token": "faketoken", 59 "token_expiry": { 60 "datetime": "2016-10-31 22:31:09.123" 61 } 62 }`}, 63 "missing/tokenkey/noargs": { 64 args: []string{}, 65 output: `{ 66 "broken": "faketoken", 67 "token_expiry": { 68 "datetime": "2016-10-31 22:31:09.123000000Z" 69 } 70 }`}, 71 "missing/expirykey/legacyargs": { 72 args: []string{"split", "on", "whitespace"}, 73 output: `{ 74 "access_token": "faketoken", 75 "expires": "2016-10-31T22:31:09.123000000Z" 76 }`}, 77 "invalid expiry/timestamp": { 78 args: []string{"foo", "--bar", "--baz=abc,def"}, 79 output: `{ 80 "access_token": "faketoken", 81 "token_expiry": "sometime soon, idk" 82 }`}, 83 "badjson": { 84 args: []string{}, 85 output: `{ 86 "access_token": "faketoken", 87 "token_expiry": "sometime soon, idk" 88 ------ 89 `}, 90 } 91 ) 92 93 func fakeExec(command string, args ...string) *exec.Cmd { 94 cs := []string{"-test.run=TestHelperProcess", "--", command} 95 cs = append(cs, args...) 96 cmd := exec.Command(os.Args[0], cs...) 97 cmd.Env = []string{"GO_WANT_HELPER_PROCESS=1"} 98 return cmd 99 } 100 101 func TestHelperProcess(t *testing.T) { 102 if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { 103 return 104 } 105 // Strip out the leading args used to exec into this function. 106 gotCmd := os.Args[3] 107 gotArgs := os.Args[4:] 108 output, ok := execOutputs[gotCmd] 109 if !ok { 110 fmt.Fprintf(os.Stdout, "unexpected call cmd=%q args=%v\n", gotCmd, gotArgs) 111 os.Exit(1) 112 } else if !reflect.DeepEqual(output.args, gotArgs) { 113 fmt.Fprintf(os.Stdout, "call cmd=%q got args %v, want: %v\n", gotCmd, gotArgs, output.args) 114 os.Exit(1) 115 } 116 fmt.Fprintf(os.Stdout, output.output) 117 os.Exit(0) 118 } 119 120 func Test_isCmdTokenSource(t *testing.T) { 121 c1 := map[string]string{"cmd-path": "foo"} 122 if v := isCmdTokenSource(c1); !v { 123 t.Fatalf("cmd-path present in config (%+v), but got %v", c1, v) 124 } 125 126 c2 := map[string]string{"cmd-args": "foo bar"} 127 if v := isCmdTokenSource(c2); v { 128 t.Fatalf("cmd-path not present in config (%+v), but got %v", c2, v) 129 } 130 } 131 132 func Test_tokenSource_cmd(t *testing.T) { 133 if _, err := tokenSource(true, map[string]string{}); err == nil { 134 t.Fatalf("expected error, cmd-args not present in config") 135 } 136 137 c := map[string]string{ 138 "cmd-path": "foo", 139 "cmd-args": "bar"} 140 ts, err := tokenSource(true, c) 141 if err != nil { 142 t.Fatalf("failed to return cmd token source: %+v", err) 143 } 144 if ts == nil { 145 t.Fatal("returned nil token source") 146 } 147 if _, ok := ts.(*commandTokenSource); !ok { 148 t.Fatalf("returned token source type:(%T) expected:(*commandTokenSource)", ts) 149 } 150 } 151 152 func Test_tokenSource_cmdCannotBeUsedWithScopes(t *testing.T) { 153 c := map[string]string{ 154 "cmd-path": "foo", 155 "scopes": "A,B"} 156 if _, err := tokenSource(true, c); err == nil { 157 t.Fatal("expected error when scopes is used with cmd-path") 158 } 159 } 160 161 func Test_tokenSource_applicationDefaultCredentials_fails(t *testing.T) { 162 // try to use empty ADC file 163 fakeTokenFile, err := ioutil.TempFile("", "adctoken") 164 if err != nil { 165 t.Fatalf("failed to create fake token file: +%v", err) 166 } 167 fakeTokenFile.Close() 168 defer os.Remove(fakeTokenFile.Name()) 169 170 os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", fakeTokenFile.Name()) 171 defer os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") 172 if _, err := tokenSource(false, map[string]string{}); err == nil { 173 t.Fatalf("expected error because specified ADC token file is not a JSON") 174 } 175 } 176 177 func Test_tokenSource_applicationDefaultCredentials(t *testing.T) { 178 fakeTokenFile, err := ioutil.TempFile("", "adctoken") 179 if err != nil { 180 t.Fatalf("failed to create fake token file: +%v", err) 181 } 182 fakeTokenFile.Close() 183 defer os.Remove(fakeTokenFile.Name()) 184 if err := ioutil.WriteFile(fakeTokenFile.Name(), []byte(`{"type":"service_account"}`), 0600); err != nil { 185 t.Fatalf("failed to write to fake token file: %+v", err) 186 } 187 188 os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", fakeTokenFile.Name()) 189 defer os.Unsetenv("GOOGLE_APPLICATION_CREDENTIALS") 190 ts, err := tokenSource(false, map[string]string{}) 191 if err != nil { 192 t.Fatalf("failed to get a token source: %+v", err) 193 } 194 if ts == nil { 195 t.Fatal("returned nil token source") 196 } 197 } 198 199 func Test_parseScopes(t *testing.T) { 200 cases := []struct { 201 in map[string]string 202 out []string 203 }{ 204 { 205 map[string]string{}, 206 []string{ 207 "https://www.googleapis.com/auth/cloud-platform", 208 "https://www.googleapis.com/auth/userinfo.email"}, 209 }, 210 { 211 map[string]string{"scopes": ""}, 212 []string{}, 213 }, 214 { 215 map[string]string{"scopes": "A,B,C"}, 216 []string{"A", "B", "C"}, 217 }, 218 } 219 220 for _, c := range cases { 221 got := parseScopes(c.in) 222 if !reflect.DeepEqual(got, c.out) { 223 t.Errorf("expected=%v, got=%v", c.out, got) 224 } 225 } 226 } 227 228 func errEquiv(got, want error) bool { 229 if got == want { 230 return true 231 } 232 if got != nil && want != nil { 233 return strings.Contains(got.Error(), want.Error()) 234 } 235 return false 236 } 237 238 func TestCmdTokenSource(t *testing.T) { 239 execCommand = fakeExec 240 fakeExpiry := time.Date(2016, 10, 31, 22, 31, 9, 123000000, time.UTC) 241 customFmt := "2006-01-02 15:04:05.999999999" 242 243 tests := []struct { 244 name string 245 gcpConfig map[string]string 246 tok *oauth2.Token 247 newErr, tokenErr error 248 }{ 249 { 250 "default", 251 map[string]string{ 252 "cmd-path": "/default/no/args", 253 }, 254 &oauth2.Token{ 255 AccessToken: "faketoken", 256 TokenType: "Bearer", 257 Expiry: fakeExpiry, 258 }, 259 nil, 260 nil, 261 }, 262 { 263 "default legacy args", 264 map[string]string{ 265 "cmd-path": "/default/legacy/args arg1 arg2 arg3", 266 }, 267 &oauth2.Token{ 268 AccessToken: "faketoken", 269 TokenType: "Bearer", 270 Expiry: fakeExpiry, 271 }, 272 nil, 273 nil, 274 }, 275 276 { 277 "custom keys", 278 map[string]string{ 279 "cmd-path": "/space in path/customkeys", 280 "cmd-args": "can haz auth", 281 "token-key": "{.token}", 282 "expiry-key": "{.token_expiry.datetime}", 283 "time-fmt": customFmt, 284 }, 285 &oauth2.Token{ 286 AccessToken: "faketoken", 287 TokenType: "Bearer", 288 Expiry: fakeExpiry, 289 }, 290 nil, 291 nil, 292 }, 293 { 294 "missing cmd", 295 map[string]string{ 296 "cmd-path": "", 297 }, 298 nil, 299 fmt.Errorf("missing access token cmd"), 300 nil, 301 }, 302 { 303 "missing token-key", 304 map[string]string{ 305 "cmd-path": "missing/tokenkey/noargs", 306 "token-key": "{.token}", 307 }, 308 nil, 309 nil, 310 fmt.Errorf("error parsing token-key %q", "{.token}"), 311 }, 312 313 { 314 "missing expiry-key", 315 map[string]string{ 316 "cmd-path": "missing/expirykey/legacyargs split on whitespace", 317 "expiry-key": "{.expiry}", 318 }, 319 nil, 320 nil, 321 fmt.Errorf("error parsing expiry-key %q", "{.expiry}"), 322 }, 323 { 324 "invalid expiry timestamp", 325 map[string]string{ 326 "cmd-path": "invalid expiry/timestamp", 327 "cmd-args": "foo --bar --baz=abc,def", 328 }, 329 &oauth2.Token{ 330 AccessToken: "faketoken", 331 TokenType: "Bearer", 332 Expiry: time.Time{}, 333 }, 334 nil, 335 nil, 336 }, 337 { 338 "bad JSON", 339 map[string]string{ 340 "cmd-path": "badjson", 341 }, 342 nil, 343 nil, 344 fmt.Errorf("invalid character '-' after object key:value pair"), 345 }, 346 } 347 348 for _, tc := range tests { 349 provider, err := newGCPAuthProvider("", tc.gcpConfig, nil /* persister */) 350 if !errEquiv(err, tc.newErr) { 351 t.Errorf("%q newGCPAuthProvider error: got %v, want %v", tc.name, err, tc.newErr) 352 continue 353 } 354 if err != nil { 355 continue 356 } 357 ts := provider.(*gcpAuthProvider).tokenSource.(*cachedTokenSource).source.(*commandTokenSource) 358 wantCmd = append([]string{ts.cmd}, ts.args...) 359 tok, err := ts.Token() 360 if !errEquiv(err, tc.tokenErr) { 361 t.Errorf("%q Token() error: got %v, want %v", tc.name, err, tc.tokenErr) 362 } 363 if !reflect.DeepEqual(tok, tc.tok) { 364 t.Errorf("%q Token() got %v, want %v", tc.name, tok, tc.tok) 365 } 366 } 367 } 368 369 type fakePersister struct { 370 lk sync.Mutex 371 cache map[string]string 372 } 373 374 func (f *fakePersister) Persist(cache map[string]string) error { 375 f.lk.Lock() 376 defer f.lk.Unlock() 377 f.cache = map[string]string{} 378 for k, v := range cache { 379 f.cache[k] = v 380 } 381 return nil 382 } 383 384 func (f *fakePersister) read() map[string]string { 385 ret := map[string]string{} 386 f.lk.Lock() 387 defer f.lk.Unlock() 388 for k, v := range f.cache { 389 ret[k] = v 390 } 391 return ret 392 } 393 394 type fakeTokenSource struct { 395 token *oauth2.Token 396 err error 397 } 398 399 func (f *fakeTokenSource) Token() (*oauth2.Token, error) { 400 return f.token, f.err 401 } 402 403 func TestCachedTokenSource(t *testing.T) { 404 tok := &oauth2.Token{AccessToken: "fakeaccesstoken"} 405 persister := &fakePersister{} 406 source := &fakeTokenSource{ 407 token: tok, 408 err: nil, 409 } 410 cache := map[string]string{ 411 "foo": "bar", 412 "baz": "bazinga", 413 } 414 ts, err := newCachedTokenSource("fakeaccesstoken", "", persister, source, cache) 415 if err != nil { 416 t.Fatal(err) 417 } 418 var wg sync.WaitGroup 419 wg.Add(10) 420 for i := 0; i < 10; i++ { 421 go func() { 422 _, err := ts.Token() 423 if err != nil { 424 t.Errorf("unexpected error: %s", err) 425 } 426 wg.Done() 427 }() 428 } 429 wg.Wait() 430 cache["access-token"] = "fakeaccesstoken" 431 cache["expiry"] = tok.Expiry.Format(time.RFC3339Nano) 432 if got := persister.read(); !reflect.DeepEqual(got, cache) { 433 t.Errorf("got cache %v, want %v", got, cache) 434 } 435 } 436 437 type MockTransport struct { 438 res *http.Response 439 } 440 441 func (t *MockTransport) RoundTrip(req *http.Request) (*http.Response, error) { 442 return t.res, nil 443 } 444 445 func Test_cmdTokenSource_roundTrip(t *testing.T) { 446 447 accessToken := "fakeToken" 448 fakeExpiry := time.Now().Add(time.Hour) 449 fakeExpiryStr := fakeExpiry.Format(time.RFC3339Nano) 450 fs := &fakeTokenSource{ 451 token: &oauth2.Token{ 452 AccessToken: accessToken, 453 Expiry: fakeExpiry, 454 }, 455 } 456 457 cmdCache := map[string]string{ 458 "cmd-path": "/path/to/tokensource/cmd", 459 "cmd-args": "--output=json", 460 } 461 cmdCacheUpdated := map[string]string{ 462 "cmd-path": "/path/to/tokensource/cmd", 463 "cmd-args": "--output=json", 464 "access-token": accessToken, 465 "expiry": fakeExpiryStr, 466 } 467 simpleCacheUpdated := map[string]string{ 468 "access-token": accessToken, 469 "expiry": fakeExpiryStr, 470 } 471 472 tests := []struct { 473 name string 474 res http.Response 475 baseCache, expectedCache map[string]string 476 }{ 477 { 478 "Unauthorized", 479 http.Response{StatusCode: http.StatusUnauthorized}, 480 make(map[string]string), 481 make(map[string]string), 482 }, 483 { 484 "Unauthorized, nonempty defaultCache", 485 http.Response{StatusCode: http.StatusUnauthorized}, 486 cmdCache, 487 cmdCache, 488 }, 489 { 490 "Authorized", 491 http.Response{StatusCode: http.StatusOK}, 492 make(map[string]string), 493 simpleCacheUpdated, 494 }, 495 { 496 "Authorized, nonempty defaultCache", 497 http.Response{StatusCode: http.StatusOK}, 498 cmdCache, 499 cmdCacheUpdated, 500 }, 501 } 502 503 persister := &fakePersister{} 504 req := http.Request{Header: http.Header{}} 505 506 for _, tc := range tests { 507 cts, err := newCachedTokenSource(accessToken, fakeExpiry.String(), persister, fs, tc.baseCache) 508 if err != nil { 509 t.Fatalf("unexpected error from newCachedTokenSource: %v", err) 510 } 511 authProvider := gcpAuthProvider{cts, persister} 512 513 fakeTransport := MockTransport{&tc.res} 514 transport := (authProvider.WrapTransport(&fakeTransport)) 515 // call Token to persist/update cache 516 if _, err := cts.Token(); err != nil { 517 t.Fatalf("unexpected error from cachedTokenSource.Token(): %v", err) 518 } 519 520 transport.RoundTrip(&req) 521 522 if got := persister.read(); !reflect.DeepEqual(got, tc.expectedCache) { 523 t.Errorf("got cache %v, want %v", got, tc.expectedCache) 524 } 525 } 526 527 }