open-nomad/api/error_unexpected_response_test.go
2023-05-30 09:20:32 -05:00

270 lines
8.4 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package api_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"testing/iotest"
"time"
"github.com/felixge/httpsnoop"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/internal/testutil"
"github.com/shoenig/test/must"
)
const mockNamespaceBody = `{"Capabilities":null,"CreateIndex":1,"Description":"Default shared namespace","Hash":"C7UbjDwBK0dK8wQq7Izg7SJIzaV+lIo2X7wRtzY3pSw=","Meta":null,"ModifyIndex":1,"Name":"default","Quota":""}`
func TestUnexpectedResponseError(t *testing.T) {
testutil.Parallel(t)
a := mockserver(t)
cfg := api.DefaultConfig()
cfg.Address = a
c, e := api.NewClient(cfg)
must.NoError(t, e)
type testCase struct {
testFunc func()
statusCode *int
body *int
}
// ValidateServer ensures that the mock server handles the default namespace
// correctly. This ensures that the routing rule for this path is at least
// correct and that the mock server is passing its address to the client
// properly.
t.Run("ValidateServer", func(t *testing.T) {
n, _, err := c.Namespaces().Info("default", nil)
must.NoError(t, err)
var ns api.Namespace
err = unmock(t, mockNamespaceBody, &ns)
must.NoError(t, err)
must.Eq(t, ns, *n)
})
// WrongStatus tests that an UnexpectedResponseError is generated and filled
// with the correct data when a response code that the API client wasn't
// looking for is returned by the server.
t.Run("WrongStatus", func(t *testing.T) {
testutil.Parallel(t)
n, _, err := c.Namespaces().Info("badStatus", nil)
must.Nil(t, n)
must.Error(t, err)
t.Logf("err: %v", err)
ure, ok := err.(api.UnexpectedResponseError)
must.True(t, ok)
must.True(t, ure.HasStatusCode())
must.Eq(t, http.StatusAccepted, ure.StatusCode())
must.True(t, ure.HasStatusText())
must.Eq(t, http.StatusText(http.StatusAccepted), ure.StatusText())
must.True(t, ure.HasBody())
must.Eq(t, mockNamespaceBody, ure.Body())
})
// NotFound tests that an UnexpectedResponseError is generated and filled
// with the correct data when a `404 Not Found`` is returned to the API
// client, since the requireOK wrapper doesn't "expect" 404s.
t.Run("NotFound", func(t *testing.T) {
testutil.Parallel(t)
n, _, err := c.Namespaces().Info("wat", nil)
must.Nil(t, n)
must.Error(t, err)
t.Logf("err: %v", err)
ure, ok := err.(api.UnexpectedResponseError)
must.True(t, ok)
must.True(t, ure.HasStatusCode())
must.Eq(t, http.StatusNotFound, ure.StatusCode())
must.True(t, ure.HasStatusText())
must.Eq(t, http.StatusText(http.StatusNotFound), ure.StatusText())
must.True(t, ure.HasBody())
must.Eq(t, "Namespace not found", ure.Body())
})
// EarlyClose tests what happens when an error occurs during the building of
// the UnexpectedResponseError using FromHTTPRequest.
t.Run("EarlyClose", func(t *testing.T) {
testutil.Parallel(t)
n, _, err := c.Namespaces().Info("earlyClose", nil)
must.Nil(t, n)
must.Error(t, err)
t.Logf("e: %v\n", err)
ure, ok := err.(api.UnexpectedResponseError)
must.True(t, ok)
must.True(t, ure.HasStatusCode())
must.Eq(t, http.StatusInternalServerError, ure.StatusCode())
must.True(t, ure.HasStatusText())
must.Eq(t, http.StatusText(http.StatusInternalServerError), ure.StatusText())
must.True(t, ure.HasAdditional())
must.ErrorContains(t, err, "the body might be truncated")
must.True(t, ure.HasBody())
must.Eq(t, "{", ure.Body()) // The body is truncated to the first byte
})
}
// mockserver creates a httptest.Server that can be used to serve simple mock
// data, which is faster than starting a real Nomad agent.
func mockserver(t *testing.T) string {
port := testutil.PortAllocator.One()
mux := http.NewServeMux()
mux.Handle("/v1/namespace/earlyClose", closingHandler(http.StatusInternalServerError, mockNamespaceBody))
mux.Handle("/v1/namespace/badStatus", testHandler(http.StatusAccepted, mockNamespaceBody))
mux.Handle("/v1/namespace/default", testHandler(http.StatusOK, mockNamespaceBody))
mux.Handle("/v1/namespace/", testNotFoundHandler("Namespace not found"))
mux.Handle("/v1/namespace", http.NotFoundHandler())
mux.Handle("/v1", http.NotFoundHandler())
mux.Handle("/", testHandler(http.StatusOK, "ok"))
lMux := testLogRequestHandler(t, mux)
ts := httptest.NewUnstartedServer(lMux)
ts.Config.Addr = fmt.Sprintf("127.0.0.1:%d", port)
t.Logf("starting mock server on %s", ts.Config.Addr)
ts.Start()
t.Cleanup(func() {
t.Log("stopping mock server")
ts.Close()
})
// Test the server
tc := ts.Client()
resp, err := tc.Get(func() string { p, _ := url.JoinPath(ts.URL, "/"); return p }())
must.NoError(t, err)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
must.NoError(t, err)
t.Logf("checking mock server, got resp: %s", b)
// If we get here, the mock server is running and ready for requests.
return ts.URL
}
// addMockHeaders sets the common Nomad headers to values sufficient to be
// parsed into api.QueryMeta
func addMockHeaders(h http.Header) {
h.Add("X-Nomad-Knownleader", "true")
h.Add("X-Nomad-Lastcontact", "0")
h.Add("X-Nomad-Index", "1")
h.Add("Content-Type", "application/json")
}
// testNotFoundHandler creates a testHandler preconfigured with status code 404.
func testNotFoundHandler(b string) http.Handler { return testHandler(http.StatusNotFound, b) }
// testNotFoundHandler creates a testHandler preconfigured with status code 200.
func testOKHandler(b string) http.Handler { return testHandler(http.StatusOK, b) }
// testHandler is a helper function that writes a Nomad-like server response
// with the necessary headers to make the API client happy
func testHandler(sc int, b string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addMockHeaders(w.Header())
w.WriteHeader(sc)
w.Write([]byte(b))
})
}
// closingHandler is a handler that terminates the response body early in the
// reading process
func closingHandler(sc int, b string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// We need a misbehaving reader to test network effects when collecting
// the http.Response data into a UnexpectedResponseError
er := iotest.TimeoutReader( // TimeoutReader throws an error on the second read
iotest.OneByteReader( // OneByteReader yields a byte at a time, causing multiple reads
strings.NewReader(mockNamespaceBody),
),
)
// We need to set content-length to the true value it _should_ be so the
// API-side reader knows it's a short read.
w.Header().Set("content-length", fmt.Sprint(len(mockNamespaceBody)))
addMockHeaders(w.Header())
w.WriteHeader(sc)
// Using io.Copy to send the data into w prevents golang from setting the
// content-length itself.
io.Copy(w, er)
})
}
// testLogRequestHandler wraps a http.Handler with a logger that writes to the
// test log output
func testLogRequestHandler(t *testing.T, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// call the original http.Handler wrapped in a httpsnoop
m := httpsnoop.CaptureMetrics(h, w, r)
ri := httpReqInfo{
uri: r.URL.String(),
method: r.Method,
ipaddr: ipAddrFromRemoteAddr(r.RemoteAddr),
code: m.Code,
duration: m.Duration,
size: m.Written,
userAgent: r.UserAgent(),
}
t.Logf(ri.String())
})
}
// httpReqInfo holds all the information used to log a request to the mock server
type httpReqInfo struct {
method string
uri string
referer string
ipaddr string
code int
size int64
duration time.Duration
userAgent string
}
func (i httpReqInfo) String() string {
return fmt.Sprintf(
"method=%q uri=%q referer=%q ipaddr=%q code=%d size=%d duration=%q userAgent=%q",
i.method, i.uri, i.referer, i.ipaddr, i.code, i.size, i.duration, i.userAgent,
)
}
// ipAddrFromRemoteAddr removes the port from the address:port in remote addr
// in case of a parse error, the original value is returned unparsed
func ipAddrFromRemoteAddr(s string) string {
if ap, err := netip.ParseAddrPort(s); err == nil {
return ap.Addr().String()
}
return s
}
// unmock attempts to unmarshal a given mock json body into dst, which should
// be a pointer to the correct API struct.
func unmock(t *testing.T, src string, dst any) error {
if err := json.Unmarshal([]byte(src), dst); err != nil {
return fmt.Errorf("error unmarshaling mock: %w", err)
}
return nil
}