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 }