acfdf0f479
Closes #17117
270 lines
8.4 KiB
Go
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
|
|
}
|