github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/k6runner/http_test.go (about)

     1  package k6runner
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"os"
    13  	"strings"
    14  	"sync/atomic"
    15  	"testing"
    16  	"time"
    17  
    18  	"github.com/go-logfmt/logfmt"
    19  	"github.com/grafana/synthetic-monitoring-agent/internal/testhelper"
    20  	"github.com/prometheus/client_golang/prometheus"
    21  	"github.com/prometheus/common/expfmt"
    22  	"github.com/rs/zerolog"
    23  	"github.com/stretchr/testify/require"
    24  )
    25  
    26  func TestHttpRunnerRun(t *testing.T) {
    27  	t.Parallel()
    28  
    29  	script := Script{
    30  		Script: testhelper.MustReadFile(t, "testdata/test.js"),
    31  		Settings: Settings{
    32  			Timeout: 1000,
    33  		},
    34  		K6ChannelManifest: "test",
    35  	}
    36  
    37  	mux := http.NewServeMux()
    38  	mux.HandleFunc("/run", func(w http.ResponseWriter, r *http.Request) {
    39  		require.Equal(t, http.MethodPost, r.Method)
    40  		require.Equal(t, "application/json", r.Header.Get("Content-Type"))
    41  
    42  		var req struct {
    43  			Script            []byte    `json:"script"`
    44  			Settings          Settings  `json:"settings"`
    45  			NotAfter          time.Time `json:"notAfter"`
    46  			K6ChannelManifest string    `json:"k6ChannelManifest"`
    47  			ExecutionID       string    `json:"executionId"`
    48  		}
    49  
    50  		err := json.NewDecoder(r.Body).Decode(&req)
    51  		if err != nil {
    52  			t.Logf("decoding body: %v", err)
    53  			t.Fail()
    54  			w.WriteHeader(400) // Use 400 as the client won't retry this failure.
    55  
    56  			return
    57  		}
    58  
    59  		if time.Since(req.NotAfter) > time.Hour || time.Until(req.NotAfter) > time.Hour {
    60  			t.Log("unexpected value for NotAfter too far from the present")
    61  			t.Fail()
    62  			w.WriteHeader(400) // Use 400 as the client won't retry this failure.
    63  
    64  			return
    65  		}
    66  
    67  		if req.K6ChannelManifest == "" {
    68  			t.Log("Unexpected empty channel manifest")
    69  			t.Fail()
    70  			w.WriteHeader(400) // Use 400 as the client won't retry this failure.
    71  
    72  			return
    73  		}
    74  
    75  		if req.ExecutionID != "test-execution-id" {
    76  			t.Logf("unexpected executionID: got %q, want %q", req.ExecutionID, "test-execution-id")
    77  			t.Fail()
    78  			w.WriteHeader(400)
    79  
    80  			return
    81  		}
    82  
    83  		w.Header().Set("Content-Type", "application/json")
    84  		w.WriteHeader(http.StatusOK)
    85  
    86  		rr := &RunResponse{
    87  			Metrics: testhelper.MustReadFile(t, "testdata/test.out"),
    88  			Logs:    testhelper.MustReadFile(t, "testdata/test.log"),
    89  		}
    90  		_ = json.NewEncoder(w).Encode(rr)
    91  	})
    92  	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    93  		w.WriteHeader(http.StatusBadRequest)
    94  		_, _ = w.Write([]byte(`{"msg": "bad request"}`))
    95  	})
    96  
    97  	srv := httptest.NewServer(mux)
    98  	t.Cleanup(srv.Close)
    99  
   100  	runner, err := New(RunnerOpts{Uri: srv.URL + "/run"})
   101  	require.NoError(t, err)
   102  	require.IsType(t, &HttpRunner{}, runner)
   103  
   104  	ctx := context.Background()
   105  	ctx, cancel := testhelper.Context(ctx, t)
   106  	t.Cleanup(cancel)
   107  
   108  	_, err = runner.Run(ctx, script, SecretStore{}, "test-execution-id")
   109  	require.NoError(t, err)
   110  }
   111  
   112  func TestHttpRunnerRunError(t *testing.T) {
   113  	t.Parallel()
   114  
   115  	script := Script{
   116  		Script: testhelper.MustReadFile(t, "testdata/test.js"),
   117  		Settings: Settings{
   118  			Timeout: 1000,
   119  		},
   120  	}
   121  
   122  	mux := http.NewServeMux()
   123  	mux.HandleFunc("/run", func(w http.ResponseWriter, r *http.Request) {
   124  		w.Header().Set("Content-Type", "application/json; charset=utf-8")
   125  		w.WriteHeader(http.StatusBadRequest)
   126  		resp := requestError{
   127  			Err:     http.StatusText(http.StatusBadRequest),
   128  			Message: "test error",
   129  		}
   130  		_ = json.NewEncoder(w).Encode(resp)
   131  	})
   132  	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
   133  		t.Log("http runner called the wrong endpoint")
   134  		t.Fail()
   135  
   136  		w.WriteHeader(http.StatusBadRequest)
   137  		resp := requestError{
   138  			Err:     http.StatusText(http.StatusBadRequest),
   139  			Message: "unexpected request to " + r.URL.Path,
   140  		}
   141  		_ = json.NewEncoder(w).Encode(resp)
   142  	})
   143  
   144  	srv := httptest.NewServer(mux)
   145  	t.Cleanup(srv.Close)
   146  
   147  	runner, err := New(RunnerOpts{Uri: srv.URL + "/run"})
   148  	require.NoError(t, err)
   149  	require.IsType(t, &HttpRunner{}, runner)
   150  
   151  	// HTTPRunner will retry until the context deadline is met, so we set a short one.
   152  	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   153  	t.Cleanup(cancel)
   154  
   155  	ctx, cancel = testhelper.Context(ctx, t)
   156  	t.Cleanup(cancel)
   157  
   158  	_, err = runner.Run(ctx, script, SecretStore{}, "test-execution-id")
   159  	require.ErrorIs(t, err, ErrUnexpectedStatus)
   160  }
   161  
   162  // TestScriptHTTPRun tests that Script reports what it should depending on the status code and responses of the HTTP
   163  // runner.
   164  func TestScriptHTTPRun(t *testing.T) {
   165  	t.Parallel()
   166  
   167  	var (
   168  		testMetrics = testhelper.MustReadFile(t, "testdata/test.out")
   169  		// testLogs is a file containing a bunch of log lines. All lines but one have level=debug, which are discarded
   170  		// by the loki submitter implementation.
   171  		testLogs = testhelper.MustReadFile(t, "testdata/test.log")
   172  		// nonkdebugLogLine is the only line on testLogs that does not have level=debug, therefore the only one that is
   173  		// actually submitted. We use it in the test table below as a sentinel to assert whether logs have been
   174  		// submitted or not.
   175  		nonDebugLogLine = `time="2023-06-01T13:40:26-06:00" level="test" msg="Non-debug message, for testing!"` + "\n"
   176  	)
   177  
   178  	for _, tc := range []struct {
   179  		name          string
   180  		response      *RunResponse
   181  		delay         time.Duration
   182  		statusCode    int
   183  		expectSuccess bool
   184  		expectError   error
   185  		expectErrorAs any // To accommodate return of unnamed errors. If set, expectError is ignored.
   186  		expectLogs    string
   187  	}{
   188  		{
   189  			name: "all good",
   190  			response: &RunResponse{
   191  				Metrics: testMetrics,
   192  				Logs:    testLogs,
   193  			},
   194  			statusCode:    http.StatusOK,
   195  			expectSuccess: true,
   196  			expectError:   nil,
   197  			expectLogs:    nonDebugLogLine,
   198  		},
   199  		{
   200  			// HTTP runner should report failure and an error when the upstream status is not recognized.
   201  			name:          "unexpected status",
   202  			response:      &RunResponse{},
   203  			statusCode:    999,
   204  			expectSuccess: false,
   205  			expectError:   ErrUnexpectedStatus,
   206  		},
   207  		{
   208  			// HTTP runner should report failure and an error when the error is unknown.
   209  			name: "non-user error",
   210  			response: &RunResponse{
   211  				Metrics:   testMetrics,
   212  				Logs:      testLogs,
   213  				Error:     "something went wrong",
   214  				ErrorCode: "something-wrong",
   215  			},
   216  			statusCode:    http.StatusOK,
   217  			expectSuccess: false,
   218  			expectError:   ErrFromRunner,
   219  			expectLogs: nonDebugLogLine + fmt.Sprintf(
   220  				"level=\"error\" msg=\"script did not execute successfully\" error=%q errorCode=%q\n",
   221  				"something went wrong",
   222  				"something-wrong",
   223  			),
   224  		},
   225  		{
   226  			// HTTP runner should report failure but no error when the error is unknown.
   227  			name: "user error",
   228  			response: &RunResponse{
   229  				Metrics:   testMetrics,
   230  				Logs:      testLogs,
   231  				Error:     "syntax error somewhere or something",
   232  				ErrorCode: "aborted",
   233  			},
   234  			statusCode:    http.StatusOK,
   235  			expectSuccess: false,
   236  			expectError:   nil,
   237  			expectLogs: nonDebugLogLine + fmt.Sprintf(
   238  				"level=\"error\" msg=\"script did not execute successfully\" error=%q errorCode=%q\n",
   239  				"syntax error somewhere or something",
   240  				"aborted",
   241  			),
   242  		},
   243  		{
   244  			name: "borked logs are sent best-effort",
   245  			response: &RunResponse{
   246  				Metrics:   testMetrics,
   247  				Logs:      []byte(`level=error foo="b` + "\n"),
   248  				Error:     "we killed k6",
   249  				ErrorCode: "aborted",
   250  			},
   251  			statusCode:    http.StatusUnprocessableEntity,
   252  			expectSuccess: false,
   253  			expectErrorAs: &logfmt.SyntaxError{},
   254  			expectLogs: `level="error"` + "\n" + fmt.Sprintf(
   255  				"level=\"error\" msg=\"script did not execute successfully\" error=%q errorCode=%q\n",
   256  				"we killed k6",
   257  				"aborted",
   258  			),
   259  		},
   260  		{
   261  			name: "logs are sent on borked metrics",
   262  			response: &RunResponse{
   263  				Metrics:   []byte("probe_succ{"),
   264  				Logs:      testLogs,
   265  				Error:     "we killed k6",
   266  				ErrorCode: "aborted",
   267  			},
   268  			statusCode:    http.StatusUnprocessableEntity,
   269  			expectSuccess: false,
   270  			expectErrorAs: expfmt.ParseError{},
   271  			expectLogs: nonDebugLogLine + fmt.Sprintf(
   272  				"level=\"error\" msg=\"script did not execute successfully\" error=%q errorCode=%q\n",
   273  				"we killed k6",
   274  				"aborted",
   275  			),
   276  		},
   277  		{
   278  			name: "inconsistent runner response A",
   279  			response: &RunResponse{
   280  				Metrics:   testMetrics,
   281  				Logs:      testLogs,
   282  				Error:     "set",
   283  				ErrorCode: "",
   284  			},
   285  			statusCode:    http.StatusInternalServerError,
   286  			expectSuccess: false,
   287  			expectError:   ErrBuggyRunner,
   288  			expectLogs:    "",
   289  		},
   290  		{
   291  			name: "inconsistent runner response B",
   292  			response: &RunResponse{
   293  				Metrics:   testMetrics,
   294  				Logs:      testLogs,
   295  				Error:     "",
   296  				ErrorCode: "set",
   297  			},
   298  			statusCode:    http.StatusInternalServerError,
   299  			expectSuccess: false,
   300  			expectError:   ErrBuggyRunner,
   301  			expectLogs:    "",
   302  		},
   303  		{
   304  			name: "request timeout",
   305  			response: &RunResponse{
   306  				Metrics: testMetrics,
   307  				Logs:    testLogs,
   308  			},
   309  			delay:         3 * time.Second, // Beyond timeout + graceTime.
   310  			statusCode:    http.StatusInternalServerError,
   311  			expectSuccess: false,
   312  			expectError:   context.DeadlineExceeded,
   313  		},
   314  	} {
   315  		t.Run(tc.name, func(t *testing.T) {
   316  			t.Parallel()
   317  
   318  			mux := http.NewServeMux()
   319  			mux.HandleFunc("/run", func(w http.ResponseWriter, r *http.Request) {
   320  				time.Sleep(tc.delay)
   321  				w.Header().Set("Content-Type", "application/json")
   322  				w.WriteHeader(tc.statusCode)
   323  				_ = json.NewEncoder(w).Encode(tc.response)
   324  			})
   325  			srv := httptest.NewServer(mux)
   326  			t.Cleanup(srv.Close)
   327  
   328  			runner := HttpRunner{url: srv.URL, graceTime: time.Second, backoff: time.Second, metrics: NewHTTPMetrics(prometheus.NewRegistry())}
   329  			script, err := NewProcessor(Script{
   330  				Script: []byte("tee-hee"),
   331  				Settings: Settings{
   332  					Timeout: 1000,
   333  				},
   334  			}, runner)
   335  			require.NoError(t, err)
   336  
   337  			baseCtx, baseCancel := context.WithTimeout(context.Background(), 4*time.Second)
   338  			t.Cleanup(baseCancel)
   339  			ctx, cancel := testhelper.Context(baseCtx, t)
   340  			t.Cleanup(cancel)
   341  
   342  			var (
   343  				registry = prometheus.NewRegistry()
   344  				logBuf   = bytes.Buffer{}
   345  				logger   = recordingLogger{buf: &logBuf}
   346  				zlogger  = testhelper.Logger(t)
   347  			)
   348  
   349  			success, _, err := script.Run(ctx, registry, logger, zlogger, SecretStore{}, "test-execution-id")
   350  			require.Equal(t, tc.expectSuccess, success)
   351  			require.Equal(t, tc.expectLogs, logger.buf.String())
   352  
   353  			if tc.expectErrorAs == nil {
   354  				require.ErrorIs(t, err, tc.expectError)
   355  			} else {
   356  				require.ErrorAs(t, err, &tc.expectErrorAs)
   357  			}
   358  		})
   359  	}
   360  }
   361  
   362  func TestHTTPProcessorRetries(t *testing.T) {
   363  	t.Parallel()
   364  
   365  	t.Run("status codes", func(t *testing.T) {
   366  		t.Parallel()
   367  
   368  		for _, tc := range []struct {
   369  			name           string
   370  			handler        http.Handler
   371  			scriptTimeout  time.Duration
   372  			graceTime      time.Duration
   373  			globalTimeout  time.Duration
   374  			expectRequests int64
   375  			expectError    error
   376  		}{
   377  			{
   378  				name:          "no retries needed",
   379  				handler:       emptyJSON(http.StatusOK),
   380  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   381  				expectRequests: 1,
   382  				expectError:    nil,
   383  			},
   384  			{
   385  				name:          "does not retry 400",
   386  				handler:       emptyJSON(http.StatusBadRequest),
   387  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   388  				expectRequests: 1,
   389  				expectError:    ErrUnexpectedStatus,
   390  			},
   391  			{
   392  				name:          "does not retry 422",
   393  				handler:       emptyJSON(http.StatusUnprocessableEntity),
   394  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   395  				expectRequests: 1,
   396  				expectError:    nil,
   397  			},
   398  			{
   399  				name:          "does not retry 428",
   400  				handler:       emptyJSON(http.StatusRequestTimeout),
   401  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   402  				expectRequests: 1,
   403  				expectError:    nil,
   404  			},
   405  			{
   406  				name:          "does not retry 500",
   407  				handler:       emptyJSON(http.StatusInternalServerError),
   408  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   409  				expectRequests: 1,
   410  				expectError:    nil,
   411  			},
   412  			{
   413  				name:          "retries 503",
   414  				handler:       afterAttempts(emptyJSON(http.StatusServiceUnavailable), 1, emptyJSON(http.StatusOK)),
   415  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   416  				expectRequests: 2,
   417  				expectError:    nil,
   418  			},
   419  			{
   420  				name:          "retries 504",
   421  				handler:       afterAttempts(emptyJSON(http.StatusGatewayTimeout), 1, emptyJSON(http.StatusOK)),
   422  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   423  				expectRequests: 2,
   424  				expectError:    nil,
   425  			},
   426  			{
   427  				name:          "retries more than once",
   428  				handler:       afterAttempts(emptyJSON(http.StatusGatewayTimeout), 2, emptyJSON(http.StatusOK)),
   429  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   430  				expectRequests: 3,
   431  				expectError:    nil,
   432  			},
   433  			{
   434  				name:          "gives up eventually",
   435  				handler:       emptyJSON(http.StatusServiceUnavailable),
   436  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   437  				// Context is forced to timeout after 5 seconds. This means 3 requests, with delays of 0, [1-2), [2-3), as
   438  				// the fourth request would need to wait [3-4) seconds after [3-5) have passed, thus guaranteed to
   439  				// go beyond the 5s deadline.
   440  				expectRequests: 3,
   441  				expectError:    context.DeadlineExceeded,
   442  			},
   443  			{
   444  				name:          "gives up eventually when server hangs",
   445  				handler:       delay(10*time.Second, emptyJSON(http.StatusServiceUnavailable)),
   446  				scriptTimeout: time.Second, graceTime: time.Second, globalTimeout: 5 * time.Second,
   447  				// Requests have 2s timeout and 0s backoff after that (backoff includes timeout in
   448  				// this implementation), so that means requests are made at the 0s, 2s and 4s marks. A fourth request
   449  				// would happen beyond the 5s timeline.
   450  				expectRequests: 3,
   451  				expectError:    context.DeadlineExceeded,
   452  			},
   453  		} {
   454  			t.Run(tc.name, func(t *testing.T) {
   455  				t.Parallel()
   456  
   457  				// Use atomic as we write to this in a handler and read from in on the test.
   458  				// go test -race trips if we use a regular int here.
   459  				var requests atomic.Int64
   460  
   461  				mux := http.NewServeMux()
   462  				mux.HandleFunc("/run", func(w http.ResponseWriter, r *http.Request) {
   463  					requests.Add(1)
   464  					tc.handler.ServeHTTP(w, r)
   465  				})
   466  				srv := httptest.NewServer(mux)
   467  				t.Cleanup(srv.Close)
   468  
   469  				runner := HttpRunner{url: srv.URL, graceTime: tc.graceTime, backoff: time.Second, metrics: NewHTTPMetrics(prometheus.NewRegistry())}
   470  				processor, err := NewProcessor(Script{Script: nil, Settings: Settings{tc.scriptTimeout.Milliseconds()}}, runner)
   471  				require.NoError(t, err)
   472  
   473  				baseCtx, baseCancel := context.WithTimeout(context.Background(), tc.globalTimeout)
   474  				t.Cleanup(baseCancel)
   475  				ctx, cancel := testhelper.Context(baseCtx, t)
   476  				t.Cleanup(cancel)
   477  
   478  				var (
   479  					registry = prometheus.NewRegistry()
   480  					logger   testLogger
   481  					zlogger  = zerolog.New(io.Discard)
   482  				)
   483  
   484  				success, _, err := processor.Run(ctx, registry, &logger, zlogger, SecretStore{}, "test-execution-id")
   485  				require.ErrorIs(t, err, tc.expectError)
   486  				require.Equal(t, tc.expectError == nil, success)
   487  				require.Equal(t, tc.expectRequests, requests.Load())
   488  			})
   489  		}
   490  	})
   491  
   492  	t.Run("retries network errors", func(t *testing.T) {
   493  		t.Parallel()
   494  
   495  		mux := http.NewServeMux()
   496  		mux.Handle("/run", emptyJSON(http.StatusOK))
   497  
   498  		listenerCh := make(chan string)
   499  
   500  		go func() {
   501  			listener, err := net.Listen("tcp4", "localhost:")
   502  			require.NoError(t, err)
   503  
   504  			listenerCh <- listener.Addr().String()
   505  
   506  			err = http.Serve(listener, mux)
   507  			require.NoError(t, err)
   508  
   509  			t.Cleanup(func() {
   510  				listener.Close()
   511  			})
   512  		}()
   513  
   514  		addr := <-listenerCh
   515  
   516  		runner := HttpRunner{url: "http://" + addr, graceTime: time.Second, backoff: time.Second, metrics: NewHTTPMetrics(prometheus.NewRegistry())}
   517  		processor, err := NewProcessor(Script{Script: nil, Settings: Settings{Timeout: 1000}}, runner)
   518  		require.NoError(t, err)
   519  
   520  		baseCtx, baseCancel := context.WithTimeout(context.Background(), 5*time.Second)
   521  		t.Cleanup(baseCancel)
   522  		ctx, cancel := testhelper.Context(baseCtx, t)
   523  		t.Cleanup(cancel)
   524  
   525  		var (
   526  			registry = prometheus.NewRegistry()
   527  			logger   testLogger
   528  			zlogger  = zerolog.New(io.Discard)
   529  		)
   530  
   531  		success, _, err := processor.Run(ctx, registry, &logger, zlogger, SecretStore{}, "test-execution-id")
   532  		require.NoError(t, err)
   533  		require.True(t, success)
   534  	})
   535  }
   536  
   537  func TestHTTPVersions(t *testing.T) {
   538  	t.Parallel()
   539  
   540  	mux := http.NewServeMux()
   541  	mux.HandleFunc("/run", func(rw http.ResponseWriter, _ *http.Request) {
   542  		// Should never be called.
   543  		rw.WriteHeader(http.StatusInternalServerError)
   544  		t.Fail()
   545  	})
   546  	mux.HandleFunc("/versions", func(rw http.ResponseWriter, r *http.Request) {
   547  		if !strings.Contains(r.Header.Get("accept"), "application/json") {
   548  			rw.WriteHeader(http.StatusNotAcceptable)
   549  			return
   550  		}
   551  
   552  		rw.Header().Add("content-type", "application/json")
   553  		rw.WriteHeader(http.StatusOK)
   554  
   555  		_, err := rw.Write([]byte(`{
   556  			"versions": [
   557  				{"path":"/usr/local/libexec/sm-k6/k6-v1", "version":"1.2.3"},
   558  				{"path":"/usr/local/libexec/sm-k6/k6-v2", "version":"2.3.4"}
   559  			]
   560  		}`))
   561  		if err != nil {
   562  			t.Fatalf("writing response body: %v", err)
   563  		}
   564  	})
   565  
   566  	srv := httptest.NewServer(mux)
   567  
   568  	logger := zerolog.New(os.Stderr)
   569  
   570  	runner := HttpRunner{
   571  		logger:              &logger,
   572  		url:                 srv.URL,
   573  		versionPollInterval: time.Second,
   574  	}
   575  
   576  	ctx, cancel := context.WithCancel(context.Background())
   577  	t.Cleanup(cancel)
   578  
   579  	versionsCh := runner.Versions(ctx)
   580  
   581  	t.Run("Retrieves version at least once", func(t *testing.T) {
   582  		select {
   583  		case <-time.After(3 * time.Second):
   584  			t.Fatalf("No version was sent to the channel")
   585  		case vs := <-versionsCh:
   586  			require.ElementsMatch(t, vs, []string{"1.2.3", "2.3.4"})
   587  		}
   588  	})
   589  
   590  	t.Run("Closes channel and stops on context cancellation", func(t *testing.T) {
   591  		cancel()
   592  
   593  		select {
   594  		case <-time.After(3 * time.Second):
   595  			t.Fatalf("Channel was not closed within timeout after context cancellation")
   596  		case vs, ok := <-versionsCh:
   597  			if ok {
   598  				t.Fatalf("Expected nothing from a closed channel, got %v", vs)
   599  			}
   600  		}
   601  	})
   602  
   603  	t.Cleanup(srv.Close)
   604  }
   605  
   606  func TestTrimSuffix(t *testing.T) {
   607  	t.Parallel()
   608  
   609  	for _, tc := range []struct {
   610  		name     string
   611  		input    string
   612  		expected string
   613  	}{
   614  		{
   615  			name:     "URL without run",
   616  			input:    "http://test.local/",
   617  			expected: "http://test.local/",
   618  		},
   619  		{
   620  			name:     "URL without trailing slash",
   621  			input:    "http://test.local",
   622  			expected: "http://test.local",
   623  		},
   624  		{
   625  			name:     "URL with /run not at the end",
   626  			input:    "http://test.local/run/boo",
   627  			expected: "http://test.local/run/boo",
   628  		},
   629  		{
   630  			name:     "URL with /run",
   631  			input:    "http://test.local/run/",
   632  			expected: "http://test.local",
   633  		},
   634  		{
   635  			name:     "URL with /run, no slash",
   636  			input:    "http://test.local/run",
   637  			expected: "http://test.local",
   638  		},
   639  		{
   640  			name:     "URL with two runs",
   641  			input:    "http://test.local/run/run",
   642  			expected: "http://test.local/run",
   643  		},
   644  		{
   645  			name:     "Invalid URL",
   646  			input:    "!!!#nyanya~",
   647  			expected: "!!!#nyanya~",
   648  		},
   649  	} {
   650  		t.Run(tc.name, func(t *testing.T) {
   651  			t.Parallel()
   652  			require.Equal(t, tc.expected, trimRunSuffix(tc.input))
   653  		})
   654  	}
   655  }
   656  
   657  func emptyJSON(status int) http.Handler {
   658  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   659  		w.Header().Set("Content-Type", "application/json")
   660  		w.WriteHeader(status)
   661  		_, _ = w.Write([]byte("{}"))
   662  	})
   663  }
   664  
   665  // afterAttempts invokes the first handler if strictly less than [afterAttempts] requests have been made before to it.
   666  // afterAttempts(a, 0, b) always invokes b.
   667  // afterAttempts(a, 1, b) always invokes a for the first request, then b for the subsequent ones.
   668  func afterAttempts(a http.Handler, afterAttempts int, b http.Handler) http.Handler {
   669  	pastAttempts := 0
   670  
   671  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   672  		if pastAttempts >= afterAttempts {
   673  			b.ServeHTTP(w, r)
   674  			return
   675  		}
   676  
   677  		a.ServeHTTP(w, r)
   678  
   679  		pastAttempts++
   680  	})
   681  }
   682  
   683  // delay calls next after some time has passed. It watches for incoming request context cancellation to avoid leaking
   684  // connections.
   685  func delay(d time.Duration, next http.Handler) http.Handler {
   686  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   687  		// Fully consume the request body before waiting. If we do not do this, cancelling the request context will not
   688  		// close the network connection, and httptest.Server will complain.
   689  		_, _ = io.Copy(io.Discard, r.Body)
   690  
   691  		select {
   692  		case <-r.Context().Done():
   693  			// Abort waiting if the client closes the request. Again, if we don't, httptest.Server will complain.
   694  		case <-time.After(d):
   695  			next.ServeHTTP(w, r)
   696  		}
   697  	})
   698  }