github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/github/app_auth_roundtripper_test.go (about) 1 /* 2 Copyright 2020 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 github 18 19 import ( 20 "bytes" 21 "crypto/rand" 22 "crypto/rsa" 23 "encoding/json" 24 "fmt" 25 "io" 26 "net/http" 27 "strings" 28 "sync" 29 "testing" 30 "time" 31 32 "github.com/sirupsen/logrus" 33 34 utilpointer "k8s.io/utils/pointer" 35 ) 36 37 // *appsAuthError implements the error interface 38 var _ error = &appsAuthError{} 39 40 // *appsRoundTripper implements the http.RoundTripper interface 41 var _ http.RoundTripper = &appsRoundTripper{} 42 43 type fakeRoundTripper struct { 44 lock sync.Mutex 45 requests []*http.Request 46 // path -> response 47 responses map[string]*http.Response 48 } 49 50 func (frt *fakeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { 51 frt.lock.Lock() 52 defer frt.lock.Unlock() 53 frt.requests = append(frt.requests, r) 54 if response, found := frt.responses[r.URL.Path]; found { 55 return response, nil 56 } 57 return &http.Response{StatusCode: 400}, nil 58 } 59 60 func TestAppsAuth(t *testing.T) { 61 62 const appID = "13" 63 testCases := []struct { 64 name string 65 githubBaseURL string 66 cachedAppSlug *string 67 cachedInstallations map[string]AppInstallation 68 cachedTokens map[int64]*AppInstallationToken 69 doRequest func(Client) error 70 responses map[string]*http.Response 71 verifyRequests func([]*http.Request) error 72 }{ 73 { 74 name: "App auth success", 75 doRequest: func(c Client) error { 76 _, err := c.GetApp() 77 return err 78 }, 79 responses: map[string]*http.Response{"/app": { 80 StatusCode: 200, 81 Body: serializeOrDie(App{}), 82 }}, 83 verifyRequests: func(r []*http.Request) error { 84 if n := len(r); n != 1 { 85 return fmt.Errorf("expected exactly one request, got %d", n) 86 } 87 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 88 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 89 } 90 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != appID { 91 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value %s", val, appID) 92 } 93 return nil 94 }, 95 }, 96 { 97 name: "App auth failure", 98 doRequest: func(c Client) error { 99 _, err := c.GetApp() 100 if expectedMsg := "status code 401 not one of [200], body: "; err == nil || err.Error() != expectedMsg { 101 return fmt.Errorf("expected error to have message %s, was %w", expectedMsg, err) 102 } 103 return nil 104 }, 105 responses: map[string]*http.Response{"/app": { 106 StatusCode: 401, 107 Body: io.NopCloser(&bytes.Buffer{}), 108 }}, 109 verifyRequests: func(r []*http.Request) error { 110 if n := len(r); n != 1 { 111 return fmt.Errorf("expected exactly one request, got %d", n) 112 } 113 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 114 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 115 } 116 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != appID { 117 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value %s", val, appID) 118 } 119 return nil 120 }, 121 }, 122 { 123 name: "App installation auth success, everything served from cache", 124 cachedAppSlug: utilpointer.String("ci-app"), 125 cachedInstallations: map[string]AppInstallation{"org": {ID: 1}}, 126 cachedTokens: map[int64]*AppInstallationToken{1: {Token: "the-token", ExpiresAt: time.Now().Add(time.Hour)}}, 127 doRequest: func(c Client) error { 128 _, err := c.GetOrg("org") 129 return err 130 }, 131 responses: map[string]*http.Response{"/orgs/org": { 132 StatusCode: 200, 133 Body: serializeOrDie(Organization{}), 134 }}, 135 verifyRequests: func(r []*http.Request) error { 136 if n := len(r); n != 1 { 137 return fmt.Errorf("expected exactly one request, got %d", n) 138 } 139 if val := r[0].Header.Get("Authorization"); val != "Bearer the-token" { 140 return fmt.Errorf("expected the Authorization header %q to be 'Bearer the-token'", val) 141 } 142 expectedGHCacheHeaderValue := "ci-app - org" 143 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != expectedGHCacheHeaderValue { 144 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to be %q", val, expectedGHCacheHeaderValue) 145 } 146 return nil 147 }, 148 }, 149 { 150 name: "App installation auth success, new token is requested", 151 cachedAppSlug: utilpointer.String("ci-app"), 152 cachedInstallations: map[string]AppInstallation{"org": {ID: 1}}, 153 doRequest: func(c Client) error { 154 _, err := c.GetOrg("org") 155 return err 156 }, 157 responses: map[string]*http.Response{ 158 "/orgs/org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 159 "/app/installations/1/access_tokens": {StatusCode: 201, Body: serializeOrDie(AppInstallationToken{Token: "the-token"})}, 160 }, 161 verifyRequests: func(r []*http.Request) error { 162 if n := len(r); n != 2 { 163 return fmt.Errorf("expected exactly one request, got %d", n) 164 } 165 expectedGHCacheHeaderValue := "ci-app - org" 166 if r[0].URL.Path != "/app/installations/1/access_tokens" { 167 return fmt.Errorf("expected first request to request a token, but had path %s", r[0].URL.Path) 168 } 169 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 170 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 171 } 172 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 173 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 174 } 175 if val := r[1].Header.Get("Authorization"); val != "Bearer the-token" { 176 return fmt.Errorf("expected the Authorization header %q to be 'Bearer the-token'", val) 177 } 178 if val := r[1].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != expectedGHCacheHeaderValue { 179 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to be %q", val, expectedGHCacheHeaderValue) 180 } 181 return nil 182 }, 183 }, 184 { 185 name: "App installation auth success, installations and token is requsted", 186 cachedAppSlug: utilpointer.String("ci-app"), 187 doRequest: func(c Client) error { 188 _, err := c.GetOrg("org") 189 return err 190 }, 191 responses: map[string]*http.Response{ 192 "/app/installations": {StatusCode: 200, Body: serializeOrDie([]AppInstallation{{ID: 1, Account: User{Login: "org"}}})}, 193 "/app/installations/1/access_tokens": {StatusCode: 201, Body: serializeOrDie(AppInstallationToken{Token: "the-token"})}, 194 "/orgs/org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 195 }, 196 verifyRequests: func(r []*http.Request) error { 197 if n := len(r); n != 3 { 198 return fmt.Errorf("expected exactly three request, got %d", n) 199 } 200 if r[0].URL.Path != "/app/installations" { 201 return fmt.Errorf("expected first request to have path '/app/installations' but had %q", r[0].URL.Path) 202 } 203 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 204 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 205 } 206 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 207 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 208 } 209 210 if r[1].URL.Path != "/app/installations/1/access_tokens" { 211 return fmt.Errorf("expected second request to request a token, but had path %s", r[0].URL.Path) 212 } 213 if val := r[1].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 214 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 215 } 216 if val := r[1].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 217 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 218 } 219 220 expectedGHCacheHeaderValue := "ci-app - org" 221 if val := r[2].Header.Get("Authorization"); val != "Bearer the-token" { 222 return fmt.Errorf("expected the Authorization header %q to be 'Bearer the-token'", val) 223 } 224 if val := r[2].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != expectedGHCacheHeaderValue { 225 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to be %q", val, expectedGHCacheHeaderValue) 226 } 227 return nil 228 }, 229 }, 230 { 231 name: "App installation auth success, slug, installations and token is requsted", 232 doRequest: func(c Client) error { 233 _, err := c.GetOrg("org") 234 return err 235 }, 236 responses: map[string]*http.Response{ 237 "/app": {StatusCode: 200, Body: serializeOrDie(App{Slug: "ci-app"})}, 238 "/app/installations": {StatusCode: 200, Body: serializeOrDie([]AppInstallation{{ID: 1, Account: User{Login: "org"}}})}, 239 "/app/installations/1/access_tokens": {StatusCode: 201, Body: serializeOrDie(AppInstallationToken{Token: "the-token"})}, 240 "/orgs/org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 241 }, 242 verifyRequests: func(r []*http.Request) error { 243 if n := len(r); n != 4 { 244 return fmt.Errorf("expected exactly four request, got %d", n) 245 } 246 247 if r[0].URL.Path != "/app" { 248 return fmt.Errorf("expected first request to have path '/app' but had %q", r[0].URL.Path) 249 } 250 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 251 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 252 } 253 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "13" { 254 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value '13'", val) 255 } 256 257 if r[1].URL.Path != "/app/installations" { 258 return fmt.Errorf("expected first request to have path '/app/installations' but had %q", r[0].URL.Path) 259 } 260 if val := r[1].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 261 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 262 } 263 if val := r[1].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 264 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 265 } 266 267 if r[2].URL.Path != "/app/installations/1/access_tokens" { 268 return fmt.Errorf("expected second request to request a token, but had path %s", r[0].URL.Path) 269 } 270 if val := r[2].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 271 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 272 } 273 if val := r[2].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 274 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 275 } 276 277 expectedGHCacheHeaderValue := "ci-app - org" 278 if val := r[3].Header.Get("Authorization"); val != "Bearer the-token" { 279 return fmt.Errorf("expected the Authorization header %q to be 'Bearer the-token'", val) 280 } 281 if val := r[3].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != expectedGHCacheHeaderValue { 282 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to be %q", val, expectedGHCacheHeaderValue) 283 } 284 return nil 285 }, 286 }, 287 { 288 name: "App installation auth with custom base url with path is successful, slug, installations and token are requsted", 289 githubBaseURL: "https://corp.internal/api/v3", 290 doRequest: func(c Client) error { 291 _, err := c.GetOrg("org") 292 return err 293 }, 294 responses: map[string]*http.Response{ 295 "/api/v3/app": {StatusCode: 200, Body: serializeOrDie(App{Slug: "ci-app"})}, 296 "/api/v3/app/installations": {StatusCode: 200, Body: serializeOrDie([]AppInstallation{{ID: 1, Account: User{Login: "org"}}})}, 297 "/api/v3/app/installations/1/access_tokens": {StatusCode: 201, Body: serializeOrDie(AppInstallationToken{Token: "the-token"})}, 298 "/api/v3/orgs/org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 299 }, 300 verifyRequests: func(r []*http.Request) error { 301 if n := len(r); n != 4 { 302 return fmt.Errorf("expected exactly four request, got %d", n) 303 } 304 305 if r[0].URL.Path != "/api/v3/app" { 306 return fmt.Errorf("expected first request to have path '/api/v3/app' but had %q", r[0].URL.Path) 307 } 308 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 309 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 310 } 311 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "13" { 312 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value '13'", val) 313 } 314 315 if r[1].URL.Path != "/api/v3/app/installations" { 316 return fmt.Errorf("expected first request to have path '/api/v3/app/installations' but had %q", r[0].URL.Path) 317 } 318 if val := r[1].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 319 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 320 } 321 if val := r[1].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 322 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 323 } 324 325 if r[2].URL.Path != "/api/v3/app/installations/1/access_tokens" { 326 return fmt.Errorf("expected second request to request a token, but had path %s", r[0].URL.Path) 327 } 328 if val := r[2].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 329 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 330 } 331 if val := r[2].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 332 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 333 } 334 335 expectedGHCacheHeaderValue := "ci-app - org" 336 if val := r[3].Header.Get("Authorization"); val != "Bearer the-token" { 337 return fmt.Errorf("expected the Authorization header %q to be 'Bearer the-token'", val) 338 } 339 if val := r[3].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != expectedGHCacheHeaderValue { 340 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to be %q", val, expectedGHCacheHeaderValue) 341 } 342 return nil 343 }, 344 }, 345 { 346 name: "App installation request has no installation, failure", 347 cachedAppSlug: utilpointer.String("ci-app"), 348 doRequest: func(c Client) error { 349 _, err := c.GetOrg("other-org") 350 expectedErrMsgSubstr := "failed to get installation id for org other-org: the github app is not installed in organization other-org" 351 if err == nil || !strings.Contains(err.Error(), expectedErrMsgSubstr) { 352 return fmt.Errorf("expected error to contain string %s, was %w", expectedErrMsgSubstr, err) 353 } 354 return nil 355 }, 356 responses: map[string]*http.Response{ 357 "/app/installations": {StatusCode: 200, Body: serializeOrDie([]AppInstallation{{ID: 1, Account: User{Login: "org"}}})}, 358 }, 359 verifyRequests: func(r []*http.Request) error { 360 if n := len(r); n != 1 { 361 return fmt.Errorf("expected exactly four request, got %d", n) 362 } 363 364 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") { 365 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer '", val) 366 } 367 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 368 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 369 } 370 371 return nil 372 }, 373 }, 374 { 375 name: "Check app installation for repo uses JWT", 376 cachedAppSlug: utilpointer.String("ci-app"), 377 cachedTokens: map[int64]*AppInstallationToken{1: {Token: "the-token", ExpiresAt: time.Now().Add(time.Hour)}}, 378 cachedInstallations: map[string]AppInstallation{"kuber": {ID: 1}}, 379 doRequest: func(c Client) error { 380 _, err := c.IsAppInstalled("kuber", "k8.s-repo") 381 return err 382 }, 383 responses: map[string]*http.Response{"/repos/kuber/k8.s-repo/installation": { 384 StatusCode: 200, 385 Body: serializeOrDie(AppInstallation{}), 386 }}, 387 verifyRequests: func(r []*http.Request) error { 388 if n := len(r); n != 1 { 389 return fmt.Errorf("expected exactly one request, got %d", n) 390 } 391 if val := r[0].Header.Get("Authorization"); !strings.HasPrefix(val, "Bearer ") || val == "Bearer the-token" { 392 return fmt.Errorf("expected the Authorization header %q to start with 'Bearer ', and to be a JWT", val) 393 } 394 if val := r[0].Header.Get("X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER"); val != "ci-app" { 395 return fmt.Errorf("expected X-PROW-GHCACHE-TOKEN-BUDGET-IDENTIFIER header %q to have value ci-app", val) 396 } 397 return nil 398 }, 399 }, 400 } 401 402 // Generate it only once. Can not be smaller, otherwise the JWT signature generation 403 // fails with "message too long for RSA public key size" 404 rsaKey, err := rsa.GenerateKey(rand.Reader, 512) 405 if err != nil { 406 t.Fatalf("Failed to generate RSA key: %v", err) 407 } 408 409 for _, tc := range testCases { 410 t.Run(tc.name, func(t *testing.T) { 411 if tc.githubBaseURL == "" { 412 tc.githubBaseURL = "https://api.github.com" 413 } 414 _, _, ghClient, err := NewAppsAuthClientWithFields(logrus.Fields{}, func(b []byte) []byte { return b }, appID, func() *rsa.PrivateKey { return rsaKey }, "", tc.githubBaseURL) 415 if err != nil { 416 t.Fatalf("failed to construct client: %v", err) 417 } 418 419 appsRoundTripper := validateAppsRoundTripper(t, ghClient) 420 421 roundTripper := &fakeRoundTripper{ 422 responses: tc.responses, 423 } 424 appsRoundTripper.upstream = roundTripper 425 if tc.cachedAppSlug != nil { 426 appsRoundTripper.appSlug = *tc.cachedAppSlug 427 } 428 if tc.cachedInstallations != nil { 429 appsRoundTripper.installations = tc.cachedInstallations 430 } 431 if tc.cachedTokens != nil { 432 appsRoundTripper.tokens = tc.cachedTokens 433 } 434 435 if err := tc.doRequest(ghClient); err != nil { 436 t.Fatalf("Failed to do request: %v", err) 437 } 438 439 if err := tc.verifyRequests(roundTripper.requests); err != nil { 440 t.Errorf("Request verification failed: %v", err) 441 } 442 }) 443 } 444 } 445 446 func validateAppsRoundTripper(t *testing.T, ghClient interface{}) *appsRoundTripper { 447 if _, ok := ghClient.(*client); !ok { 448 t.Fatalf("ghclient is a %T not a *client", ghClient) 449 } 450 if _, ok := ghClient.(*client).client.(*ghThrottler); !ok { 451 t.Fatalf("the ghclients client is a %T not a *throttler", ghClient.(*client).client) 452 } 453 if _, ok := ghClient.(*client).client.(*ghThrottler).http.(*http.Client); !ok { 454 t.Fatalf("the ghclients client is a %T not a *http.Client", ghClient.(*client).client.(*ghThrottler).http) 455 } 456 if _, ok := ghClient.(*client).client.(*ghThrottler).http.(*http.Client).Transport.(*appsRoundTripper); !ok { 457 t.Fatalf("the ghclients didn't get configured to use the appsRoundTripper, found %T instead", ghClient.(*client).client.(*ghThrottler).http.(*http.Client).Transport) 458 } 459 return ghClient.(*client).client.(*ghThrottler).http.(*http.Client).Transport.(*appsRoundTripper) 460 } 461 462 func TestAppsRoundTripperThreadSafety(t *testing.T) { 463 const appID = "13" 464 // Can not be smaller, otherwise the JWT signature generation 465 // fails with "message too long for RSA public key size" 466 rsaKey, err := rsa.GenerateKey(rand.Reader, 512) 467 if err != nil { 468 t.Fatalf("Failed to generate RSA key: %v", err) 469 } 470 471 _, _, ghClient, err := NewAppsAuthClientWithFields(logrus.Fields{}, nil, appID, func() *rsa.PrivateKey { return rsaKey }, "", "https://api.github.com") 472 if err != nil { 473 t.Fatalf("failed to construct github client: %v", err) 474 } 475 476 // installation and token for requests to "org" are cached, but need to be fetched for requests 477 // to "other-org" 478 appsRoundTripper := validateAppsRoundTripper(t, ghClient) 479 appsRoundTripper.installations = map[string]AppInstallation{"org": {ID: 1}} 480 appsRoundTripper.tokens = map[int64]*AppInstallationToken{1: {Token: "the-token", ExpiresAt: time.Now().Add(time.Hour)}} 481 appsRoundTripper.upstream = &fakeRoundTripper{ 482 responses: map[string]*http.Response{ 483 "/app": {StatusCode: 200, Body: serializeOrDie(App{Slug: "ci-app"})}, 484 "/app/installations": {StatusCode: 200, Body: serializeOrDie([]AppInstallation{ 485 {ID: 1, Account: User{Login: "org"}}, 486 {ID: 2, Account: User{Login: "other-org"}}, 487 })}, 488 "/app/installations/2/access_tokens": {StatusCode: 201, Body: serializeOrDie(AppInstallationToken{Token: "the-other-token"})}, 489 "/orgs/org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 490 "/orgs/other-org": {StatusCode: 200, Body: serializeOrDie(Organization{})}, 491 }, 492 } 493 494 req1Done, req2Done := make(chan struct{}), make(chan struct{}) 495 496 go func() { 497 defer close(req1Done) 498 if _, err := ghClient.GetOrg("org"); err != nil { 499 t.Errorf("failed to get org org: %v", err) 500 } 501 }() 502 503 go func() { 504 defer close(req2Done) 505 if _, err := ghClient.GetOrg("other-org"); err != nil { 506 t.Errorf("failed to get org other-org: %v", err) 507 } 508 }() 509 510 <-req1Done 511 <-req2Done 512 } 513 514 func serializeOrDie(in interface{}) io.ReadCloser { 515 rawData, err := json.Marshal(in) 516 if err != nil { 517 panic(fmt.Sprintf("Serialization failed: %v", err)) 518 } 519 return io.NopCloser(bytes.NewBuffer(rawData)) 520 }