Files
moby/client/request_test.go
Sebastiaan van Stijn 839c2709af client: WithMockClient: match version behavior of actual client
The WithMockClient option was explicitly resetting the client's API
version (see [1]), which differs from the regular client, which is
initialized with the current API version used by the client (see [2]).

This patch:

- reduces the `WithMockClient` to only set the custom HTTP client, leaving
  other fields un-touched.
- adds a test utility and updates tests to handle the API-version prefix
- removes redundant uses of `WithVersion()` in tests; for most test-cases
  it was used to make sure a current API version is used that supports the
  feature being tested, but there was no test to verify the behavior for
  lower API versions, so we may as well test against "latest".

[1]: 5a582729d8/client/client_mock_test.go (L22-L36)
[2]: 5a582729d8/client/client.go (L167-L190)

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-09-18 11:37:56 +02:00

350 lines
11 KiB
Go

package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"strings"
"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 := NewClientWithOpts(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 &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(""))),
}, nil
}), WithHost(tc.host))
assert.NilError(t, err)
_, err = client.sendRequest(context.Background(), http.MethodGet, testEndpoint, nil, nil, nil)
assert.NilError(t, err)
})
}
}
// TestPlainTextError tests the server returning an error in plain text for
// backwards compatibility with API versions <1.24. All other tests use
// errors returned as JSON
func TestPlainTextError(t *testing.T) {
client, err := NewClientWithOpts(WithMockClient(plainTextErrorMock(http.StatusInternalServerError, "Server error")))
assert.NilError(t, err)
_, err = client.ContainerList(context.Background(), 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 := NewClientWithOpts(WithMockClient(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{tc.contentType}},
Body: io.NopCloser(bytes.NewReader([]byte(tc.response))),
}, nil
}))
if tc.apiVersion != "" {
client, err = NewClientWithOpts(WithHTTPClient(client.client), WithVersion(tc.apiVersion))
}
assert.NilError(t, err)
_, err = client.Ping(context.Background())
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 := NewClientWithOpts(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(context.Background())
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 := NewClientWithOpts(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(context.Background())
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 := NewClientWithOpts(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(context.Background(), 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
expNilBody bool
expHeaders http.Header
}{
{
doc: "nil body",
body: nil,
headers: http.Header{"Something": []string{"something"}},
expNilBody: true,
expHeaders: http.Header{
// currently, no content-type is set on empty requests.
"Something": []string{"something"},
},
},
{
doc: "nil interface body",
body: (*struct{})(nil),
headers: http.Header{"Something": []string{"something"}},
expNilBody: true,
expHeaders: http.Header{
// currently, 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 body",
body: http.NoBody,
expBody: `{}`,
expHeaders: http.Header{
"Content-Type": []string{"application/json"},
},
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
req, hdr, err := prepareJSONRequest(tc.body, tc.headers)
assert.NilError(t, err)
var body string
if tc.expNilBody {
assert.Check(t, is.Nil(req))
} else {
assert.Assert(t, req != nil)
resp, err := io.ReadAll(req)
assert.NilError(t, err)
body = strings.TrimSpace(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")
})
}
}