package client import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/pkg/fips" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" "github.com/stretchr/testify/require" ) func TestHttpClient(t *testing.T) { t.Parallel() fips.InitFIPS(false) // Valid TLS configuration endpoint := &portainer.Endpoint{} endpoint.TLSConfig = portainer.TLSConfiguration{TLS: true} cli, err := httpClient(endpoint, nil) require.NoError(t, err) require.NotNil(t, cli) // Invalid TLS configuration endpoint.TLSConfig.TLSCertPath = "/invalid/path/client.crt" endpoint.TLSConfig.TLSKeyPath = "/invalid/path/client.key" cli, err = httpClient(endpoint, nil) require.Error(t, err) require.Nil(t, cli) } // startRecord captures the request made to POST /containers/{id}/start. type startRecord struct { path string body []byte } // newDockerStub returns an httptest server that mimics a Docker daemon // advertising apiVersion, and records the request made to the container start // endpoint into rec. func newDockerStub(t *testing.T, apiVersion string, rec *startRecord) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/_ping"): w.Header().Set("Api-Version", apiVersion) w.Header().Set("Ostype", "linux") w.WriteHeader(http.StatusOK) case strings.HasSuffix(r.URL.Path, "/start"): body, _ := io.ReadAll(r.Body) rec.path = r.URL.Path rec.body = body w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("{}")) } })) t.Cleanup(srv.Close) return srv } // TestNegotiateWithFloor_ModernDaemon verifies that against a daemon // advertising a modern API version (>= 1.24) the client negotiates a modern // version and issues POST /containers/{id}/start with an EMPTY body — the // behavior required by API >= 1.24 (regression test for issue #34). func TestNegotiateWithFloor_ModernDaemon(t *testing.T) { t.Parallel() var rec startRecord srv := newDockerStub(t, "1.54", &rec) opts := []client.Opt{ client.WithHost(srv.URL), client.WithAPIVersionNegotiation(), } cli, err := negotiateWithFloor(opts, negotiateTimeout) require.NoError(t, err) t.Cleanup(func() { _ = cli.Close() }) // Negotiation resolved a concrete, modern version (capped at the client's // maximum supported version). require.NotEmpty(t, cli.ClientVersion()) require.False(t, versions.LessThan(cli.ClientVersion(), minSupportedAPIVersion), "effective API version %q must be >= %q", cli.ClientVersion(), minSupportedAPIVersion) err = cli.ContainerStart(context.Background(), "abc123", dockercontainer.StartOptions{}) require.NoError(t, err) // The request must be versioned and carry no HostConfig body. require.Contains(t, rec.path, "/v"+cli.ClientVersion()+"/") require.Empty(t, rec.body, "start request body must be empty on API >= 1.24") } // TestNegotiateWithFloor_BelowFloorDaemon verifies the floor branch: a daemon // that advertises an API version below the supported floor causes negotiation // to resolve a legacy version, and the client must then be pinned at // modernAPIVersionFloor instead of issuing requests below the supported floor. func TestNegotiateWithFloor_BelowFloorDaemon(t *testing.T) { t.Parallel() var rec startRecord // 1.20 is below minSupportedAPIVersion (1.24); negotiation would downgrade // the client to it, which is exactly what must NOT be used for requests. srv := newDockerStub(t, "1.20", &rec) opts := []client.Opt{ client.WithHost(srv.URL), client.WithAPIVersionNegotiation(), } cli, err := negotiateWithFloor(opts, negotiateTimeout) require.NoError(t, err) t.Cleanup(func() { _ = cli.Close() }) // The client is pinned at the modern floor, not the legacy 1.20. require.Equal(t, modernAPIVersionFloor, cli.ClientVersion()) require.False(t, versions.LessThan(cli.ClientVersion(), minSupportedAPIVersion), "floor version %q must be >= %q", cli.ClientVersion(), minSupportedAPIVersion) } // TestNegotiateWithFloor_UnreachableDaemon verifies that when the daemon is // unreachable at construction (negotiation cannot ping it) the client still // resolves to an effective API version >= the supported floor, never a legacy // default. func TestNegotiateWithFloor_UnreachableDaemon(t *testing.T) { t.Parallel() opts := []client.Opt{ // Unreachable daemon: connection is refused immediately. client.WithHost("tcp://127.0.0.1:1"), client.WithAPIVersionNegotiation(), } cli, err := negotiateWithFloor(opts, 2*time.Second) require.NoError(t, err) t.Cleanup(func() { _ = cli.Close() }) require.NotEmpty(t, cli.ClientVersion()) require.False(t, versions.LessThan(cli.ClientVersion(), minSupportedAPIVersion), "effective API version %q must be >= %q", cli.ClientVersion(), minSupportedAPIVersion) }