github.com/m-lab/locate@v0.17.6/handler/handler_test.go (about)

     1  // Package handler provides a client and handlers for responding to locate
     2  // requests.
     3  package handler
     4  
     5  import (
     6  	"errors"
     7  	"net/http"
     8  	"net/http/httptest"
     9  	"net/url"
    10  	"reflect"
    11  	"strings"
    12  	"testing"
    13  	"time"
    14  
    15  	"github.com/m-lab/go/rtx"
    16  	v2 "github.com/m-lab/locate/api/v2"
    17  	"github.com/m-lab/locate/clientgeo"
    18  	"github.com/m-lab/locate/heartbeat"
    19  	"github.com/m-lab/locate/heartbeat/heartbeattest"
    20  	"github.com/m-lab/locate/limits"
    21  	"github.com/m-lab/locate/proxy"
    22  	"github.com/m-lab/locate/static"
    23  	prom "github.com/prometheus/client_golang/api/prometheus/v1"
    24  	log "github.com/sirupsen/logrus"
    25  	"gopkg.in/square/go-jose.v2/jwt"
    26  )
    27  
    28  func init() {
    29  	// Disable most logs for unit tests.
    30  	log.SetLevel(log.FatalLevel)
    31  }
    32  
    33  type fakeSigner struct {
    34  	err error
    35  }
    36  
    37  func (s *fakeSigner) Sign(cl jwt.Claims) (string, error) {
    38  	if s.err != nil {
    39  		return "", s.err
    40  	}
    41  	t := strings.Join([]string{
    42  		cl.Audience[0], cl.Subject, cl.Issuer, cl.Expiry.Time().Format(time.RFC3339),
    43  	}, "--")
    44  	return t, nil
    45  }
    46  
    47  type fakeLocatorV2 struct {
    48  	heartbeat.StatusTracker
    49  	err     error
    50  	targets []v2.Target
    51  	urls    []url.URL
    52  }
    53  
    54  func (l *fakeLocatorV2) Nearest(service string, lat, lon float64, opts *heartbeat.NearestOptions) (*heartbeat.TargetInfo, error) {
    55  	if l.err != nil {
    56  		return nil, l.err
    57  	}
    58  	return &heartbeat.TargetInfo{
    59  		Targets: l.targets,
    60  		URLs:    l.urls,
    61  		Ranks:   map[string]int{},
    62  	}, nil
    63  }
    64  
    65  type fakeAppEngineLocator struct {
    66  	loc *clientgeo.Location
    67  	err error
    68  }
    69  
    70  func (l *fakeAppEngineLocator) Locate(req *http.Request) (*clientgeo.Location, error) {
    71  	return l.loc, l.err
    72  }
    73  
    74  type fakeRateLimiter struct {
    75  	status limits.LimitStatus
    76  	err    error
    77  }
    78  
    79  func (r *fakeRateLimiter) IsLimited(ip, ua string) (limits.LimitStatus, error) {
    80  	if r.err != nil {
    81  		return limits.LimitStatus{}, r.err
    82  	}
    83  	return r.status, nil
    84  }
    85  
    86  func TestClient_Nearest(t *testing.T) {
    87  	tests := []struct {
    88  		name       string
    89  		path       string
    90  		signer     Signer
    91  		locator    *fakeLocatorV2
    92  		cl         ClientLocator
    93  		project    string
    94  		latlon     string
    95  		limits     limits.Agents
    96  		ipLimiter  Limiter
    97  		header     http.Header
    98  		wantLatLon string
    99  		wantKey    string
   100  		wantStatus int
   101  	}{
   102  		{
   103  			name:   "error-unmatched-service",
   104  			path:   "no-instances-serve-this/datatype-name",
   105  			signer: &fakeSigner{},
   106  			locator: &fakeLocatorV2{
   107  				err: errors.New("No servers found for this service error"),
   108  			},
   109  			header: http.Header{
   110  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   111  			},
   112  			wantLatLon: "40.3,-70.4", // Client receives lat/lon provided by AppEngine.
   113  			wantStatus: http.StatusInternalServerError,
   114  		},
   115  		{
   116  			name: "error-nearest-failure",
   117  			path: "ndt/ndt5",
   118  			header: http.Header{
   119  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   120  			},
   121  			wantLatLon: "40.3,-70.4", // Client receives lat/lon provided by AppEngine.
   122  			locator: &fakeLocatorV2{
   123  				err: errors.New("Fake signer error"),
   124  			},
   125  			wantStatus: http.StatusInternalServerError,
   126  		},
   127  		{
   128  			name: "error-nearest-failure-no-content",
   129  			path: "ndt/ndt5",
   130  			locator: &fakeLocatorV2{
   131  				err: heartbeat.ErrNoAvailableServers,
   132  			},
   133  			wantStatus: http.StatusServiceUnavailable,
   134  		},
   135  		{
   136  			name: "error-corrupt-latlon",
   137  			path: "ndt/ndt5",
   138  			header: http.Header{
   139  				"X-AppEngine-CityLatLong": []string{"corrupt-value"},
   140  			},
   141  			wantStatus: http.StatusServiceUnavailable,
   142  		},
   143  		{
   144  			name: "error-cannot-parse-latlon",
   145  			path: "ndt/ndt5",
   146  			cl: &fakeAppEngineLocator{
   147  				loc: &clientgeo.Location{
   148  					Latitude:  "invalid-float",
   149  					Longitude: "invalid-float",
   150  				},
   151  			},
   152  			wantStatus: http.StatusInternalServerError,
   153  		},
   154  		{
   155  			name: "error-limit-request",
   156  			path: "ndt/ndt5",
   157  			limits: limits.Agents{
   158  				"foo": limits.NewCron("* * * * *", time.Minute),
   159  			},
   160  			header: http.Header{
   161  				"User-Agent": []string{"foo"},
   162  			},
   163  			wantStatus: http.StatusTooManyRequests,
   164  		},
   165  		{
   166  			name:   "success-nearest-server",
   167  			path:   "ndt/ndt5",
   168  			signer: &fakeSigner{},
   169  			locator: &fakeLocatorV2{
   170  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   171  				urls: []url.URL{
   172  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   173  					{Scheme: "wss", Host: ":3010", Path: "ndt_protocol"},
   174  				},
   175  			},
   176  			header: http.Header{
   177  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   178  			},
   179  			wantLatLon: "40.3,-70.4", // Client receives lat/lon provided by AppEngine.
   180  			wantKey:    "ws://:3001/ndt_protocol",
   181  			wantStatus: http.StatusOK,
   182  		},
   183  		{
   184  			name:   "success-nearest-server-using-region",
   185  			path:   "ndt/ndt5",
   186  			signer: &fakeSigner{},
   187  			locator: &fakeLocatorV2{
   188  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   189  				urls: []url.URL{
   190  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   191  					{Scheme: "wss", Host: ":3010", Path: "ndt_protocol"},
   192  				},
   193  			},
   194  			header: http.Header{
   195  				"X-AppEngine-Country": []string{"US"},
   196  				"X-AppEngine-Region":  []string{"ny"},
   197  			},
   198  			wantLatLon: "43.19880000,-75.3242000", // Region center.
   199  			wantKey:    "ws://:3001/ndt_protocol",
   200  			wantStatus: http.StatusOK,
   201  		},
   202  		{
   203  			name:   "success-nearest-server-using-country",
   204  			path:   "ndt/ndt5",
   205  			signer: &fakeSigner{},
   206  			locator: &fakeLocatorV2{
   207  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   208  				urls: []url.URL{
   209  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   210  					{Scheme: "wss", Host: ":3010", Path: "ndt_protocol"},
   211  				},
   212  			},
   213  			header: http.Header{
   214  				"X-AppEngine-Region":      []string{"fake-region"},
   215  				"X-AppEngine-Country":     []string{"US"},
   216  				"X-AppEngine-CityLatLong": []string{"0.000000,0.000000"},
   217  			},
   218  			wantLatLon: "37.09024,-95.712891", // Country center.
   219  			wantKey:    "ws://:3001/ndt_protocol",
   220  			wantStatus: http.StatusOK,
   221  		},
   222  		{
   223  			name:   "error-rate-limit-exceeded-ip",
   224  			path:   "ndt/ndt5",
   225  			signer: &fakeSigner{},
   226  			locator: &fakeLocatorV2{
   227  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   228  			},
   229  			header: http.Header{
   230  				"X-Forwarded-For": []string{"192.0.2.1"},
   231  				"User-Agent":      []string{"test-client"},
   232  			},
   233  			ipLimiter: &fakeRateLimiter{
   234  				status: limits.LimitStatus{
   235  					IsLimited: true,
   236  					LimitType: "ip",
   237  				},
   238  			},
   239  			wantStatus: http.StatusTooManyRequests,
   240  		},
   241  		{
   242  			name:   "error-rate-limit-exceeded-ipua",
   243  			path:   "ndt/ndt5",
   244  			signer: &fakeSigner{},
   245  			locator: &fakeLocatorV2{
   246  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   247  			},
   248  			header: http.Header{
   249  				"X-Forwarded-For": []string{"192.0.2.1"},
   250  				"User-Agent":      []string{"test-client"},
   251  			},
   252  			ipLimiter: &fakeRateLimiter{
   253  				status: limits.LimitStatus{
   254  					IsLimited: true,
   255  					LimitType: "ipua",
   256  				},
   257  			},
   258  			wantStatus: http.StatusTooManyRequests,
   259  		},
   260  		{
   261  			name:   "success-rate-limit-not-exceeded",
   262  			path:   "ndt/ndt5",
   263  			signer: &fakeSigner{},
   264  			locator: &fakeLocatorV2{
   265  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   266  				urls: []url.URL{
   267  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   268  					{Scheme: "wss", Host: ":3010", Path: "ndt_protocol"},
   269  				},
   270  			},
   271  			header: http.Header{
   272  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   273  				"X-Forwarded-For":         []string{"192.168.1.1"},
   274  				"User-Agent":              []string{"test-client"},
   275  			},
   276  			ipLimiter: &fakeRateLimiter{
   277  				status: limits.LimitStatus{
   278  					IsLimited: false,
   279  					LimitType: "",
   280  				},
   281  			},
   282  			wantLatLon: "40.3,-70.4",
   283  			wantKey:    "ws://:3001/ndt_protocol",
   284  			wantStatus: http.StatusOK,
   285  		},
   286  		{
   287  			name:   "success-rate-limiter-error",
   288  			path:   "ndt/ndt5",
   289  			signer: &fakeSigner{},
   290  			locator: &fakeLocatorV2{
   291  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   292  				urls: []url.URL{
   293  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   294  					{Scheme: "wss", Host: ":3010", Path: "/ndt_protocol"},
   295  				},
   296  			},
   297  			header: http.Header{
   298  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   299  				"X-Forwarded-For":         []string{"192.168.1.1"},
   300  				"User-Agent":              []string{"test-client"},
   301  			},
   302  			ipLimiter: &fakeRateLimiter{
   303  				err: errors.New("redis error"),
   304  			},
   305  			wantLatLon: "40.3,-70.4",
   306  			wantKey:    "ws://:3001/ndt_protocol",
   307  			wantStatus: http.StatusOK, // Should fail open
   308  		},
   309  		{
   310  			name:   "success-missing-forwarded-for",
   311  			path:   "ndt/ndt5",
   312  			signer: &fakeSigner{},
   313  			locator: &fakeLocatorV2{
   314  				targets: []v2.Target{{Machine: "mlab1-lga0t.measurement-lab.org"}},
   315  				urls: []url.URL{
   316  					{Scheme: "ws", Host: ":3001", Path: "/ndt_protocol"},
   317  					{Scheme: "wss", Host: ":3010", Path: "/ndt_protocol"},
   318  				},
   319  			},
   320  			header: http.Header{
   321  				"X-AppEngine-CityLatLong": []string{"40.3,-70.4"},
   322  				// No X-Forwarded-For
   323  				"User-Agent": []string{"test-client"},
   324  			},
   325  			ipLimiter: &fakeRateLimiter{
   326  				status: limits.LimitStatus{
   327  					IsLimited: false,
   328  					LimitType: "",
   329  				},
   330  			},
   331  			wantLatLon: "40.3,-70.4",
   332  			wantKey:    "ws://:3001/ndt_protocol",
   333  			wantStatus: http.StatusOK,
   334  		},
   335  	}
   336  	for _, tt := range tests {
   337  		t.Run(tt.name, func(t *testing.T) {
   338  			if tt.cl == nil {
   339  				tt.cl = clientgeo.NewAppEngineLocator()
   340  			}
   341  			c := NewClient(tt.project, tt.signer, tt.locator, tt.cl, prom.NewAPI(nil), tt.limits, tt.ipLimiter, nil)
   342  
   343  			mux := http.NewServeMux()
   344  			mux.HandleFunc("/v2/nearest/", c.Nearest)
   345  			srv := httptest.NewServer(mux)
   346  			defer srv.Close()
   347  
   348  			req, err := http.NewRequest(http.MethodGet, srv.URL+"/v2/nearest/"+tt.path+"?client_name=foo", nil)
   349  			rtx.Must(err, "Failed to create request")
   350  			req.Header = tt.header
   351  
   352  			result := &v2.NearestResult{}
   353  			resp, err := proxy.UnmarshalResponse(req, result)
   354  			if err != nil {
   355  				t.Fatalf("Failed to get response from: %s %s", srv.URL, tt.path)
   356  			}
   357  			if resp.Header.Get("Access-Control-Allow-Origin") != "*" {
   358  				t.Errorf("Nearest() wrong Access-Control-Allow-Origin header; got %s, want '*'",
   359  					resp.Header.Get("Access-Control-Allow-Origin"))
   360  			}
   361  			if resp.Header.Get("Content-Type") != "application/json" {
   362  				t.Errorf("Nearest() wrong Content-Type header; got %s, want 'application/json'",
   363  					resp.Header.Get("Content-Type"))
   364  			}
   365  			if resp.Header.Get("X-Locate-ClientLatLon") != tt.wantLatLon {
   366  				t.Errorf("Nearest() wrong X-Locate-ClientLatLon header; got %s, want '%s'",
   367  					resp.Header.Get("X-Locate-ClientLatLon"), tt.wantLatLon)
   368  			}
   369  			if result.Error != nil && result.Error.Status != tt.wantStatus {
   370  				t.Errorf("Nearest() wrong status; got %d, want %d", result.Error.Status, tt.wantStatus)
   371  			}
   372  			if result.Error != nil {
   373  				return
   374  			}
   375  			if result.Results == nil && tt.wantStatus == http.StatusOK {
   376  				t.Errorf("Nearest() wrong status; got %d, want %d", result.Error.Status, tt.wantStatus)
   377  			}
   378  			if len(tt.locator.targets) != len(result.Results) {
   379  				t.Errorf("Nearest() wrong result count; got %d, want %d",
   380  					len(result.Results), len(tt.locator.targets))
   381  			}
   382  			if len(result.Results[0].URLs) != len(static.Configs[tt.path]) {
   383  				t.Errorf("Nearest() result wrong URL count; got %d, want %d",
   384  					len(result.Results[0].URLs), len(static.Configs[tt.path]))
   385  			}
   386  			if _, ok := result.Results[0].URLs[tt.wantKey]; !ok {
   387  				t.Errorf("Nearest() result missing URLs key; want %q", tt.wantKey)
   388  			}
   389  		})
   390  	}
   391  }
   392  
   393  func TestNewClientDirect(t *testing.T) {
   394  	t.Run("success", func(t *testing.T) {
   395  		c := NewClientDirect("fake-project", nil, nil, nil, nil)
   396  		if c == nil {
   397  			t.Error("got nil client!")
   398  		}
   399  	})
   400  }
   401  
   402  func TestClient_Ready(t *testing.T) {
   403  	tests := []struct {
   404  		name       string
   405  		fakeErr    error
   406  		wantStatus int
   407  	}{
   408  		{
   409  			name:       "success",
   410  			wantStatus: http.StatusOK,
   411  		},
   412  		{
   413  			name:       "error-not-ready",
   414  			fakeErr:    errors.New("fake error"),
   415  			wantStatus: http.StatusInternalServerError,
   416  		},
   417  	}
   418  	for _, tt := range tests {
   419  		t.Run(tt.name, func(t *testing.T) {
   420  			c := NewClient("foo", &fakeSigner{}, &fakeLocatorV2{StatusTracker: &heartbeattest.FakeStatusTracker{Err: tt.fakeErr}}, nil, nil, nil, nil, nil)
   421  
   422  			mux := http.NewServeMux()
   423  			mux.HandleFunc("/ready/", c.Ready)
   424  			mux.HandleFunc("/live/", c.Live)
   425  			srv := httptest.NewServer(mux)
   426  			defer srv.Close()
   427  
   428  			req, err := http.NewRequest(http.MethodGet, srv.URL+"/ready", nil)
   429  			rtx.Must(err, "Failed to create request")
   430  			resp, err := http.DefaultClient.Do(req)
   431  			rtx.Must(err, "failed to issue request")
   432  			if resp.StatusCode != tt.wantStatus {
   433  				t.Errorf("Ready() wrong status; got %d; want %d", resp.StatusCode, tt.wantStatus)
   434  			}
   435  			defer resp.Body.Close()
   436  
   437  			req, err = http.NewRequest(http.MethodGet, srv.URL+"/live", nil)
   438  			rtx.Must(err, "Failed to create request")
   439  			resp, err = http.DefaultClient.Do(req)
   440  			rtx.Must(err, "failed to issue request")
   441  			if resp.StatusCode != http.StatusOK {
   442  				t.Errorf("Live() wrong status; got %d; want %d", resp.StatusCode, http.StatusOK)
   443  			}
   444  			defer resp.Body.Close()
   445  		})
   446  	}
   447  }
   448  func TestClient_Registrations(t *testing.T) {
   449  	tests := []struct {
   450  		name       string
   451  		instances  map[string]v2.HeartbeatMessage
   452  		fakeErr    error
   453  		wantStatus int
   454  	}{
   455  		{
   456  			name: "success-status-200",
   457  			instances: map[string]v2.HeartbeatMessage{
   458  				"ndt-mlab1-abc0t.mlab-sandbox.measurement-lab.org": {},
   459  			},
   460  			wantStatus: http.StatusOK,
   461  		},
   462  		{
   463  			name: "error-status-500",
   464  			instances: map[string]v2.HeartbeatMessage{
   465  				"invalid-hostname.xyz": {},
   466  			},
   467  			fakeErr:    errors.New("fake error"),
   468  			wantStatus: http.StatusInternalServerError,
   469  		},
   470  	}
   471  	for _, tt := range tests {
   472  		fakeStatusTracker := &heartbeattest.FakeStatusTracker{
   473  			Err:           tt.fakeErr,
   474  			FakeInstances: tt.instances,
   475  		}
   476  
   477  		t.Run(tt.name, func(t *testing.T) {
   478  			c := NewClient("foo", &fakeSigner{}, &fakeLocatorV2{StatusTracker: fakeStatusTracker}, nil, nil, nil, nil, nil)
   479  
   480  			mux := http.NewServeMux()
   481  			mux.HandleFunc("/v2/siteinfo/registrations/", c.Registrations)
   482  			srv := httptest.NewServer(mux)
   483  			defer srv.Close()
   484  
   485  			req, err := http.NewRequest(http.MethodGet, srv.URL+"/v2/siteinfo/registrations?org=mlab", nil)
   486  			rtx.Must(err, "failed to create request")
   487  			resp, err := http.DefaultClient.Do(req)
   488  			rtx.Must(err, "failed to issue request")
   489  			if resp.StatusCode != tt.wantStatus {
   490  				t.Errorf("Registrations() wrong status; got %d; want %d", resp.StatusCode, tt.wantStatus)
   491  			}
   492  		})
   493  	}
   494  }
   495  
   496  func TestExtraParams(t *testing.T) {
   497  	tests := []struct {
   498  		name                 string
   499  		hostname             string
   500  		index                int
   501  		p                    paramOpts
   502  		client               *Client
   503  		earlyExitProbability float64
   504  		want                 url.Values
   505  	}{
   506  		{
   507  			name:     "all-params",
   508  			hostname: "host",
   509  			index:    0,
   510  			p: paramOpts{
   511  				raw:       map[string][]string{"client_name": {"client"}},
   512  				version:   "v2",
   513  				ranks:     map[string]int{"host": 0},
   514  				svcParams: map[string]float64{},
   515  			},
   516  			client: &Client{},
   517  			want: url.Values{
   518  				"client_name":    []string{"client"},
   519  				"locate_version": []string{"v2"},
   520  				"metro_rank":     []string{"0"},
   521  				"index":          []string{"0"},
   522  			},
   523  		},
   524  		{
   525  			name:     "early-exit-client-match",
   526  			hostname: "host",
   527  			index:    0,
   528  			p: paramOpts{
   529  				raw:       map[string][]string{"client_name": {"foo"}},
   530  				version:   "v2",
   531  				ranks:     map[string]int{"host": 0},
   532  				svcParams: map[string]float64{},
   533  			},
   534  			client: &Client{
   535  				earlyExitClients: map[string]bool{"foo": true},
   536  			},
   537  			want: url.Values{
   538  				"client_name":    []string{"foo"},
   539  				"locate_version": []string{"v2"},
   540  				"metro_rank":     []string{"0"},
   541  				"index":          []string{"0"},
   542  				"early_exit":     []string{"250"},
   543  			},
   544  		},
   545  		{
   546  			name:     "early-exit-client-no-match",
   547  			hostname: "host",
   548  			index:    0,
   549  			p: paramOpts{
   550  				raw:       map[string][]string{"client_name": {"bar"}},
   551  				version:   "v2",
   552  				ranks:     map[string]int{"host": 0},
   553  				svcParams: map[string]float64{},
   554  			},
   555  			client: &Client{
   556  				earlyExitClients: map[string]bool{"foo": true},
   557  			},
   558  			want: url.Values{
   559  				"client_name":    []string{"bar"},
   560  				"locate_version": []string{"v2"},
   561  				"metro_rank":     []string{"0"},
   562  				"index":          []string{"0"},
   563  			},
   564  		},
   565  		{
   566  			name:     "no-client",
   567  			hostname: "host",
   568  			index:    0,
   569  			p: paramOpts{
   570  				version:   "v2",
   571  				ranks:     map[string]int{"host": 0},
   572  				svcParams: map[string]float64{},
   573  			},
   574  			want: url.Values{
   575  				"locate_version": []string{"v2"},
   576  				"metro_rank":     []string{"0"},
   577  				"index":          []string{"0"},
   578  			},
   579  		},
   580  		{
   581  			name:     "unmatched-host",
   582  			hostname: "host",
   583  			index:    0,
   584  			p: paramOpts{
   585  				version:   "v2",
   586  				ranks:     map[string]int{"different-host": 0},
   587  				svcParams: map[string]float64{},
   588  			},
   589  			want: url.Values{
   590  				"locate_version": []string{"v2"},
   591  				"index":          []string{"0"},
   592  			},
   593  		},
   594  		{
   595  			name:  "early-exit-true",
   596  			index: 0,
   597  			p: paramOpts{
   598  				raw:     map[string][]string{static.EarlyExitParameter: {"250"}},
   599  				version: "v2",
   600  				svcParams: map[string]float64{
   601  					static.EarlyExitParameter: 1,
   602  				},
   603  			},
   604  			earlyExitProbability: 1,
   605  			want: url.Values{
   606  				static.EarlyExitParameter: []string{"250"},
   607  				"locate_version":          []string{"v2"},
   608  				"index":                   []string{"0"},
   609  			},
   610  		},
   611  		{
   612  			name:  "early-exit-false",
   613  			index: 0,
   614  			p: paramOpts{
   615  				raw:       map[string][]string{static.EarlyExitParameter: {"250"}},
   616  				version:   "v2",
   617  				svcParams: map[string]float64{static.EarlyExitParameter: 0},
   618  			},
   619  			earlyExitProbability: 0,
   620  			want: url.Values{
   621  				"locate_version": []string{"v2"},
   622  				"index":          []string{"0"},
   623  			},
   624  		},
   625  		{
   626  			name:  "max-cwnd-gain-and-early-exit-true",
   627  			index: 0,
   628  			p: paramOpts{
   629  				raw: map[string][]string{
   630  					static.EarlyExitParameter:   {"250"},
   631  					static.MaxCwndGainParameter: {"512"},
   632  				},
   633  				version: "v2",
   634  				svcParams: map[string]float64{
   635  					static.EarlyExitParameter:   1,
   636  					static.MaxCwndGainParameter: 1,
   637  				},
   638  			},
   639  			earlyExitProbability: 1,
   640  			want: url.Values{
   641  				static.EarlyExitParameter:   []string{"250"},
   642  				static.MaxCwndGainParameter: []string{"512"},
   643  				"locate_version":            []string{"v2"},
   644  				"index":                     []string{"0"},
   645  			},
   646  		},
   647  		{
   648  			name:  "max-cwnd-gain-and-max-elapsed-time-true",
   649  			index: 0,
   650  			p: paramOpts{
   651  				raw: map[string][]string{
   652  					static.MaxCwndGainParameter:    {"512"},
   653  					static.MaxElapsedTimeParameter: {"5"},
   654  				},
   655  				version: "v2",
   656  				svcParams: map[string]float64{
   657  					static.MaxElapsedTimeParameter: 1,
   658  					static.MaxCwndGainParameter:    1,
   659  				},
   660  			},
   661  			earlyExitProbability: 1,
   662  			want: url.Values{
   663  				static.MaxCwndGainParameter:    []string{"512"},
   664  				static.MaxElapsedTimeParameter: []string{"5"},
   665  				"locate_version":               []string{"v2"},
   666  				"index":                        []string{"0"},
   667  			},
   668  		},
   669  	}
   670  	for _, tt := range tests {
   671  		t.Run(tt.name, func(t *testing.T) {
   672  			got := tt.client.extraParams(tt.hostname, tt.index, tt.p)
   673  			if !reflect.DeepEqual(got, tt.want) {
   674  				t.Errorf("extraParams() = %v, want %v", got, tt.want)
   675  			}
   676  		})
   677  	}
   678  }
   679  
   680  func TestClient_limitRequest(t *testing.T) {
   681  	tests := []struct {
   682  		name   string
   683  		limits limits.Agents
   684  		t      time.Time
   685  		req    *http.Request
   686  		want   bool
   687  	}{
   688  		{
   689  			name:   "allowed-user-agent-allowed-time",
   690  			limits: limits.Agents{},
   691  			t:      time.Now().UTC(),
   692  			req: &http.Request{
   693  				Header: http.Header{
   694  					"User-Agent": []string{"foo"},
   695  				},
   696  			},
   697  			want: false,
   698  		},
   699  		{
   700  			name: "allowed-user-agent-limited-time",
   701  			limits: limits.Agents{
   702  				"foo": limits.NewCron("* * * * *", time.Minute), // Every minute of every hour.
   703  			},
   704  			t: time.Now().UTC(),
   705  			req: &http.Request{
   706  				Header: http.Header{
   707  					"User-Agent": []string{"bar"},
   708  				},
   709  			},
   710  			want: false,
   711  		},
   712  		{
   713  			name: "limited-user-agent-allowed-time",
   714  			limits: limits.Agents{
   715  				"foo": limits.NewCron("*/30 * * * *", time.Minute), // Every 30th minute.
   716  			},
   717  			t: time.Date(2023, time.November, 16, 19, 29, 0, 0, time.UTC), // Request at minute 29.
   718  			req: &http.Request{
   719  				Header: http.Header{
   720  					"User-Agent": []string{"foo"},
   721  				},
   722  			},
   723  			want: false,
   724  		},
   725  		{
   726  			name: "limited-user-agent-limited-time",
   727  			limits: limits.Agents{
   728  				"foo": limits.NewCron("*/30 * * * *", time.Minute), // Every 30th minute.
   729  			},
   730  			t: time.Date(2023, time.November, 16, 19, 30, 0, 0, time.UTC), // Request at minute 30.
   731  			req: &http.Request{
   732  				Header: http.Header{
   733  					"User-Agent": []string{"foo"},
   734  				},
   735  			},
   736  			want: true,
   737  		},
   738  	}
   739  	for _, tt := range tests {
   740  		t.Run(tt.name, func(t *testing.T) {
   741  			c := &Client{
   742  				agentLimits: tt.limits,
   743  			}
   744  			if got := c.limitRequest(tt.t, tt.req); got != tt.want {
   745  				t.Errorf("Client.limitRequest() = %v, want %v", got, tt.want)
   746  			}
   747  		})
   748  	}
   749  }