Files
moby/client/client_options_test.go
Cory Snider 9ebbf652bd client: do not modify user-provided HTTP client
The http.Client passed into client.WithHTTPClient() is modified by the
constructor in-place: the value of its Transport field is mutated and
wrapped in an OpenTelemetry decorator. This can lead to very surprising
behaviour when a second client is constructed reusing the same
http.Client value. If the http.Client is configured for TLS, the second
client will fail to detect that and will incorrectly dial the Engine API
socket as cleartext HTTP. Copy the provided http.Client so our
modifications don't leak out to unexpected places.

Signed-off-by: Cory Snider <csnider@mirantis.com>
2026-01-06 18:14:13 -05:00

393 lines
12 KiB
Go

package client
import (
"crypto/tls"
"net/http"
"net/http/cookiejar"
"runtime"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp/cmpopts"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestOptionWithHostFromEnv(t *testing.T) {
c, err := New(WithHostFromEnv())
assert.NilError(t, err)
assert.Check(t, c.client != nil)
assert.Check(t, is.Equal(c.basePath, ""))
if runtime.GOOS == "windows" {
assert.Check(t, is.Equal(c.host, "npipe:////./pipe/docker_engine"))
assert.Check(t, is.Equal(c.proto, "npipe"))
assert.Check(t, is.Equal(c.addr, "//./pipe/docker_engine"))
} else {
assert.Check(t, is.Equal(c.host, "unix:///var/run/docker.sock"))
assert.Check(t, is.Equal(c.proto, "unix"))
assert.Check(t, is.Equal(c.addr, "/var/run/docker.sock"))
}
t.Setenv("DOCKER_HOST", "tcp://foo.example.com:2376/test/")
c, err = New(WithHostFromEnv())
assert.NilError(t, err)
assert.Check(t, c.client != nil)
assert.Check(t, is.Equal(c.basePath, "/test/"))
assert.Check(t, is.Equal(c.host, "tcp://foo.example.com:2376/test/"))
assert.Check(t, is.Equal(c.proto, "tcp"))
assert.Check(t, is.Equal(c.addr, "foo.example.com:2376"))
}
func TestOptionWithTimeout(t *testing.T) {
timeout := 10 * time.Second
c, err := New(WithTimeout(timeout))
assert.NilError(t, err)
assert.Check(t, c.client != nil)
assert.Check(t, is.Equal(c.client.Timeout, timeout))
}
func TestOptionWithAPIVersion(t *testing.T) {
tests := []struct {
doc string
version string
expected string
expError string
}{
{
doc: "empty version",
version: "",
expected: MaxAPIVersion,
},
{
doc: "custom lower version with whitespace, no v-prefix",
version: " 1.50 ",
expected: "1.50",
},
{
// We currently allow downgrading the client to an unsupported lower version for testing.
doc: "downgrade unsupported version, no v-prefix",
version: "1.0",
expected: "1.0",
},
{
doc: "custom lower version, no v-prefix",
version: "1.50",
expected: "1.50",
},
{
// We currently allow upgrading the client to an unsupported higher version for testing.
doc: "upgrade version, no v-prefix",
version: "9.99",
expected: "9.99",
},
{
doc: "empty version, with v-prefix",
version: "v",
expected: MaxAPIVersion,
},
{
doc: "whitespace, with v-prefix",
version: " v1.0 ",
expected: "1.0",
},
{
doc: "downgrade unsupported version, with v-prefix",
version: "v1.0",
expected: "1.0",
},
{
doc: "custom lower version with whitespace and v-prefix",
version: " v1.50 ",
expected: "1.50",
},
{
doc: "custom lower version, with v-prefix",
version: "v1.50",
expected: "1.50",
},
{
doc: "upgrade version, with v-prefix",
version: "v9.99",
expected: "9.99",
},
{
doc: "malformed version",
version: "something-weird",
expError: "invalid API version (something-weird): must be formatted <major>.<minor>",
},
{
doc: "no minor",
version: "1",
expError: "invalid API version (1): must be formatted <major>.<minor>",
},
{
doc: "too many digits",
version: "1.2.3",
expError: "invalid API version (1.2.3): invalid minor version: must be formatted <major>.<minor>",
},
{
doc: "embedded whitespace",
version: "v 1.0",
expError: "invalid API version (v 1.0): invalid major version: must be formatted <major>.<minor>",
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
client, err := New(WithAPIVersion(tc.version))
if tc.expError != "" {
assert.Check(t, is.ErrorContains(err, tc.expError))
assert.Check(t, client == nil)
} else {
assert.NilError(t, err)
assert.Check(t, client != nil)
assert.Check(t, is.Equal(client.ClientVersion(), tc.expected))
isNoOp := strings.TrimPrefix(strings.TrimSpace(tc.version), "v") == ""
assert.Check(t, is.Equal(client.negotiated.Load(), !isNoOp))
}
})
}
}
func TestOptionWithAPIVersionFromEnv(t *testing.T) {
tests := []struct {
doc string
version string
expected string
expError string
}{
{
doc: "empty version",
version: "",
expected: MaxAPIVersion,
},
{
doc: "custom lower version with whitespace, no v-prefix",
version: " 1.50 ",
expected: "1.50",
},
{
// We currently allow downgrading the client to an unsupported lower version for testing.
doc: "downgrade unsupported version, no v-prefix",
version: "1.0",
expected: "1.0",
},
{
doc: "custom lower version, no v-prefix",
version: "1.50",
expected: "1.50",
},
{
// We currently allow upgrading the client to an unsupported higher version for testing.
doc: "upgrade version, no v-prefix",
version: "9.99",
expected: "9.99",
},
{
doc: "empty version, with v-prefix",
version: "v",
expected: MaxAPIVersion,
},
{
doc: "whitespace, with v-prefix",
version: " v1.0 ",
expected: "1.0",
},
{
doc: "downgrade unsupported version, with v-prefix",
version: "v1.0",
expected: "1.0",
},
{
doc: "custom lower version with whitespace and v-prefix",
version: " v1.50 ",
expected: "1.50",
},
{
doc: "custom lower version, with v-prefix",
version: "v1.50",
expected: "1.50",
},
{
doc: "upgrade version, with v-prefix",
version: "v9.99",
expected: "9.99",
},
{
doc: "malformed version",
version: "something-weird",
expError: "invalid API version (something-weird): must be formatted <major>.<minor>",
},
{
doc: "no minor",
version: "1",
expError: "invalid API version (1): must be formatted <major>.<minor>",
},
{
doc: "too many digits",
version: "1.2.3",
expError: "invalid API version (1.2.3): invalid minor version: must be formatted <major>.<minor>",
},
{
doc: "embedded whitespace",
version: "v 1.0",
expError: "invalid API version (v 1.0): invalid major version: must be formatted <major>.<minor>",
},
}
for _, tc := range tests {
t.Run(tc.doc, func(t *testing.T) {
t.Setenv(EnvOverrideAPIVersion, tc.version)
client, err := New(WithAPIVersionFromEnv())
if tc.expError != "" {
assert.Check(t, is.ErrorContains(err, tc.expError))
assert.Check(t, client == nil)
} else {
assert.NilError(t, err)
assert.Check(t, client != nil)
assert.Check(t, is.Equal(client.ClientVersion(), tc.expected))
isNoOp := strings.TrimPrefix(strings.TrimSpace(tc.version), "v") == ""
assert.Check(t, is.Equal(client.negotiated.Load(), !isNoOp))
}
})
}
}
// TestOptionOverridePriority validates that overriding the API version through
// [WithAPIVersionFromEnv] takes precedence over other manual options, regardless
// the order in which they're passed.
func TestOptionOverridePriority(t *testing.T) {
t.Run("no env-var set", func(t *testing.T) {
client, err := New(WithAPIVersionFromEnv(), WithAPIVersion("1.50"))
assert.NilError(t, err)
assert.Check(t, is.Equal(client.ClientVersion(), "1.50"))
assert.Check(t, is.Equal(client.negotiated.Load(), true))
})
const expected = "1.51"
t.Setenv(EnvOverrideAPIVersion, expected)
t.Run("WithAPIVersionFromEnv first", func(t *testing.T) {
client, err := New(WithAPIVersionFromEnv(), WithAPIVersion("1.50"))
assert.NilError(t, err)
assert.Check(t, is.Equal(client.ClientVersion(), expected))
assert.Check(t, is.Equal(client.negotiated.Load(), true))
})
t.Run("WithAPIVersionFromEnv last", func(t *testing.T) {
client, err := New(WithAPIVersion("1.50"), WithAPIVersionFromEnv())
assert.NilError(t, err)
assert.Check(t, is.Equal(client.ClientVersion(), expected))
assert.Check(t, is.Equal(client.negotiated.Load(), true))
})
t.Run("FromEnv first", func(t *testing.T) {
client, err := New(FromEnv, WithAPIVersion("1.50"))
assert.NilError(t, err)
assert.Check(t, is.Equal(client.ClientVersion(), expected))
assert.Check(t, is.Equal(client.negotiated.Load(), true))
})
t.Run("FromEnv last", func(t *testing.T) {
client, err := New(WithAPIVersion("1.50"), FromEnv)
assert.NilError(t, err)
assert.Check(t, is.Equal(client.ClientVersion(), expected))
assert.Check(t, is.Equal(client.negotiated.Load(), true))
})
}
func TestWithUserAgent(t *testing.T) {
const userAgent = "Magic-Client/v1.2.3"
t.Run("user-agent", func(t *testing.T) {
c, err := New(
WithUserAgent(userAgent),
WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.Equal(req.Header.Get("User-Agent"), userAgent))
return &http.Response{StatusCode: http.StatusOK}, nil
}),
)
assert.NilError(t, err)
_, err = c.Ping(t.Context(), PingOptions{})
assert.NilError(t, err)
assert.NilError(t, c.Close())
})
t.Run("user-agent and custom headers", func(t *testing.T) {
c, err := New(
WithUserAgent(userAgent),
WithHTTPHeaders(map[string]string{"User-Agent": "should-be-ignored/1.0.0", "Other-Header": "hello-world"}),
WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.Equal(req.Header.Get("User-Agent"), userAgent))
assert.Check(t, is.Equal(req.Header.Get("Other-Header"), "hello-world"))
return &http.Response{StatusCode: http.StatusOK}, nil
}),
)
assert.NilError(t, err)
_, err = c.Ping(t.Context(), PingOptions{})
assert.NilError(t, err)
assert.NilError(t, c.Close())
})
t.Run("custom headers", func(t *testing.T) {
c, err := New(
WithHTTPHeaders(map[string]string{"User-Agent": "from-custom-headers/1.0.0", "Other-Header": "hello-world"}),
WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.Equal(req.Header.Get("User-Agent"), "from-custom-headers/1.0.0"))
assert.Check(t, is.Equal(req.Header.Get("Other-Header"), "hello-world"))
return &http.Response{StatusCode: http.StatusOK}, nil
}),
)
assert.NilError(t, err)
_, err = c.Ping(t.Context(), PingOptions{})
assert.NilError(t, err)
assert.NilError(t, c.Close())
})
t.Run("no user-agent set", func(t *testing.T) {
c, err := New(
WithHTTPHeaders(map[string]string{"Other-Header": "hello-world"}),
WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.Equal(req.Header.Get("User-Agent"), ""))
assert.Check(t, is.Equal(req.Header.Get("Other-Header"), "hello-world"))
return &http.Response{StatusCode: http.StatusOK}, nil
}),
)
assert.NilError(t, err)
_, err = c.Ping(t.Context(), PingOptions{})
assert.NilError(t, err)
assert.NilError(t, c.Close())
})
t.Run("reset custom user-agent", func(t *testing.T) {
c, err := New(
WithUserAgent(""),
WithHTTPHeaders(map[string]string{"User-Agent": "from-custom-headers/1.0.0", "Other-Header": "hello-world"}),
WithMockClient(func(req *http.Request) (*http.Response, error) {
assert.Check(t, is.Equal(req.Header.Get("User-Agent"), ""))
assert.Check(t, is.Equal(req.Header.Get("Other-Header"), "hello-world"))
return &http.Response{StatusCode: http.StatusOK}, nil
}),
)
assert.NilError(t, err)
_, err = c.Ping(t.Context(), PingOptions{})
assert.NilError(t, err)
assert.NilError(t, c.Close())
})
}
func TestWithHTTPClient(t *testing.T) {
cookieJar, err := cookiejar.New(nil)
assert.NilError(t, err)
pristineHTTPClient := func() *http.Client {
return &http.Client{
Timeout: 42 * time.Second,
Jar: cookieJar,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{ServerName: "example.com", MinVersion: tls.VersionTLS12},
},
}
}
hc := pristineHTTPClient()
_, err = New(WithHTTPClient(hc), WithHost("tcp://example.com:443"))
assert.NilError(t, err)
assert.DeepEqual(t, hc, pristineHTTPClient(),
cmpopts.IgnoreUnexported(http.Transport{}, tls.Config{}),
cmpopts.EquateComparable(&cookiejar.Jar{}))
}