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  }