Files
moby/client/request_test.go
Sebastiaan van Stijn 4622dd0ccc client: Client.buildRequest, jsonEncode improve handling of content
- add early returns for `nil` body, `http.NoBody`, and `json.RawMessage`
- use `http.NoBody` instead of `nil` for empty bodies; it's more clear
  on intent.
- use json.Encode instead of json.Encoder.Encode(), as we're marshaling
  a single JSON document; this also avoid adding a trailing newline.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-11-15 00:39:59 +01:00

337 lines
10 KiB
Go

package client
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"testing"
"time"
cerrdefs "github.com/containerd/errdefs"
"github.com/moby/moby/api/types/common"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
// TestSetHostHeader should set fake host for local communications, set real host
// for normal communications.
func TestSetHostHeader(t *testing.T) {
const testEndpoint = "/test"
testCases := []struct {
host string
expectedHost string
expectedURLHost string
}{
{
host: "unix:///var/run/docker.sock",
expectedHost: DummyHost,
expectedURLHost: "/var/run/docker.sock",
},
{
host: "npipe:////./pipe/docker_engine",
expectedHost: DummyHost,
expectedURLHost: "//./pipe/docker_engine",
},
{
host: "tcp://0.0.0.0:4243",
expectedHost: "",
expectedURLHost: "0.0.0.0:4243",
},
{
host: "tcp://localhost:4243",
expectedHost: "",
expectedURLHost: "localhost:4243",
},
}
for _, tc := range testCases {
t.Run(tc.host, func(t *testing.T) {
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) {
if err := assertRequest(req, http.MethodGet, testEndpoint); err != nil {
return nil, err
}
if req.Host != tc.expectedHost {
return nil, fmt.Errorf("wxpected host %q, got %q", tc.expectedHost, req.Host)
}
if req.URL.Host != tc.expectedURLHost {
return nil, fmt.Errorf("expected URL host %q, got %q", tc.expectedURLHost, req.URL.Host)
}
return mockResponse(http.StatusOK, nil, "")(req)
}), WithHost(tc.host))
assert.NilError(t, err)
_, err = client.sendRequest(t.Context(), http.MethodGet, testEndpoint, nil, nil, nil)
assert.NilError(t, err)
})
}
}
// TestPlainTextError tests the server returning an error in plain text.
// API versions < 1.24 returned plain text errors, but we may encounter
// other situations where a non-JSON error is returned.
func TestPlainTextError(t *testing.T) {
client, err := New(WithMockClient(mockResponse(http.StatusInternalServerError, nil, "Server error")))
assert.NilError(t, err)
_, err = client.ContainerList(t.Context(), ContainerListOptions{})
assert.Check(t, is.ErrorType(err, cerrdefs.IsInternal))
}
// TestResponseErrors tests handling of error responses returned by the API.
// It includes test-cases for malformed and invalid error-responses, as well
// as plain text errors for backwards compatibility with API versions <1.24.
func TestResponseErrors(t *testing.T) {
errorResponse, err := json.Marshal(&common.ErrorResponse{
Message: "Some error occurred",
})
assert.NilError(t, err)
tests := []struct {
doc string
apiVersion string
contentType string
response string
expected string
}{
{
// Valid [common.ErrorResponse] error, but not using a fixture, to validate current implementation..
doc: "JSON error (non-fixture)",
contentType: "application/json",
response: string(errorResponse),
expected: `Error response from daemon: Some error occurred`,
},
{
// Valid [common.ErrorResponse] error.
doc: "JSON error",
contentType: "application/json",
response: `{"message":"Some error occurred"}`,
expected: `Error response from daemon: Some error occurred`,
},
{
// Valid [common.ErrorResponse] error with additional fields.
doc: "JSON error with extra fields",
contentType: "application/json",
response: `{"message":"Some error occurred", "other_field": "some other field that's not part of common.ErrorResponse"}`,
expected: `Error response from daemon: Some error occurred`,
},
{
// API versions before 1.24 did not support JSON errors. Technically,
// we no longer downgrade to older API versions, but we make an
// exception for errors so that older clients would print a more
// readable error.
doc: "JSON error on old API",
apiVersion: "1.23",
contentType: "text/plain; charset=utf-8",
response: `client version 1.10 is too old. Minimum supported API version is 1.24, please upgrade your client to a newer version`,
expected: `Error response from daemon: client version 1.10 is too old. Minimum supported API version is 1.24, please upgrade your client to a newer version`,
},
{
doc: "plain-text error",
contentType: "text/plain",
response: `Some error occurred`,
expected: `Error response from daemon: Some error occurred`,
},
{
// TODO(thaJeztah): consider returning (partial) raw response for these
doc: "malformed JSON",
contentType: "application/json",
response: `{"message":"Some error occurred`,
expected: `error reading JSON: unexpected end of JSON input`,
},
{
// Server response that's valid JSON, but not the expected [common.ErrorResponse] scheme
doc: "incorrect JSON scheme",
contentType: "application/json",
response: `{"error":"Some error occurred"}`,
expected: `Error response from daemon: API returned a 400 (Bad Request) but provided no error-message`,
},
{
// TODO(thaJeztah): improve handling of such errors; we can return the generic "502 Bad Gateway" instead
doc: "html error",
contentType: "text/html",
response: `<!doctype html>
<html lang="en">
<head>
<title>502 Bad Gateway</title>
</head>
<body>
<h1>Bad Gateway</h1>
<p>The server was unable to complete your request. Please try again later.</p>
<p>If this problem persists, please <a href="https://example.com/support">contact support</a>.</p>
</body>
</html>`,
expected: `Error response from daemon: <!doctype html>
<html lang="en">
<head>
<title>502 Bad Gateway</title>
</head>
<body>
<h1>Bad Gateway</h1>
<p>The server was unable to complete your request. Please try again later.</p>
<p>If this problem persists, please <a href="https://example.com/support">contact support</a>.</p>
</body>
</html>`,
},
{
// TODO(thaJeztah): improve handling of these errors (JSON: invalid character '<' looking for beginning of value)
doc: "html error masquerading as JSON",
contentType: "application/json",
response: `<!doctype html>
<html lang="en">
<head>
<title>502 Bad Gateway</title>
</head>
<body>
<h1>Bad Gateway</h1>
<p>The server was unable to complete your request. Please try again later.</p>
<p>If this problem persists, please <a href="https://example.com/support">contact support</a>.</p>
</body>
</html>`,
expected: `error reading JSON: invalid character '<' looking for beginning of value`,
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) {
return mockResponse(http.StatusBadRequest, http.Header{"Content-Type": []string{tc.contentType}}, tc.response)(req)
}))
if tc.apiVersion != "" {
client, err = New(WithHTTPClient(client.client), WithAPIVersion(tc.apiVersion))
}
assert.NilError(t, err)
_, err = client.Ping(t.Context(), PingOptions{})
assert.Check(t, is.Error(err, tc.expected))
assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
})
}
}
func TestInfiniteError(t *testing.T) {
infinitR := rand.New(rand.NewSource(42))
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) {
resp := &http.Response{
StatusCode: http.StatusInternalServerError,
Header: http.Header{},
Body: io.NopCloser(infinitR),
}
return resp, nil
}))
assert.NilError(t, err)
_, err = client.Ping(t.Context(), PingOptions{})
assert.Check(t, is.ErrorType(err, cerrdefs.IsInternal))
assert.Check(t, is.ErrorContains(err, "request returned Internal Server Error"))
}
func TestCanceledContext(t *testing.T) {
const testEndpoint = "/test"
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.ErrorType(req.Context().Err(), context.Canceled))
return nil, context.Canceled
}))
assert.NilError(t, err)
ctx, cancel := context.WithCancel(t.Context())
cancel()
_, err = client.sendRequest(ctx, http.MethodGet, testEndpoint, nil, nil, nil)
assert.Check(t, is.ErrorIs(err, context.Canceled))
}
func TestDeadlineExceededContext(t *testing.T) {
const testEndpoint = "/test"
client, err := New(WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.ErrorType(req.Context().Err(), context.DeadlineExceeded))
return nil, context.DeadlineExceeded
}))
assert.NilError(t, err)
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
defer cancel()
<-ctx.Done()
_, err = client.sendRequest(ctx, http.MethodGet, testEndpoint, nil, nil, nil)
assert.Check(t, is.ErrorIs(err, context.DeadlineExceeded))
}
func TestPrepareJSONRequest(t *testing.T) {
tests := []struct {
doc string
body any
headers http.Header
expBody string
expHeaders http.Header
}{
{
doc: "nil body",
body: nil,
headers: http.Header{"Something": []string{"something"}},
expBody: "",
expHeaders: http.Header{
// no content-type is set on empty requests.
"Something": []string{"something"},
},
},
{
doc: "nil interface body",
body: (*struct{})(nil),
headers: http.Header{"Something": []string{"something"}},
expBody: "",
expHeaders: http.Header{
// no content-type is set on empty requests.
"Something": []string{"something"},
},
},
{
doc: "empty struct body",
body: &struct{}{},
headers: http.Header{"Something": []string{"something"}},
expBody: `{}`,
expHeaders: http.Header{
"Content-Type": []string{"application/json"},
"Something": []string{"something"},
},
},
{
doc: "json raw message",
body: json.RawMessage("{}"),
expBody: `{}`,
expHeaders: http.Header{
"Content-Type": []string{"application/json"},
},
},
{
doc: "empty json raw message",
body: json.RawMessage(""),
expBody: "",
expHeaders: nil, // no content-type is set on empty requests.
},
{
doc: "empty body",
body: http.NoBody,
expBody: "",
expHeaders: nil, // no content-type is set on empty requests.
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
req, hdr, err := prepareJSONRequest(tc.body, tc.headers)
assert.NilError(t, err)
resp, err := io.ReadAll(req)
assert.NilError(t, err)
body := string(resp)
assert.Check(t, is.Equal(body, tc.expBody))
assert.Check(t, is.DeepEqual(hdr, tc.expHeaders))
assert.Check(t, is.Equal(tc.headers.Get("Content-Type"), ""), "Should not have mutated original headers")
})
}
}