Files
moby/client/client_mock_test.go
Sebastiaan van Stijn 701f2fdade client: improve mocking responses
Make the mocked responses match the API closer;

- Add headers as returned by the daemon's VersionMiddleware
- By default handle "/_ping" requests to allow the client to
  perform API-version negotiation as part of tests.

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
2025-11-27 12:05:27 +01:00

191 lines
5.9 KiB
Go

package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strconv"
"strings"
"github.com/moby/moby/api/types/build"
"github.com/moby/moby/api/types/common"
"github.com/moby/moby/api/types/swarm"
)
// defaultAPIPath is the API path prefix for the default API version used.
const defaultAPIPath = "/v" + MaxAPIVersion
// assertRequest checks for the request method and path. If the expected
// path does not contain a version prefix, it is prefixed with the current API
// version.
func assertRequest(req *http.Request, expMethod string, expectedPath string) error {
if !strings.HasPrefix(expectedPath, "/v1.") {
expectedPath = defaultAPIPath + expectedPath
}
if !strings.HasPrefix(req.URL.Path, expectedPath) {
return fmt.Errorf("expected URL '%s', got '%s'", expectedPath, req.URL.Path)
}
if req.Method != expMethod {
return fmt.Errorf("expected %s method, got %s", expMethod, req.Method)
}
return nil
}
func assertRequestWithQuery(req *http.Request, expMethod string, expectedPath string, expectedQuery string) error {
if err := assertRequest(req, expMethod, expectedPath); err != nil {
return err
}
q := req.URL.Query().Encode()
if q != expectedQuery {
return fmt.Errorf("expected query '%s', got '%s'", expectedQuery, q)
}
return nil
}
// ensureBody makes sure the response has a Body (using [http.NoBody] if
// none is present), and that the request is set on the response, then returns
// it as a testRoundTripper.
func ensureBody(f func(req *http.Request) (*http.Response, error)) testRoundTripper {
return func(req *http.Request) (*http.Response, error) {
resp, err := f(req)
if resp != nil {
if resp.Body == nil {
resp.Body = http.NoBody
}
if resp.Request == nil {
resp.Request = req
}
}
return resp, err
}
}
// makeTestRoundTripper makes sure the response has a Body, using [http.NoBody] if
// none is present, and returns it as a testRoundTripper. If withDefaults is set,
// it also mocks the "/_ping" endpoint and sets default headers as returned
// by the daemon.
func makeTestRoundTripper(f func(req *http.Request) (*http.Response, error)) testRoundTripper {
return func(req *http.Request) (*http.Response, error) {
if req.URL.Path == "/_ping" {
return mockPingResponse(http.StatusOK, PingResult{
APIVersion: MaxAPIVersion,
OSType: runtime.GOOS,
Experimental: true,
BuilderVersion: build.BuilderBuildKit,
SwarmStatus: &SwarmStatus{
NodeState: swarm.LocalNodeStateActive,
ControlAvailable: true,
},
})(req)
}
resp, err := f(req)
if resp != nil {
if resp.Body == nil {
resp.Body = http.NoBody
}
if resp.Request == nil {
resp.Request = req
}
}
applyDefaultHeaders(resp)
return resp, err
}
}
// applyDefaultHeaders mocks the headers set by the daemon's VersionMiddleware.
func applyDefaultHeaders(resp *http.Response) {
if resp == nil {
return
}
if resp.Header == nil {
resp.Header = make(http.Header)
}
if resp.Header.Get("Server") == "" {
resp.Header.Set("Server", fmt.Sprintf("Docker/%s (%s)", "v99.99.99", runtime.GOOS))
}
if resp.Header.Get("Api-Version") == "" {
resp.Header.Set("Api-Version", MaxAPIVersion)
}
if resp.Header.Get("Ostype") == "" {
resp.Header.Set("Ostype", runtime.GOOS)
}
}
// WithMockClient is a test helper that allows you to inject a mock client for
// testing. By default, it mocks the "/_ping" endpoint, so allow the client
// to perform API-version negotiation. Other endpoints are handled by "doer".
func WithMockClient(doer func(*http.Request) (*http.Response, error)) Opt {
return WithHTTPClient(&http.Client{
Transport: makeTestRoundTripper(doer),
})
}
// WithBaseMockClient is a test helper that allows you to inject a mock client
// for testing. It is identical to [WithMockClient], but does not mock the "/_ping"
// endpoint, and doesn't set the default headers.
func WithBaseMockClient(doer func(*http.Request) (*http.Response, error)) Opt {
return WithHTTPClient(&http.Client{
Transport: ensureBody(doer),
})
}
func errorMock(statusCode int, message string) func(req *http.Request) (*http.Response, error) {
return mockJSONResponse(statusCode, nil, common.ErrorResponse{
Message: message,
})
}
func mockJSONResponse[T any](statusCode int, headers http.Header, resp T) func(req *http.Request) (*http.Response, error) {
respBody, err := json.Marshal(&resp)
if err != nil {
panic(err)
}
hdr := make(http.Header)
if headers != nil {
hdr = headers.Clone()
}
hdr.Set("Content-Type", "application/json")
return mockResponse(statusCode, hdr, string(respBody))
}
// mockPingResponse mocks the headers set for a "/_ping" response.
func mockPingResponse(statusCode int, ping PingResult) func(req *http.Request) (*http.Response, error) {
headers := http.Header{}
if s := ping.SwarmStatus; s != nil {
role := "worker"
if s.ControlAvailable {
role = "manager"
}
headers.Set("Swarm", fmt.Sprintf("%s/%s", string(swarm.LocalNodeStateActive), role))
}
headers.Set("Api-Version", ping.APIVersion)
headers.Set("Ostype", ping.OSType)
headers.Set("Docker-Experimental", strconv.FormatBool(ping.Experimental))
headers.Set("Builder-Version", string(ping.BuilderVersion))
headers.Set("Content-Type", "text/plain; charset=utf-8")
headers.Set("Cache-Control", "no-cache, no-store, must-revalidate")
headers.Set("Pragma", "no-cache")
return mockResponse(statusCode, headers, "OK")
}
func mockResponse(statusCode int, headers http.Header, respBody string) func(req *http.Request) (*http.Response, error) {
return func(req *http.Request) (*http.Response, error) {
var body io.ReadCloser
if respBody == "" || req.Method == http.MethodHead {
body = http.NoBody
} else {
body = io.NopCloser(strings.NewReader(respBody))
}
return &http.Response{
Status: fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)),
StatusCode: statusCode,
Header: headers.Clone(),
Body: body,
Request: req,
}, nil
}
}