github.com/calmw/ethereum@v0.1.1/node/rpcstack_test.go (about)

     1  // Copyright 2020 The go-ethereum Authors
     2  // This file is part of the go-ethereum library.
     3  //
     4  // The go-ethereum library is free software: you can redistribute it and/or modify
     5  // it under the terms of the GNU Lesser General Public License as published by
     6  // the Free Software Foundation, either version 3 of the License, or
     7  // (at your option) any later version.
     8  //
     9  // The go-ethereum library is distributed in the hope that it will be useful,
    10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    12  // GNU Lesser General Public License for more details.
    13  //
    14  // You should have received a copy of the GNU Lesser General Public License
    15  // along with the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.
    16  
    17  package node
    18  
    19  import (
    20  	"bytes"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/http/httptest"
    25  	"net/url"
    26  	"strconv"
    27  	"strings"
    28  	"testing"
    29  	"time"
    30  
    31  	"github.com/calmw/ethereum/internal/testlog"
    32  	"github.com/calmw/ethereum/log"
    33  	"github.com/calmw/ethereum/rpc"
    34  	"github.com/golang-jwt/jwt/v4"
    35  	"github.com/gorilla/websocket"
    36  	"github.com/stretchr/testify/assert"
    37  )
    38  
    39  const testMethod = "rpc_modules"
    40  
    41  // TestCorsHandler makes sure CORS are properly handled on the http server.
    42  func TestCorsHandler(t *testing.T) {
    43  	srv := createAndStartServer(t, &httpConfig{CorsAllowedOrigins: []string{"test", "test.com"}}, false, &wsConfig{}, nil)
    44  	defer srv.stop()
    45  	url := "http://" + srv.listenAddr()
    46  
    47  	resp := rpcRequest(t, url, testMethod, "origin", "test.com")
    48  	assert.Equal(t, "test.com", resp.Header.Get("Access-Control-Allow-Origin"))
    49  
    50  	resp2 := rpcRequest(t, url, testMethod, "origin", "bad")
    51  	assert.Equal(t, "", resp2.Header.Get("Access-Control-Allow-Origin"))
    52  }
    53  
    54  // TestVhosts makes sure vhosts are properly handled on the http server.
    55  func TestVhosts(t *testing.T) {
    56  	srv := createAndStartServer(t, &httpConfig{Vhosts: []string{"test"}}, false, &wsConfig{}, nil)
    57  	defer srv.stop()
    58  	url := "http://" + srv.listenAddr()
    59  
    60  	resp := rpcRequest(t, url, testMethod, "host", "test")
    61  	assert.Equal(t, resp.StatusCode, http.StatusOK)
    62  
    63  	resp2 := rpcRequest(t, url, testMethod, "host", "bad")
    64  	assert.Equal(t, resp2.StatusCode, http.StatusForbidden)
    65  }
    66  
    67  type originTest struct {
    68  	spec    string
    69  	expOk   []string
    70  	expFail []string
    71  }
    72  
    73  // splitAndTrim splits input separated by a comma
    74  // and trims excessive white space from the substrings.
    75  // Copied over from flags.go
    76  func splitAndTrim(input string) (ret []string) {
    77  	l := strings.Split(input, ",")
    78  	for _, r := range l {
    79  		r = strings.TrimSpace(r)
    80  		if len(r) > 0 {
    81  			ret = append(ret, r)
    82  		}
    83  	}
    84  	return ret
    85  }
    86  
    87  // TestWebsocketOrigins makes sure the websocket origins are properly handled on the websocket server.
    88  func TestWebsocketOrigins(t *testing.T) {
    89  	tests := []originTest{
    90  		{
    91  			spec: "*", // allow all
    92  			expOk: []string{"", "http://test", "https://test", "http://test:8540", "https://test:8540",
    93  				"http://test.com", "https://foo.test", "http://testa", "http://atestb:8540", "https://atestb:8540"},
    94  		},
    95  		{
    96  			spec:    "test",
    97  			expOk:   []string{"http://test", "https://test", "http://test:8540", "https://test:8540"},
    98  			expFail: []string{"http://test.com", "https://foo.test", "http://testa", "http://atestb:8540", "https://atestb:8540"},
    99  		},
   100  		// scheme tests
   101  		{
   102  			spec:  "https://test",
   103  			expOk: []string{"https://test", "https://test:9999"},
   104  			expFail: []string{
   105  				"test",                                // no scheme, required by spec
   106  				"http://test",                         // wrong scheme
   107  				"http://test.foo", "https://a.test.x", // subdomain variations
   108  				"http://testx:8540", "https://xtest:8540"},
   109  		},
   110  		// ip tests
   111  		{
   112  			spec:  "https://12.34.56.78",
   113  			expOk: []string{"https://12.34.56.78", "https://12.34.56.78:8540"},
   114  			expFail: []string{
   115  				"http://12.34.56.78",     // wrong scheme
   116  				"http://12.34.56.78:443", // wrong scheme
   117  				"http://1.12.34.56.78",   // wrong 'domain name'
   118  				"http://12.34.56.78.a",   // wrong 'domain name'
   119  				"https://87.65.43.21", "http://87.65.43.21:8540", "https://87.65.43.21:8540"},
   120  		},
   121  		// port tests
   122  		{
   123  			spec:  "test:8540",
   124  			expOk: []string{"http://test:8540", "https://test:8540"},
   125  			expFail: []string{
   126  				"http://test", "https://test", // spec says port required
   127  				"http://test:8541", "https://test:8541", // wrong port
   128  				"http://bad", "https://bad", "http://bad:8540", "https://bad:8540"},
   129  		},
   130  		// scheme and port
   131  		{
   132  			spec:  "https://test:8540",
   133  			expOk: []string{"https://test:8540"},
   134  			expFail: []string{
   135  				"https://test",                          // missing port
   136  				"http://test",                           // missing port, + wrong scheme
   137  				"http://test:8540",                      // wrong scheme
   138  				"http://test:8541", "https://test:8541", // wrong port
   139  				"http://bad", "https://bad", "http://bad:8540", "https://bad:8540"},
   140  		},
   141  		// several allowed origins
   142  		{
   143  			spec: "localhost,http://127.0.0.1",
   144  			expOk: []string{"localhost", "http://localhost", "https://localhost:8443",
   145  				"http://127.0.0.1", "http://127.0.0.1:8080"},
   146  			expFail: []string{
   147  				"https://127.0.0.1", // wrong scheme
   148  				"http://bad", "https://bad", "http://bad:8540", "https://bad:8540"},
   149  		},
   150  	}
   151  	for _, tc := range tests {
   152  		srv := createAndStartServer(t, &httpConfig{}, true, &wsConfig{Origins: splitAndTrim(tc.spec)}, nil)
   153  		url := fmt.Sprintf("ws://%v", srv.listenAddr())
   154  		for _, origin := range tc.expOk {
   155  			if err := wsRequest(t, url, "Origin", origin); err != nil {
   156  				t.Errorf("spec '%v', origin '%v': expected ok, got %v", tc.spec, origin, err)
   157  			}
   158  		}
   159  		for _, origin := range tc.expFail {
   160  			if err := wsRequest(t, url, "Origin", origin); err == nil {
   161  				t.Errorf("spec '%v', origin '%v': expected not to allow,  got ok", tc.spec, origin)
   162  			}
   163  		}
   164  		srv.stop()
   165  	}
   166  }
   167  
   168  // TestIsWebsocket tests if an incoming websocket upgrade request is handled properly.
   169  func TestIsWebsocket(t *testing.T) {
   170  	r, _ := http.NewRequest(http.MethodGet, "/", nil)
   171  
   172  	assert.False(t, isWebsocket(r))
   173  	r.Header.Set("upgrade", "websocket")
   174  	assert.False(t, isWebsocket(r))
   175  	r.Header.Set("connection", "upgrade")
   176  	assert.True(t, isWebsocket(r))
   177  	r.Header.Set("connection", "upgrade,keep-alive")
   178  	assert.True(t, isWebsocket(r))
   179  	r.Header.Set("connection", " UPGRADE,keep-alive")
   180  	assert.True(t, isWebsocket(r))
   181  }
   182  
   183  func Test_checkPath(t *testing.T) {
   184  	tests := []struct {
   185  		req      *http.Request
   186  		prefix   string
   187  		expected bool
   188  	}{
   189  		{
   190  			req:      &http.Request{URL: &url.URL{Path: "/test"}},
   191  			prefix:   "/test",
   192  			expected: true,
   193  		},
   194  		{
   195  			req:      &http.Request{URL: &url.URL{Path: "/testing"}},
   196  			prefix:   "/test",
   197  			expected: true,
   198  		},
   199  		{
   200  			req:      &http.Request{URL: &url.URL{Path: "/"}},
   201  			prefix:   "/test",
   202  			expected: false,
   203  		},
   204  		{
   205  			req:      &http.Request{URL: &url.URL{Path: "/fail"}},
   206  			prefix:   "/test",
   207  			expected: false,
   208  		},
   209  		{
   210  			req:      &http.Request{URL: &url.URL{Path: "/"}},
   211  			prefix:   "",
   212  			expected: true,
   213  		},
   214  		{
   215  			req:      &http.Request{URL: &url.URL{Path: "/fail"}},
   216  			prefix:   "",
   217  			expected: false,
   218  		},
   219  		{
   220  			req:      &http.Request{URL: &url.URL{Path: "/"}},
   221  			prefix:   "/",
   222  			expected: true,
   223  		},
   224  		{
   225  			req:      &http.Request{URL: &url.URL{Path: "/testing"}},
   226  			prefix:   "/",
   227  			expected: true,
   228  		},
   229  	}
   230  
   231  	for i, tt := range tests {
   232  		t.Run(strconv.Itoa(i), func(t *testing.T) {
   233  			assert.Equal(t, tt.expected, checkPath(tt.req, tt.prefix))
   234  		})
   235  	}
   236  }
   237  
   238  func createAndStartServer(t *testing.T, conf *httpConfig, ws bool, wsConf *wsConfig, timeouts *rpc.HTTPTimeouts) *httpServer {
   239  	t.Helper()
   240  
   241  	if timeouts == nil {
   242  		timeouts = &rpc.DefaultHTTPTimeouts
   243  	}
   244  	srv := newHTTPServer(testlog.Logger(t, log.LvlDebug), *timeouts)
   245  	assert.NoError(t, srv.enableRPC(apis(), *conf))
   246  	if ws {
   247  		assert.NoError(t, srv.enableWS(nil, *wsConf))
   248  	}
   249  	assert.NoError(t, srv.setListenAddr("localhost", 0))
   250  	assert.NoError(t, srv.start())
   251  	return srv
   252  }
   253  
   254  // wsRequest attempts to open a WebSocket connection to the given URL.
   255  func wsRequest(t *testing.T, url string, extraHeaders ...string) error {
   256  	t.Helper()
   257  	//t.Logf("checking WebSocket on %s (origin %q)", url, browserOrigin)
   258  
   259  	headers := make(http.Header)
   260  	// Apply extra headers.
   261  	if len(extraHeaders)%2 != 0 {
   262  		panic("odd extraHeaders length")
   263  	}
   264  	for i := 0; i < len(extraHeaders); i += 2 {
   265  		key, value := extraHeaders[i], extraHeaders[i+1]
   266  		headers.Set(key, value)
   267  	}
   268  	conn, _, err := websocket.DefaultDialer.Dial(url, headers)
   269  	if conn != nil {
   270  		conn.Close()
   271  	}
   272  	return err
   273  }
   274  
   275  // rpcRequest performs a JSON-RPC request to the given URL.
   276  func rpcRequest(t *testing.T, url, method string, extraHeaders ...string) *http.Response {
   277  	t.Helper()
   278  
   279  	body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"%s","params":[]}`, method)
   280  	return baseRpcRequest(t, url, body, extraHeaders...)
   281  }
   282  
   283  func batchRpcRequest(t *testing.T, url string, methods []string, extraHeaders ...string) *http.Response {
   284  	reqs := make([]string, len(methods))
   285  	for i, m := range methods {
   286  		reqs[i] = fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"%s","params":[]}`, m)
   287  	}
   288  	body := fmt.Sprintf(`[%s]`, strings.Join(reqs, ","))
   289  	return baseRpcRequest(t, url, body, extraHeaders...)
   290  }
   291  
   292  func baseRpcRequest(t *testing.T, url, bodyStr string, extraHeaders ...string) *http.Response {
   293  	t.Helper()
   294  
   295  	// Create the request.
   296  	body := bytes.NewReader([]byte(bodyStr))
   297  	req, err := http.NewRequest(http.MethodPost, url, body)
   298  	if err != nil {
   299  		t.Fatal("could not create http request:", err)
   300  	}
   301  	req.Header.Set("content-type", "application/json")
   302  	req.Header.Set("accept-encoding", "identity")
   303  
   304  	// Apply extra headers.
   305  	if len(extraHeaders)%2 != 0 {
   306  		panic("odd extraHeaders length")
   307  	}
   308  	for i := 0; i < len(extraHeaders); i += 2 {
   309  		key, value := extraHeaders[i], extraHeaders[i+1]
   310  		if strings.EqualFold(key, "host") {
   311  			req.Host = value
   312  		} else {
   313  			req.Header.Set(key, value)
   314  		}
   315  	}
   316  
   317  	// Perform the request.
   318  	t.Logf("checking RPC/HTTP on %s %v", url, extraHeaders)
   319  	resp, err := http.DefaultClient.Do(req)
   320  	if err != nil {
   321  		t.Fatal(err)
   322  	}
   323  	t.Cleanup(func() { resp.Body.Close() })
   324  	return resp
   325  }
   326  
   327  type testClaim map[string]interface{}
   328  
   329  func (testClaim) Valid() error {
   330  	return nil
   331  }
   332  
   333  func TestJWT(t *testing.T) {
   334  	var secret = []byte("secret")
   335  	issueToken := func(secret []byte, method jwt.SigningMethod, input map[string]interface{}) string {
   336  		if method == nil {
   337  			method = jwt.SigningMethodHS256
   338  		}
   339  		ss, _ := jwt.NewWithClaims(method, testClaim(input)).SignedString(secret)
   340  		return ss
   341  	}
   342  	srv := createAndStartServer(t, &httpConfig{jwtSecret: []byte("secret")},
   343  		true, &wsConfig{Origins: []string{"*"}, jwtSecret: []byte("secret")}, nil)
   344  	wsUrl := fmt.Sprintf("ws://%v", srv.listenAddr())
   345  	htUrl := fmt.Sprintf("http://%v", srv.listenAddr())
   346  
   347  	expOk := []func() string{
   348  		func() string {
   349  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   350  		},
   351  		func() string {
   352  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix() + 4}))
   353  		},
   354  		func() string {
   355  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix() - 4}))
   356  		},
   357  		func() string {
   358  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{
   359  				"iat": time.Now().Unix(),
   360  				"exp": time.Now().Unix() + 2,
   361  			}))
   362  		},
   363  		func() string {
   364  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{
   365  				"iat": time.Now().Unix(),
   366  				"bar": "baz",
   367  			}))
   368  		},
   369  	}
   370  	for i, tokenFn := range expOk {
   371  		token := tokenFn()
   372  		if err := wsRequest(t, wsUrl, "Authorization", token); err != nil {
   373  			t.Errorf("test %d-ws, token '%v': expected ok, got %v", i, token, err)
   374  		}
   375  		token = tokenFn()
   376  		if resp := rpcRequest(t, htUrl, testMethod, "Authorization", token); resp.StatusCode != 200 {
   377  			t.Errorf("test %d-http, token '%v': expected ok, got %v", i, token, resp.StatusCode)
   378  		}
   379  	}
   380  
   381  	expFail := []func() string{
   382  		// future
   383  		func() string {
   384  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix() + int64(jwtExpiryTimeout.Seconds()) + 1}))
   385  		},
   386  		// stale
   387  		func() string {
   388  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix() - int64(jwtExpiryTimeout.Seconds()) - 1}))
   389  		},
   390  		// wrong algo
   391  		func() string {
   392  			return fmt.Sprintf("Bearer %v", issueToken(secret, jwt.SigningMethodHS512, testClaim{"iat": time.Now().Unix() + 4}))
   393  		},
   394  		// expired
   395  		func() string {
   396  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix(), "exp": time.Now().Unix()}))
   397  		},
   398  		// missing mandatory iat
   399  		func() string {
   400  			return fmt.Sprintf("Bearer %v", issueToken(secret, nil, testClaim{}))
   401  		},
   402  		//  wrong secret
   403  		func() string {
   404  			return fmt.Sprintf("Bearer %v", issueToken([]byte("wrong"), nil, testClaim{"iat": time.Now().Unix()}))
   405  		},
   406  		func() string {
   407  			return fmt.Sprintf("Bearer %v", issueToken([]byte{}, nil, testClaim{"iat": time.Now().Unix()}))
   408  		},
   409  		func() string {
   410  			return fmt.Sprintf("Bearer %v", issueToken(nil, nil, testClaim{"iat": time.Now().Unix()}))
   411  		},
   412  		// Various malformed syntax
   413  		func() string {
   414  			return fmt.Sprintf("%v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   415  		},
   416  		func() string {
   417  			return fmt.Sprintf("Bearer  %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   418  		},
   419  		func() string {
   420  			return fmt.Sprintf("bearer %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   421  		},
   422  		func() string {
   423  			return fmt.Sprintf("Bearer: %v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   424  		},
   425  		func() string {
   426  			return fmt.Sprintf("Bearer:%v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   427  		},
   428  		func() string {
   429  			return fmt.Sprintf("Bearer\t%v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   430  		},
   431  		func() string {
   432  			return fmt.Sprintf("Bearer \t%v", issueToken(secret, nil, testClaim{"iat": time.Now().Unix()}))
   433  		},
   434  	}
   435  	for i, tokenFn := range expFail {
   436  		token := tokenFn()
   437  		if err := wsRequest(t, wsUrl, "Authorization", token); err == nil {
   438  			t.Errorf("tc %d-ws, token '%v': expected not to allow,  got ok", i, token)
   439  		}
   440  
   441  		token = tokenFn()
   442  		resp := rpcRequest(t, htUrl, testMethod, "Authorization", token)
   443  		if resp.StatusCode != http.StatusUnauthorized {
   444  			t.Errorf("tc %d-http, token '%v': expected not to allow,  got %v", i, token, resp.StatusCode)
   445  		}
   446  	}
   447  	srv.stop()
   448  }
   449  
   450  func TestGzipHandler(t *testing.T) {
   451  	type gzipTest struct {
   452  		name    string
   453  		handler http.HandlerFunc
   454  		status  int
   455  		isGzip  bool
   456  		header  map[string]string
   457  	}
   458  	tests := []gzipTest{
   459  		{
   460  			name: "Write",
   461  			handler: func(w http.ResponseWriter, r *http.Request) {
   462  				w.Write([]byte("response"))
   463  			},
   464  			isGzip: true,
   465  			status: 200,
   466  		},
   467  		{
   468  			name: "WriteHeader",
   469  			handler: func(w http.ResponseWriter, r *http.Request) {
   470  				w.Header().Set("x-foo", "bar")
   471  				w.WriteHeader(205)
   472  				w.Write([]byte("response"))
   473  			},
   474  			isGzip: true,
   475  			status: 205,
   476  			header: map[string]string{"x-foo": "bar"},
   477  		},
   478  		{
   479  			name: "WriteContentLength",
   480  			handler: func(w http.ResponseWriter, r *http.Request) {
   481  				w.Header().Set("content-length", "8")
   482  				w.Write([]byte("response"))
   483  			},
   484  			isGzip: true,
   485  			status: 200,
   486  		},
   487  		{
   488  			name: "Flush",
   489  			handler: func(w http.ResponseWriter, r *http.Request) {
   490  				w.Write([]byte("res"))
   491  				w.(http.Flusher).Flush()
   492  				w.Write([]byte("ponse"))
   493  			},
   494  			isGzip: true,
   495  			status: 200,
   496  		},
   497  		{
   498  			name: "disable",
   499  			handler: func(w http.ResponseWriter, r *http.Request) {
   500  				w.Header().Set("transfer-encoding", "identity")
   501  				w.Header().Set("x-foo", "bar")
   502  				w.Write([]byte("response"))
   503  			},
   504  			isGzip: false,
   505  			status: 200,
   506  			header: map[string]string{"x-foo": "bar"},
   507  		},
   508  		{
   509  			name: "disable-WriteHeader",
   510  			handler: func(w http.ResponseWriter, r *http.Request) {
   511  				w.Header().Set("transfer-encoding", "identity")
   512  				w.Header().Set("x-foo", "bar")
   513  				w.WriteHeader(205)
   514  				w.Write([]byte("response"))
   515  			},
   516  			isGzip: false,
   517  			status: 205,
   518  			header: map[string]string{"x-foo": "bar"},
   519  		},
   520  	}
   521  
   522  	for _, test := range tests {
   523  		test := test
   524  		t.Run(test.name, func(t *testing.T) {
   525  			srv := httptest.NewServer(newGzipHandler(test.handler))
   526  			defer srv.Close()
   527  
   528  			resp, err := http.Get(srv.URL)
   529  			if err != nil {
   530  				t.Fatal(err)
   531  			}
   532  			defer resp.Body.Close()
   533  
   534  			content, err := io.ReadAll(resp.Body)
   535  			if err != nil {
   536  				t.Fatal(err)
   537  			}
   538  			wasGzip := resp.Uncompressed
   539  
   540  			if string(content) != "response" {
   541  				t.Fatalf("wrong response content %q", content)
   542  			}
   543  			if wasGzip != test.isGzip {
   544  				t.Fatalf("response gzipped == %t, want %t", wasGzip, test.isGzip)
   545  			}
   546  			if resp.StatusCode != test.status {
   547  				t.Fatalf("response status == %d, want %d", resp.StatusCode, test.status)
   548  			}
   549  			for name, expectedValue := range test.header {
   550  				if v := resp.Header.Get(name); v != expectedValue {
   551  					t.Fatalf("response header %s == %s, want %s", name, v, expectedValue)
   552  				}
   553  			}
   554  		})
   555  	}
   556  }
   557  
   558  func TestHTTPWriteTimeout(t *testing.T) {
   559  	const (
   560  		timeoutRes = `{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"request timed out"}}`
   561  		greetRes   = `{"jsonrpc":"2.0","id":1,"result":"Hello"}`
   562  	)
   563  	// Set-up server
   564  	timeouts := rpc.DefaultHTTPTimeouts
   565  	timeouts.WriteTimeout = time.Second
   566  	srv := createAndStartServer(t, &httpConfig{Modules: []string{"test"}}, false, &wsConfig{}, &timeouts)
   567  	url := fmt.Sprintf("http://%v", srv.listenAddr())
   568  
   569  	// Send normal request
   570  	t.Run("message", func(t *testing.T) {
   571  		resp := rpcRequest(t, url, "test_sleep")
   572  		defer resp.Body.Close()
   573  		body, err := io.ReadAll(resp.Body)
   574  		if err != nil {
   575  			t.Fatal(err)
   576  		}
   577  		if string(body) != timeoutRes {
   578  			t.Errorf("wrong response. have %s, want %s", string(body), timeoutRes)
   579  		}
   580  	})
   581  
   582  	// Batch request
   583  	t.Run("batch", func(t *testing.T) {
   584  		want := fmt.Sprintf("[%s,%s,%s]", greetRes, timeoutRes, timeoutRes)
   585  		resp := batchRpcRequest(t, url, []string{"test_greet", "test_sleep", "test_greet"})
   586  		defer resp.Body.Close()
   587  		body, err := io.ReadAll(resp.Body)
   588  		if err != nil {
   589  			t.Fatal(err)
   590  		}
   591  		if string(body) != want {
   592  			t.Errorf("wrong response. have %s, want %s", string(body), want)
   593  		}
   594  	})
   595  }
   596  
   597  func apis() []rpc.API {
   598  	return []rpc.API{
   599  		{
   600  			Namespace: "test",
   601  			Service:   &testService{},
   602  		},
   603  	}
   604  }
   605  
   606  type testService struct{}
   607  
   608  func (s *testService) Greet() string {
   609  	return "Hello"
   610  }
   611  
   612  func (s *testService) Sleep() {
   613  	time.Sleep(1500 * time.Millisecond)
   614  }