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: `
The server was unable to complete your request. Please try again later.
If this problem persists, please contact support.
`, expected: `Error response from daemon:The server was unable to complete your request. Please try again later.
If this problem persists, please contact support.
`, }, { // 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: `The server was unable to complete your request. Please try again later.
If this problem persists, please contact support.
`, 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(WithBaseMockClient(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(WithBaseMockClient(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") }) } }