-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add tests for UnexpectedResponseError
This adds a testServer implementation and some http.Handlers that can test behaviors that are difficult or unreasonable to add to the real HTTP API server. For example, the `closingHandler` intentionally provides partial results to test the `additional` error pathway.
- Loading branch information
Showing
5 changed files
with
274 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,268 @@ | ||
package api_test | ||
|
||
import ( | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"net/url" | ||
"strings" | ||
"testing" | ||
"testing/iotest" | ||
"time" | ||
|
||
"github.com/felixge/httpsnoop" | ||
"github.com/hashicorp/nomad/api" | ||
"github.com/shoenig/test/must" | ||
"github.com/shoenig/test/portal" | ||
) | ||
|
||
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) { | ||
t.Parallel() | ||
a := testServer(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 testServer handles the default namespace | ||
// correctly. This ensures that the routing rule for this path is at least | ||
// correct and that the testServer 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) { | ||
t.Parallel() | ||
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) { | ||
t.Parallel() | ||
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) { | ||
t.Parallel() | ||
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 | ||
}) | ||
} | ||
|
||
// testServer creates a httptest.Server that can be used to serve simple mock | ||
// data, which is faster than starting a real Nomad agent. | ||
func testServer(t *testing.T) string { | ||
grabber := portal.New(t) | ||
ports := grabber.Grab(1) | ||
must.Len(t, 1, ports) | ||
|
||
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", ports[0]) | ||
|
||
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 testServer, got resp: %s", b) | ||
|
||
// If we get here, the testServer 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) { | ||
t := t | ||
// 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 testserver | ||
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 | ||
func ipAddrFromRemoteAddr(s string) string { | ||
idx := strings.LastIndex(s, ":") | ||
if idx == -1 { | ||
return s | ||
} | ||
return s[:idx] | ||
} | ||
|
||
// 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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters