k8s.io/apiserver@v0.31.1/plugin/pkg/authenticator/token/webhook/webhook_v1beta1_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 webhook
    18  
    19  import (
    20  	"context"
    21  	"crypto/tls"
    22  	"crypto/x509"
    23  	"encoding/json"
    24  	"fmt"
    25  	"io/ioutil"
    26  	"net/http"
    27  	"net/http/httptest"
    28  	"net/url"
    29  	"os"
    30  	"reflect"
    31  	"testing"
    32  	"time"
    33  
    34  	authenticationv1beta1 "k8s.io/api/authentication/v1beta1"
    35  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    36  	"k8s.io/apiserver/pkg/authentication/authenticator"
    37  	"k8s.io/apiserver/pkg/authentication/token/cache"
    38  	"k8s.io/apiserver/pkg/authentication/user"
    39  	webhookutil "k8s.io/apiserver/pkg/util/webhook"
    40  	v1 "k8s.io/client-go/tools/clientcmd/api/v1"
    41  )
    42  
    43  var apiAuds = authenticator.Audiences{"api"}
    44  
    45  // V1beta1Service mocks a remote authentication service.
    46  type V1beta1Service interface {
    47  	// Review looks at the TokenReviewSpec and provides an authentication
    48  	// response in the TokenReviewStatus.
    49  	Review(*authenticationv1beta1.TokenReview)
    50  	HTTPStatusCode() int
    51  }
    52  
    53  // NewV1beta1TestServer wraps a V1beta1Service as an httptest.Server.
    54  func NewV1beta1TestServer(s V1beta1Service, cert, key, caCert []byte) (*httptest.Server, error) {
    55  	const webhookPath = "/testserver"
    56  	var tlsConfig *tls.Config
    57  	if cert != nil {
    58  		cert, err := tls.X509KeyPair(cert, key)
    59  		if err != nil {
    60  			return nil, err
    61  		}
    62  		tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
    63  	}
    64  
    65  	if caCert != nil {
    66  		rootCAs := x509.NewCertPool()
    67  		rootCAs.AppendCertsFromPEM(caCert)
    68  		if tlsConfig == nil {
    69  			tlsConfig = &tls.Config{}
    70  		}
    71  		tlsConfig.ClientCAs = rootCAs
    72  		tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
    73  	}
    74  
    75  	serveHTTP := func(w http.ResponseWriter, r *http.Request) {
    76  		if r.Method != "POST" {
    77  			http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed)
    78  			return
    79  		}
    80  		if r.URL.Path != webhookPath {
    81  			http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound)
    82  			return
    83  		}
    84  
    85  		var review authenticationv1beta1.TokenReview
    86  		bodyData, _ := ioutil.ReadAll(r.Body)
    87  		if err := json.Unmarshal(bodyData, &review); err != nil {
    88  			http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest)
    89  			return
    90  		}
    91  		// ensure we received the serialized tokenreview as expected
    92  		if review.APIVersion != "authentication.k8s.io/v1beta1" {
    93  			http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest)
    94  			return
    95  		}
    96  		// once we have a successful request, always call the review to record that we were called
    97  		s.Review(&review)
    98  		if s.HTTPStatusCode() < 200 || s.HTTPStatusCode() >= 300 {
    99  			http.Error(w, "HTTP Error", s.HTTPStatusCode())
   100  			return
   101  		}
   102  		type userInfo struct {
   103  			Username string              `json:"username"`
   104  			UID      string              `json:"uid"`
   105  			Groups   []string            `json:"groups"`
   106  			Extra    map[string][]string `json:"extra"`
   107  		}
   108  		type status struct {
   109  			Authenticated bool     `json:"authenticated"`
   110  			User          userInfo `json:"user"`
   111  			Audiences     []string `json:"audiences"`
   112  		}
   113  
   114  		var extra map[string][]string
   115  		if review.Status.User.Extra != nil {
   116  			extra = map[string][]string{}
   117  			for k, v := range review.Status.User.Extra {
   118  				extra[k] = v
   119  			}
   120  		}
   121  
   122  		resp := struct {
   123  			Kind       string `json:"kind"`
   124  			APIVersion string `json:"apiVersion"`
   125  			Status     status `json:"status"`
   126  		}{
   127  			Kind:       "TokenReview",
   128  			APIVersion: authenticationv1beta1.SchemeGroupVersion.String(),
   129  			Status: status{
   130  				review.Status.Authenticated,
   131  				userInfo{
   132  					Username: review.Status.User.Username,
   133  					UID:      review.Status.User.UID,
   134  					Groups:   review.Status.User.Groups,
   135  					Extra:    extra,
   136  				},
   137  				review.Status.Audiences,
   138  			},
   139  		}
   140  		w.Header().Set("Content-Type", "application/json")
   141  		json.NewEncoder(w).Encode(resp)
   142  	}
   143  
   144  	server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP))
   145  	server.TLS = tlsConfig
   146  	server.StartTLS()
   147  
   148  	// Adjust the path to point to our custom path
   149  	serverURL, _ := url.Parse(server.URL)
   150  	serverURL.Path = webhookPath
   151  	server.URL = serverURL.String()
   152  
   153  	return server, nil
   154  }
   155  
   156  // A service that can be set to say yes or no to authentication requests.
   157  type mockV1beta1Service struct {
   158  	allow      bool
   159  	statusCode int
   160  	called     int
   161  }
   162  
   163  func (m *mockV1beta1Service) Review(r *authenticationv1beta1.TokenReview) {
   164  	m.called++
   165  	r.Status.Authenticated = m.allow
   166  	if m.allow {
   167  		r.Status.User.Username = "realHooman@email.com"
   168  	}
   169  }
   170  func (m *mockV1beta1Service) Allow()              { m.allow = true }
   171  func (m *mockV1beta1Service) Deny()               { m.allow = false }
   172  func (m *mockV1beta1Service) HTTPStatusCode() int { return m.statusCode }
   173  
   174  // newV1beta1TokenAuthenticator creates a temporary kubeconfig file from the provided
   175  // arguments and attempts to load a new WebhookTokenAuthenticator from it.
   176  func newV1beta1TokenAuthenticator(serverURL string, clientCert, clientKey, ca []byte, cacheTime time.Duration, implicitAuds authenticator.Audiences) (authenticator.Token, error) {
   177  	tempfile, err := ioutil.TempFile("", "")
   178  	if err != nil {
   179  		return nil, err
   180  	}
   181  	p := tempfile.Name()
   182  	defer os.Remove(p)
   183  	config := v1.Config{
   184  		Clusters: []v1.NamedCluster{
   185  			{
   186  				Cluster: v1.Cluster{Server: serverURL, CertificateAuthorityData: ca},
   187  			},
   188  		},
   189  		AuthInfos: []v1.NamedAuthInfo{
   190  			{
   191  				AuthInfo: v1.AuthInfo{ClientCertificateData: clientCert, ClientKeyData: clientKey},
   192  			},
   193  		},
   194  	}
   195  	if err := json.NewEncoder(tempfile).Encode(config); err != nil {
   196  		return nil, err
   197  	}
   198  
   199  	clientConfig, err := webhookutil.LoadKubeconfig(p, nil)
   200  	if err != nil {
   201  		return nil, err
   202  	}
   203  
   204  	c, err := tokenReviewInterfaceFromConfig(clientConfig, "v1beta1", testRetryBackoff)
   205  	if err != nil {
   206  		return nil, err
   207  	}
   208  
   209  	authn, err := newWithBackoff(c, testRetryBackoff, implicitAuds, 10*time.Second, AuthenticatorMetrics{
   210  		RecordRequestTotal:   noopMetrics{}.RequestTotal,
   211  		RecordRequestLatency: noopMetrics{}.RequestLatency,
   212  	})
   213  	if err != nil {
   214  		return nil, err
   215  	}
   216  
   217  	return cache.New(authn, false, cacheTime, cacheTime), nil
   218  }
   219  
   220  func TestV1beta1TLSConfig(t *testing.T) {
   221  	tests := []struct {
   222  		test                            string
   223  		clientCert, clientKey, clientCA []byte
   224  		serverCert, serverKey, serverCA []byte
   225  		wantErr                         bool
   226  	}{
   227  		{
   228  			test:       "TLS setup between client and server",
   229  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   230  			serverCert: serverCert, serverKey: serverKey, serverCA: caCert,
   231  		},
   232  		{
   233  			test:       "Server does not require client auth",
   234  			clientCA:   caCert,
   235  			serverCert: serverCert, serverKey: serverKey,
   236  		},
   237  		{
   238  			test:       "Server does not require client auth, client provides it",
   239  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   240  			serverCert: serverCert, serverKey: serverKey,
   241  		},
   242  		{
   243  			test:       "Client does not trust server",
   244  			clientCert: clientCert, clientKey: clientKey,
   245  			serverCert: serverCert, serverKey: serverKey,
   246  			wantErr: true,
   247  		},
   248  		{
   249  			test:       "Server does not trust client",
   250  			clientCert: clientCert, clientKey: clientKey, clientCA: caCert,
   251  			serverCert: serverCert, serverKey: serverKey, serverCA: badCACert,
   252  			wantErr: true,
   253  		},
   254  		{
   255  			// Plugin does not support insecure configurations.
   256  			test:    "Server is using insecure connection",
   257  			wantErr: true,
   258  		},
   259  	}
   260  	for _, tt := range tests {
   261  		// Use a closure so defer statements trigger between loop iterations.
   262  		func() {
   263  			service := new(mockV1beta1Service)
   264  			service.statusCode = 200
   265  
   266  			server, err := NewV1beta1TestServer(service, tt.serverCert, tt.serverKey, tt.serverCA)
   267  			if err != nil {
   268  				t.Errorf("%s: failed to create server: %v", tt.test, err)
   269  				return
   270  			}
   271  			defer server.Close()
   272  
   273  			wh, err := newV1beta1TokenAuthenticator(server.URL, tt.clientCert, tt.clientKey, tt.clientCA, 0, nil)
   274  			if err != nil {
   275  				t.Errorf("%s: failed to create client: %v", tt.test, err)
   276  				return
   277  			}
   278  
   279  			// Allow all and see if we get an error.
   280  			service.Allow()
   281  			_, authenticated, err := wh.AuthenticateToken(context.Background(), "t0k3n")
   282  			if tt.wantErr {
   283  				if err == nil {
   284  					t.Errorf("expected error making authorization request: %v", err)
   285  				}
   286  				return
   287  			}
   288  			if !authenticated {
   289  				t.Errorf("%s: failed to authenticate token", tt.test)
   290  				return
   291  			}
   292  
   293  			service.Deny()
   294  			_, authenticated, err = wh.AuthenticateToken(context.Background(), "t0k3n")
   295  			if err != nil {
   296  				t.Errorf("%s: unexpectedly failed AuthenticateToken", tt.test)
   297  			}
   298  			if authenticated {
   299  				t.Errorf("%s: incorrectly authenticated token", tt.test)
   300  			}
   301  		}()
   302  	}
   303  }
   304  
   305  // recorderV1beta1Service records all token review requests, and responds with the
   306  // provided TokenReviewStatus.
   307  type recorderV1beta1Service struct {
   308  	lastRequest authenticationv1beta1.TokenReview
   309  	response    authenticationv1beta1.TokenReviewStatus
   310  }
   311  
   312  func (rec *recorderV1beta1Service) Review(r *authenticationv1beta1.TokenReview) {
   313  	rec.lastRequest = *r
   314  	r.Status = rec.response
   315  }
   316  
   317  func (rec *recorderV1beta1Service) HTTPStatusCode() int { return 200 }
   318  
   319  func TestV1beta1WebhookTokenAuthenticator(t *testing.T) {
   320  	serv := &recorderV1beta1Service{}
   321  
   322  	s, err := NewV1beta1TestServer(serv, serverCert, serverKey, caCert)
   323  	if err != nil {
   324  		t.Fatal(err)
   325  	}
   326  	defer s.Close()
   327  
   328  	expTypeMeta := metav1.TypeMeta{
   329  		APIVersion: "authentication.k8s.io/v1beta1",
   330  		Kind:       "TokenReview",
   331  	}
   332  
   333  	tests := []struct {
   334  		description           string
   335  		implicitAuds, reqAuds authenticator.Audiences
   336  		serverResponse        authenticationv1beta1.TokenReviewStatus
   337  		expectedAuthenticated bool
   338  		expectedUser          *user.DefaultInfo
   339  		expectedAuds          authenticator.Audiences
   340  	}{
   341  		{
   342  			description: "successful response should pass through all user info.",
   343  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   344  				Authenticated: true,
   345  				User: authenticationv1beta1.UserInfo{
   346  					Username: "somebody",
   347  				},
   348  			},
   349  			expectedAuthenticated: true,
   350  			expectedUser: &user.DefaultInfo{
   351  				Name: "somebody",
   352  			},
   353  		},
   354  		{
   355  			description: "successful response should pass through all user info.",
   356  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   357  				Authenticated: true,
   358  				User: authenticationv1beta1.UserInfo{
   359  					Username: "person@place.com",
   360  					UID:      "abcd-1234",
   361  					Groups:   []string{"stuff-dev", "main-eng"},
   362  					Extra:    map[string]authenticationv1beta1.ExtraValue{"foo": {"bar", "baz"}},
   363  				},
   364  			},
   365  			expectedAuthenticated: true,
   366  			expectedUser: &user.DefaultInfo{
   367  				Name:   "person@place.com",
   368  				UID:    "abcd-1234",
   369  				Groups: []string{"stuff-dev", "main-eng"},
   370  				Extra:  map[string][]string{"foo": {"bar", "baz"}},
   371  			},
   372  		},
   373  		{
   374  			description: "unauthenticated shouldn't even include extra provided info.",
   375  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   376  				Authenticated: false,
   377  				User: authenticationv1beta1.UserInfo{
   378  					Username: "garbage",
   379  					UID:      "abcd-1234",
   380  					Groups:   []string{"not-actually-used"},
   381  				},
   382  			},
   383  			expectedAuthenticated: false,
   384  			expectedUser:          nil,
   385  		},
   386  		{
   387  			description: "unauthenticated shouldn't even include extra provided info.",
   388  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   389  				Authenticated: false,
   390  			},
   391  			expectedAuthenticated: false,
   392  			expectedUser:          nil,
   393  		},
   394  		{
   395  			description:  "good audience",
   396  			implicitAuds: apiAuds,
   397  			reqAuds:      apiAuds,
   398  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   399  				Authenticated: true,
   400  				User: authenticationv1beta1.UserInfo{
   401  					Username: "somebody",
   402  				},
   403  			},
   404  			expectedAuthenticated: true,
   405  			expectedUser: &user.DefaultInfo{
   406  				Name: "somebody",
   407  			},
   408  			expectedAuds: apiAuds,
   409  		},
   410  		{
   411  			description:  "good audience",
   412  			implicitAuds: append(apiAuds, "other"),
   413  			reqAuds:      apiAuds,
   414  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   415  				Authenticated: true,
   416  				User: authenticationv1beta1.UserInfo{
   417  					Username: "somebody",
   418  				},
   419  			},
   420  			expectedAuthenticated: true,
   421  			expectedUser: &user.DefaultInfo{
   422  				Name: "somebody",
   423  			},
   424  			expectedAuds: apiAuds,
   425  		},
   426  		{
   427  			description:  "bad audiences",
   428  			implicitAuds: apiAuds,
   429  			reqAuds:      authenticator.Audiences{"other"},
   430  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   431  				Authenticated: false,
   432  			},
   433  			expectedAuthenticated: false,
   434  		},
   435  		{
   436  			description:  "bad audiences",
   437  			implicitAuds: apiAuds,
   438  			reqAuds:      authenticator.Audiences{"other"},
   439  			// webhook authenticator hasn't been upgraded to support audience.
   440  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   441  				Authenticated: true,
   442  				User: authenticationv1beta1.UserInfo{
   443  					Username: "somebody",
   444  				},
   445  			},
   446  			expectedAuthenticated: false,
   447  		},
   448  		{
   449  			description:  "audience aware backend",
   450  			implicitAuds: apiAuds,
   451  			reqAuds:      apiAuds,
   452  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   453  				Authenticated: true,
   454  				User: authenticationv1beta1.UserInfo{
   455  					Username: "somebody",
   456  				},
   457  				Audiences: []string(apiAuds),
   458  			},
   459  			expectedAuthenticated: true,
   460  			expectedUser: &user.DefaultInfo{
   461  				Name: "somebody",
   462  			},
   463  			expectedAuds: apiAuds,
   464  		},
   465  		{
   466  			description: "audience aware backend",
   467  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   468  				Authenticated: true,
   469  				User: authenticationv1beta1.UserInfo{
   470  					Username: "somebody",
   471  				},
   472  				Audiences: []string(apiAuds),
   473  			},
   474  			expectedAuthenticated: true,
   475  			expectedUser: &user.DefaultInfo{
   476  				Name: "somebody",
   477  			},
   478  		},
   479  		{
   480  			description:  "audience aware backend",
   481  			implicitAuds: apiAuds,
   482  			reqAuds:      apiAuds,
   483  			serverResponse: authenticationv1beta1.TokenReviewStatus{
   484  				Authenticated: true,
   485  				User: authenticationv1beta1.UserInfo{
   486  					Username: "somebody",
   487  				},
   488  				Audiences: []string{"other"},
   489  			},
   490  			expectedAuthenticated: false,
   491  		},
   492  	}
   493  	token := "my-s3cr3t-t0ken" // Fake token for testing.
   494  	for _, tt := range tests {
   495  		t.Run(tt.description, func(t *testing.T) {
   496  			wh, err := newV1beta1TokenAuthenticator(s.URL, clientCert, clientKey, caCert, 0, tt.implicitAuds)
   497  			if err != nil {
   498  				t.Fatal(err)
   499  			}
   500  
   501  			ctx := context.Background()
   502  			if tt.reqAuds != nil {
   503  				ctx = authenticator.WithAudiences(ctx, tt.reqAuds)
   504  			}
   505  
   506  			serv.response = tt.serverResponse
   507  			resp, authenticated, err := wh.AuthenticateToken(ctx, token)
   508  			if err != nil {
   509  				t.Fatalf("authentication failed: %v", err)
   510  			}
   511  			if serv.lastRequest.Spec.Token != token {
   512  				t.Errorf("Server did not see correct token. Got %q, expected %q.",
   513  					serv.lastRequest.Spec.Token, token)
   514  			}
   515  			if !reflect.DeepEqual(serv.lastRequest.TypeMeta, expTypeMeta) {
   516  				t.Errorf("Server did not see correct TypeMeta. Got %v, expected %v",
   517  					serv.lastRequest.TypeMeta, expTypeMeta)
   518  			}
   519  			if authenticated != tt.expectedAuthenticated {
   520  				t.Errorf("Plugin returned incorrect authentication response. Got %t, expected %t.",
   521  					authenticated, tt.expectedAuthenticated)
   522  			}
   523  			if resp != nil && tt.expectedUser != nil && !reflect.DeepEqual(resp.User, tt.expectedUser) {
   524  				t.Errorf("Plugin returned incorrect user. Got %#v, expected %#v",
   525  					resp.User, tt.expectedUser)
   526  			}
   527  			if resp != nil && tt.expectedAuds != nil && !reflect.DeepEqual(resp.Audiences, tt.expectedAuds) {
   528  				t.Errorf("Plugin returned incorrect audiences. Got %#v, expected %#v",
   529  					resp.Audiences, tt.expectedAuds)
   530  			}
   531  		})
   532  	}
   533  }
   534  
   535  type authenticationV1beta1UserInfo authenticationv1beta1.UserInfo
   536  
   537  func (a *authenticationV1beta1UserInfo) GetName() string     { return a.Username }
   538  func (a *authenticationV1beta1UserInfo) GetUID() string      { return a.UID }
   539  func (a *authenticationV1beta1UserInfo) GetGroups() []string { return a.Groups }
   540  
   541  func (a *authenticationV1beta1UserInfo) GetExtra() map[string][]string {
   542  	if a.Extra == nil {
   543  		return nil
   544  	}
   545  	ret := map[string][]string{}
   546  	for k, v := range a.Extra {
   547  		ret[k] = []string(v)
   548  	}
   549  
   550  	return ret
   551  }
   552  
   553  // Ensure authenticationv1beta1.UserInfo contains the fields necessary to implement the
   554  // user.Info interface.
   555  var _ user.Info = (*authenticationV1beta1UserInfo)(nil)
   556  
   557  // TestWebhookCache verifies that error responses from the server are not
   558  // cached, but successful responses are. It also ensures that the webhook
   559  // call is retried on 429 and 500+ errors
   560  func TestV1beta1WebhookCacheAndRetry(t *testing.T) {
   561  	serv := new(mockV1beta1Service)
   562  	s, err := NewV1beta1TestServer(serv, serverCert, serverKey, caCert)
   563  	if err != nil {
   564  		t.Fatal(err)
   565  	}
   566  	defer s.Close()
   567  
   568  	// Create an authenticator that caches successful responses "forever" (100 days).
   569  	wh, err := newV1beta1TokenAuthenticator(s.URL, clientCert, clientKey, caCert, 2400*time.Hour, nil)
   570  	if err != nil {
   571  		t.Fatal(err)
   572  	}
   573  
   574  	testcases := []struct {
   575  		description string
   576  
   577  		token string
   578  		allow bool
   579  		code  int
   580  
   581  		expectError bool
   582  		expectOk    bool
   583  		expectCalls int
   584  	}{
   585  		{
   586  			description: "t0k3n, 500 error, retries and fails",
   587  
   588  			token: "t0k3n",
   589  			allow: false,
   590  			code:  500,
   591  
   592  			expectError: true,
   593  			expectOk:    false,
   594  			expectCalls: 5,
   595  		},
   596  		{
   597  			description: "t0k3n, 404 error, fails (but no retry)",
   598  
   599  			token: "t0k3n",
   600  			allow: false,
   601  			code:  404,
   602  
   603  			expectError: true,
   604  			expectOk:    false,
   605  			expectCalls: 1,
   606  		},
   607  		{
   608  			description: "t0k3n, 200 response, allowed, succeeds with a single call",
   609  
   610  			token: "t0k3n",
   611  			allow: true,
   612  			code:  200,
   613  
   614  			expectError: false,
   615  			expectOk:    true,
   616  			expectCalls: 1,
   617  		},
   618  		{
   619  			description: "t0k3n, 500 response, disallowed, but never called because previous 200 response was cached",
   620  
   621  			token: "t0k3n",
   622  			allow: false,
   623  			code:  500,
   624  
   625  			expectError: false,
   626  			expectOk:    true,
   627  			expectCalls: 0,
   628  		},
   629  
   630  		{
   631  			description: "an0th3r_t0k3n, 500 response, disallowed, should be called again with retries",
   632  
   633  			token: "an0th3r_t0k3n",
   634  			allow: false,
   635  			code:  500,
   636  
   637  			expectError: true,
   638  			expectOk:    false,
   639  			expectCalls: 5,
   640  		},
   641  		{
   642  			description: "an0th3r_t0k3n, 429 response, disallowed, should be called again with retries",
   643  
   644  			token: "an0th3r_t0k3n",
   645  			allow: false,
   646  			code:  429,
   647  
   648  			expectError: true,
   649  			expectOk:    false,
   650  			expectCalls: 5,
   651  		},
   652  		{
   653  			description: "an0th3r_t0k3n, 200 response, allowed, succeeds with a single call",
   654  
   655  			token: "an0th3r_t0k3n",
   656  			allow: true,
   657  			code:  200,
   658  
   659  			expectError: false,
   660  			expectOk:    true,
   661  			expectCalls: 1,
   662  		},
   663  		{
   664  			description: "an0th3r_t0k3n, 500 response, disallowed, but never called because previous 200 response was cached",
   665  
   666  			token: "an0th3r_t0k3n",
   667  			allow: false,
   668  			code:  500,
   669  
   670  			expectError: false,
   671  			expectOk:    true,
   672  			expectCalls: 0,
   673  		},
   674  	}
   675  
   676  	for _, testcase := range testcases {
   677  		t.Run(testcase.description, func(t *testing.T) {
   678  			serv.allow = testcase.allow
   679  			serv.statusCode = testcase.code
   680  			serv.called = 0
   681  
   682  			_, ok, err := wh.AuthenticateToken(context.Background(), testcase.token)
   683  			hasError := err != nil
   684  			if hasError != testcase.expectError {
   685  				t.Errorf("Webhook returned HTTP %d, expected error=%v, but got error %v", testcase.code, testcase.expectError, err)
   686  			}
   687  			if serv.called != testcase.expectCalls {
   688  				t.Errorf("Expected %d calls, got %d", testcase.expectCalls, serv.called)
   689  			}
   690  			if ok != testcase.expectOk {
   691  				t.Errorf("Expected ok=%v, got %v", testcase.expectOk, ok)
   692  			}
   693  		})
   694  	}
   695  }