github.com/hashicorp/nomad/api@v0.0.0-20240306165712-3193ac204f65/error_unexpected_response_test.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package api_test
     5  
     6  import (
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"net/http/httptest"
    12  	"net/netip"
    13  	"net/url"
    14  	"strings"
    15  	"testing"
    16  	"testing/iotest"
    17  	"time"
    18  
    19  	"github.com/felixge/httpsnoop"
    20  	"github.com/hashicorp/nomad/api"
    21  	"github.com/hashicorp/nomad/api/internal/testutil"
    22  	"github.com/shoenig/test/must"
    23  )
    24  
    25  const mockNamespaceBody = `{"Capabilities":null,"CreateIndex":1,"Description":"Default shared namespace","Hash":"C7UbjDwBK0dK8wQq7Izg7SJIzaV+lIo2X7wRtzY3pSw=","Meta":null,"ModifyIndex":1,"Name":"default","Quota":""}`
    26  
    27  func TestUnexpectedResponseError(t *testing.T) {
    28  	testutil.Parallel(t)
    29  	a := mockserver(t)
    30  	cfg := api.DefaultConfig()
    31  	cfg.Address = a
    32  
    33  	c, e := api.NewClient(cfg)
    34  	must.NoError(t, e)
    35  
    36  	type testCase struct {
    37  		testFunc   func()
    38  		statusCode *int
    39  		body       *int
    40  	}
    41  
    42  	// ValidateServer ensures that the mock server handles the default namespace
    43  	// correctly. This ensures that the routing rule for this path is at least
    44  	// correct and that the mock server is passing its address to the client
    45  	// properly.
    46  	t.Run("ValidateServer", func(t *testing.T) {
    47  		n, _, err := c.Namespaces().Info("default", nil)
    48  		must.NoError(t, err)
    49  		var ns api.Namespace
    50  		err = unmock(t, mockNamespaceBody, &ns)
    51  		must.NoError(t, err)
    52  		must.Eq(t, ns, *n)
    53  	})
    54  
    55  	// WrongStatus tests that an UnexpectedResponseError is generated and filled
    56  	// with the correct data when a response code that the API client wasn't
    57  	// looking for is returned by the server.
    58  	t.Run("WrongStatus", func(t *testing.T) {
    59  		testutil.Parallel(t)
    60  		n, _, err := c.Namespaces().Info("badStatus", nil)
    61  		must.Nil(t, n)
    62  		must.Error(t, err)
    63  		t.Logf("err: %v", err)
    64  
    65  		ure, ok := err.(api.UnexpectedResponseError)
    66  		must.True(t, ok)
    67  
    68  		must.True(t, ure.HasStatusCode())
    69  		must.Eq(t, http.StatusAccepted, ure.StatusCode())
    70  
    71  		must.True(t, ure.HasStatusText())
    72  		must.Eq(t, http.StatusText(http.StatusAccepted), ure.StatusText())
    73  
    74  		must.True(t, ure.HasBody())
    75  		must.Eq(t, mockNamespaceBody, ure.Body())
    76  	})
    77  
    78  	// NotFound tests that an UnexpectedResponseError is generated and filled
    79  	// with the correct data when a `404 Not Found`` is returned to the API
    80  	// client, since the requireOK wrapper doesn't "expect" 404s.
    81  	t.Run("NotFound", func(t *testing.T) {
    82  		testutil.Parallel(t)
    83  		n, _, err := c.Namespaces().Info("wat", nil)
    84  		must.Nil(t, n)
    85  		must.Error(t, err)
    86  		t.Logf("err: %v", err)
    87  
    88  		ure, ok := err.(api.UnexpectedResponseError)
    89  		must.True(t, ok)
    90  
    91  		must.True(t, ure.HasStatusCode())
    92  		must.Eq(t, http.StatusNotFound, ure.StatusCode())
    93  
    94  		must.True(t, ure.HasStatusText())
    95  		must.Eq(t, http.StatusText(http.StatusNotFound), ure.StatusText())
    96  
    97  		must.True(t, ure.HasBody())
    98  		must.Eq(t, "Namespace not found", ure.Body())
    99  	})
   100  
   101  	// EarlyClose tests what happens when an error occurs during the building of
   102  	// the UnexpectedResponseError using FromHTTPRequest.
   103  	t.Run("EarlyClose", func(t *testing.T) {
   104  		testutil.Parallel(t)
   105  		n, _, err := c.Namespaces().Info("earlyClose", nil)
   106  		must.Nil(t, n)
   107  		must.Error(t, err)
   108  
   109  		t.Logf("e: %v\n", err)
   110  		ure, ok := err.(api.UnexpectedResponseError)
   111  		must.True(t, ok)
   112  
   113  		must.True(t, ure.HasStatusCode())
   114  		must.Eq(t, http.StatusInternalServerError, ure.StatusCode())
   115  
   116  		must.True(t, ure.HasStatusText())
   117  		must.Eq(t, http.StatusText(http.StatusInternalServerError), ure.StatusText())
   118  
   119  		must.True(t, ure.HasAdditional())
   120  		must.ErrorContains(t, err, "the body might be truncated")
   121  
   122  		must.True(t, ure.HasBody())
   123  		must.Eq(t, "{", ure.Body()) // The body is truncated to the first byte
   124  	})
   125  }
   126  
   127  // mockserver creates a httptest.Server that can be used to serve simple mock
   128  // data, which is faster than starting a real Nomad agent.
   129  func mockserver(t *testing.T) string {
   130  	port := testutil.PortAllocator.One()
   131  
   132  	mux := http.NewServeMux()
   133  	mux.Handle("/v1/namespace/earlyClose", closingHandler(http.StatusInternalServerError, mockNamespaceBody))
   134  	mux.Handle("/v1/namespace/badStatus", testHandler(http.StatusAccepted, mockNamespaceBody))
   135  	mux.Handle("/v1/namespace/default", testHandler(http.StatusOK, mockNamespaceBody))
   136  	mux.Handle("/v1/namespace/", testNotFoundHandler("Namespace not found"))
   137  	mux.Handle("/v1/namespace", http.NotFoundHandler())
   138  	mux.Handle("/v1", http.NotFoundHandler())
   139  	mux.Handle("/", testHandler(http.StatusOK, "ok"))
   140  
   141  	lMux := testLogRequestHandler(t, mux)
   142  	ts := httptest.NewUnstartedServer(lMux)
   143  	ts.Config.Addr = fmt.Sprintf("127.0.0.1:%d", port)
   144  
   145  	t.Logf("starting mock server on %s", ts.Config.Addr)
   146  	ts.Start()
   147  	t.Cleanup(func() {
   148  		t.Log("stopping mock server")
   149  		ts.Close()
   150  	})
   151  
   152  	// Test the server
   153  	tc := ts.Client()
   154  	resp, err := tc.Get(func() string { p, _ := url.JoinPath(ts.URL, "/"); return p }())
   155  	must.NoError(t, err)
   156  	defer resp.Body.Close()
   157  	b, err := io.ReadAll(resp.Body)
   158  	must.NoError(t, err)
   159  	t.Logf("checking mock server, got resp: %s", b)
   160  
   161  	// If we get here, the mock server is running and ready for requests.
   162  	return ts.URL
   163  }
   164  
   165  // addMockHeaders sets the common Nomad headers to values sufficient to be
   166  // parsed into api.QueryMeta
   167  func addMockHeaders(h http.Header) {
   168  	h.Add("X-Nomad-Knownleader", "true")
   169  	h.Add("X-Nomad-Lastcontact", "0")
   170  	h.Add("X-Nomad-Index", "1")
   171  	h.Add("Content-Type", "application/json")
   172  }
   173  
   174  // testNotFoundHandler creates a testHandler preconfigured with status code 404.
   175  func testNotFoundHandler(b string) http.Handler { return testHandler(http.StatusNotFound, b) }
   176  
   177  // testNotFoundHandler creates a testHandler preconfigured with status code 200.
   178  func testOKHandler(b string) http.Handler { return testHandler(http.StatusOK, b) }
   179  
   180  // testHandler is a helper function that writes a Nomad-like server response
   181  // with the necessary headers to make the API client happy
   182  func testHandler(sc int, b string) http.Handler {
   183  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   184  		addMockHeaders(w.Header())
   185  		w.WriteHeader(sc)
   186  		w.Write([]byte(b))
   187  	})
   188  }
   189  
   190  // closingHandler is a handler that terminates the response body early in the
   191  // reading process
   192  func closingHandler(sc int, b string) http.Handler {
   193  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   194  
   195  		// We need a misbehaving reader to test network effects when collecting
   196  		// the http.Response data into a UnexpectedResponseError
   197  		er := iotest.TimeoutReader( // TimeoutReader throws an error on the second read
   198  			iotest.OneByteReader( // OneByteReader yields a byte at a time, causing multiple reads
   199  				strings.NewReader(mockNamespaceBody),
   200  			),
   201  		)
   202  
   203  		// We need to set content-length to the true value it _should_ be so the
   204  		// API-side reader knows it's a short read.
   205  		w.Header().Set("content-length", fmt.Sprint(len(mockNamespaceBody)))
   206  		addMockHeaders(w.Header())
   207  		w.WriteHeader(sc)
   208  
   209  		// Using io.Copy to send the data into w prevents golang from setting the
   210  		// content-length itself.
   211  		io.Copy(w, er)
   212  	})
   213  }
   214  
   215  // testLogRequestHandler wraps a http.Handler with a logger that writes to the
   216  // test log output
   217  func testLogRequestHandler(t *testing.T, h http.Handler) http.Handler {
   218  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   219  		// call the original http.Handler wrapped in a httpsnoop
   220  		m := httpsnoop.CaptureMetrics(h, w, r)
   221  		ri := httpReqInfo{
   222  			uri:       r.URL.String(),
   223  			method:    r.Method,
   224  			ipaddr:    ipAddrFromRemoteAddr(r.RemoteAddr),
   225  			code:      m.Code,
   226  			duration:  m.Duration,
   227  			size:      m.Written,
   228  			userAgent: r.UserAgent(),
   229  		}
   230  		t.Logf(ri.String())
   231  	})
   232  }
   233  
   234  // httpReqInfo holds all the information used to log a request to the mock server
   235  type httpReqInfo struct {
   236  	method    string
   237  	uri       string
   238  	referer   string
   239  	ipaddr    string
   240  	code      int
   241  	size      int64
   242  	duration  time.Duration
   243  	userAgent string
   244  }
   245  
   246  func (i httpReqInfo) String() string {
   247  	return fmt.Sprintf(
   248  		"method=%q uri=%q referer=%q ipaddr=%q code=%d size=%d duration=%q userAgent=%q",
   249  		i.method, i.uri, i.referer, i.ipaddr, i.code, i.size, i.duration, i.userAgent,
   250  	)
   251  }
   252  
   253  // ipAddrFromRemoteAddr removes the port from the address:port in remote addr
   254  // in case of a parse error, the original value is returned unparsed
   255  func ipAddrFromRemoteAddr(s string) string {
   256  	if ap, err := netip.ParseAddrPort(s); err == nil {
   257  		return ap.Addr().String()
   258  	}
   259  	return s
   260  }
   261  
   262  // unmock attempts to unmarshal a given mock json body into dst, which should
   263  // be a pointer to the correct API struct.
   264  func unmock(t *testing.T, src string, dst any) error {
   265  	if err := json.Unmarshal([]byte(src), dst); err != nil {
   266  		return fmt.Errorf("error unmarshaling mock: %w", err)
   267  	}
   268  	return nil
   269  }