diff --git a/pkg/client/selfhosted/errors/errors.go b/pkg/client/selfhosted/errors/errors.go index 9da9aa51..4a194ef0 100644 --- a/pkg/client/selfhosted/errors/errors.go +++ b/pkg/client/selfhosted/errors/errors.go @@ -1,7 +1,5 @@ package errors -import "fmt" - type HTTPError struct { Body []byte StatusCode int @@ -15,7 +13,7 @@ func NewHTTPError(statusCode int, body []byte) *HTTPError { } func (h *HTTPError) Error() string { - return fmt.Sprintf("%s", h.Body) + return string(h.Body) } func IsHTTPError(err error) (*HTTPError, bool) { diff --git a/pkg/client/selfhosted/path_test.go b/pkg/client/selfhosted/path_test.go index b78f6f13..6824427f 100644 --- a/pkg/client/selfhosted/path_test.go +++ b/pkg/client/selfhosted/path_test.go @@ -115,7 +115,6 @@ func TestRepoImage(t *testing.T) { t.Errorf("%s: unexpected repo/image, exp=%s/%s got=%s/%s", test.path, test.expRepo, test.expImage, repo, image) } - }) } } diff --git a/pkg/client/selfhosted/selfhosted.go b/pkg/client/selfhosted/selfhosted.go index da891638..b8361919 100644 --- a/pkg/client/selfhosted/selfhosted.go +++ b/pkg/client/selfhosted/selfhosted.go @@ -85,47 +85,72 @@ func New(ctx context.Context, log *logrus.Entry, opts *Options) (*Client, error) log: log.WithField("client", opts.Host), } - // Set up client with host matching if set - if opts.Host != "" { - hostRegex, scheme, err := parseURL(opts.Host) - if err != nil { - return nil, fmt.Errorf("failed parsing url: %s", err) - } - client.hostRegex = hostRegex - client.httpScheme = scheme + if err := configureHost(ctx, client, opts); err != nil { + return nil, err + } - // Setup Auth if username and password used. - if len(opts.Username) > 0 || len(opts.Password) > 0 { - if len(opts.Bearer) > 0 { - return nil, errors.New("cannot specify Bearer token as well as username/password") - } + if err := configureTLS(client, opts); err != nil { + return nil, err + } - tokenPath := opts.TokenPath - if tokenPath == "" { - tokenPath = defaultTokenPath - } + return client, nil +} - token, err := client.setupBasicAuth(ctx, opts.Host, tokenPath) - if httpErr, ok := selfhostederrors.IsHTTPError(err); ok { - return nil, fmt.Errorf("failed to setup token auth (%d): %s", - httpErr.StatusCode, httpErr.Body) - } +func configureHost(ctx context.Context, client *Client, opts *Options) error { + if opts.Host == "" { + return nil + } - if err != nil { - return nil, fmt.Errorf("failed to setup token auth: %s", err) - } - client.Bearer = token - } + hostRegex, scheme, err := parseURL(opts.Host) + if err != nil { + return fmt.Errorf("failed parsing url: %s", err) + } + client.hostRegex = hostRegex + client.httpScheme = scheme + + if err := configureAuth(ctx, client, opts); err != nil { + return err } - // Default to https if no scheme set + return nil +} + +func configureAuth(ctx context.Context, client *Client, opts *Options) error { + if len(opts.Username) == 0 && len(opts.Password) == 0 { + return nil + } + + if len(opts.Bearer) > 0 { + return errors.New("cannot specify Bearer token as well as username/password") + } + + tokenPath := opts.TokenPath + if tokenPath == "" { + tokenPath = defaultTokenPath + } + + token, err := client.setupBasicAuth(ctx, opts.Host, tokenPath) + if httpErr, ok := selfhostederrors.IsHTTPError(err); ok { + return fmt.Errorf("failed to setup token auth (%d): %s", + httpErr.StatusCode, httpErr.Body) + } + if err != nil { + return fmt.Errorf("failed to setup token auth: %s", err) + } + + client.Bearer = token + return nil +} + +func configureTLS(client *Client, opts *Options) error { if client.httpScheme == "" { client.httpScheme = "https" } + if client.httpScheme == "https" { tlsConfig, err := newTLSConfig(opts.Insecure, opts.CAPath) if err != nil { - return nil, err + return err } client.Client.Transport = &http.Transport{ @@ -134,7 +159,7 @@ func New(ctx context.Context, log *logrus.Entry, opts *Options) (*Client, error) } } - return client, nil + return nil } // Name returns the name of the host URL for the selfhosted client @@ -229,11 +254,13 @@ func (c *Client) doRequest(ctx context.Context, url, header string, obj interfac if err != nil { return nil, fmt.Errorf("failed to get docker image: %s", err) } + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, selfhostederrors.NewHTTPError(resp.StatusCode, body) @@ -268,6 +295,7 @@ func (c *Client) setupBasicAuth(ctx context.Context, url, tokenPath string) (str return "", fmt.Errorf("failed to send basic auth request %q: %s", req.URL, err) } + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { @@ -296,7 +324,7 @@ func newTLSConfig(insecure bool, CAPath string) (*tls.Config, error) { if CAPath != "" { certs, err := os.ReadFile(CAPath) if err != nil { - return nil, fmt.Errorf("Failed to append %q to RootCAs: %v", CAPath, err) + return nil, fmt.Errorf("failed to append %q to RootCAs: %v", CAPath, err) } rootCAs.AppendCertsFromPEM(certs) } diff --git a/pkg/client/selfhosted/selfhosted_test.go b/pkg/client/selfhosted/selfhosted_test.go new file mode 100644 index 00000000..e2ff7149 --- /dev/null +++ b/pkg/client/selfhosted/selfhosted_test.go @@ -0,0 +1,359 @@ +package selfhosted + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/jetstack/version-checker/pkg/api" + selfhostederrors "github.com/jetstack/version-checker/pkg/client/selfhosted/errors" +) + +func TestNew(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + ctx := context.Background() + + t.Run("successful client creation with username and password", func(t *testing.T) { + opts := &Options{ + Host: "https://testregistry.com", + Username: "testuser", + Password: "testpass", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v2/token", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"testtoken"}`)) + })) + defer server.Close() + + opts.Host = server.URL + client, err := New(ctx, log, opts) + + assert.NoError(t, err) + assert.Equal(t, "testtoken", client.Bearer) + }) + + t.Run("error on invalid URL", func(t *testing.T) { + opts := &Options{ + Host: "://invalid-url", + } + + client, err := New(ctx, log, opts) + + assert.Nil(t, client) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed parsing url") + }) + + t.Run("error on username/password and bearer token both specified", func(t *testing.T) { + opts := &Options{ + Host: "https://testregistry.com", + Username: "testuser", + Password: "testpass", + Bearer: "testtoken", + } + + client, err := New(ctx, log, opts) + + assert.Nil(t, client) + assert.EqualError(t, err, "cannot specify Bearer token as well as username/password") + }) + + t.Run("successful client creation with bearer token", func(t *testing.T) { + opts := &Options{ + Host: "https://testregistry.com", + Bearer: "testtoken", + } + + client, err := New(ctx, log, opts) + + assert.NoError(t, err) + assert.Equal(t, "testtoken", client.Bearer) + }) + + t.Run("error on invalid CA path", func(t *testing.T) { + opts := &Options{ + Host: "https://testregistry.com", + CAPath: "invalid/path", + Insecure: true, + } + + client, err := New(ctx, log, opts) + + assert.Nil(t, client) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to append") + }) +} + +func TestName(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + client := &Client{ + Options: &Options{ + Host: "testhost", + }, + log: log, + } + + assert.Equal(t, "testhost", client.Name()) + + client.Options.Host = "" + assert.Equal(t, "dockerapi", client.Name()) +} + +func TestTags(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + ctx := context.Background() + + t.Run("successful Tags fetch", func(t *testing.T) { + client := &Client{ + Client: &http.Client{}, + log: log, + Options: &Options{ + Host: "testregistry.com", + }, + httpScheme: "http", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/repo/image/tags/list": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"tags":["v1.0.0","v2.0.0"]}`)) + case "/v2/repo/image/manifests/v1.0.0": + w.Header().Add("Docker-Content-Digest", "sha256:abcdef") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"architecture":"amd64","history":[{"v1Compatibility":"{\"created\":\"2023-08-27T12:00:00Z\"}"}]}`)) + case "/v2/repo/image/manifests/v2.0.0": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) // Write some blank content + } + })) + defer server.Close() + + h, err := url.Parse(server.URL) + assert.NoError(t, err) + + tags, err := client.Tags(ctx, h.Host, "repo", "image") + + assert.NoError(t, err) + assert.Len(t, tags, 2) + assert.Equal(t, "v1.0.0", tags[0].Tag) + assert.Equal(t, api.Architecture("amd64"), tags[0].Architecture) + assert.Equal(t, "sha256:abcdef", tags[0].SHA) + assert.Equal(t, "v2.0.0", tags[1].Tag) + }) + + t.Run("error fetching tags", func(t *testing.T) { + client := &Client{ + Client: &http.Client{}, + log: log, + Options: &Options{ + Host: "https://testregistry.com", + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + tags, err := client.Tags(ctx, server.URL, "repo", "image") + assert.Nil(t, tags) + assert.Error(t, err) + }) +} + +func TestDoRequest(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + ctx := context.Background() + + client := &Client{ + Client: &http.Client{}, + Options: &Options{ + Host: "testhost", + }, + log: log, + httpScheme: "http", + } + + t.Run("successful request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v2/repo/image/tags/list", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"tags":["v1","v2"]}`)) + })) + defer server.Close() + + h, err := url.Parse(server.URL) + assert.NoError(t, err) + + var tagResponse TagResponse + headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) + + assert.NoError(t, err) + assert.NotNil(t, headers) + assert.Equal(t, []string{"v1", "v2"}, tagResponse.Tags) + }) + + t.Run("error on non-200 status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + h, err := url.Parse(server.URL) + assert.NoError(t, err) + + var tagResponse TagResponse + headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) + + assert.Nil(t, headers) + assert.Error(t, err) + var httpErr *selfhostederrors.HTTPError + if errors.As(err, &httpErr) { + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) + assert.Equal(t, "not found", string(httpErr.Body)) + } + }) + + t.Run("error on invalid json response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("invalid json")) + })) + defer server.Close() + + h, err := url.Parse(server.URL) + assert.NoError(t, err) + + var tagResponse TagResponse + headers, err := client.doRequest(ctx, h.Host+"/v2/repo/image/tags/list", "", &tagResponse) + + assert.Nil(t, headers) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected") + }) +} + +func TestSetupBasicAuth(t *testing.T) { + log := logrus.NewEntry(logrus.New()) + ctx := context.Background() + + client := &Client{ + Client: &http.Client{}, + Options: &Options{ + Username: "testuser", + Password: "testpass", + }, + log: log, + } + + t.Run("successful auth setup", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v2/token", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"testtoken"}`)) + })) + defer server.Close() + + token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") + assert.NoError(t, err) + assert.Equal(t, "testtoken", token) + }) + + t.Run("error on invalid json response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("invalid json")) + })) + defer server.Close() + + token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") + assert.Empty(t, token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid character") + }) + + t.Run("error on non-200 status code", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + })) + defer server.Close() + + token, err := client.setupBasicAuth(ctx, server.URL, "/v2/token") + assert.Empty(t, token) + assert.Error(t, err) + var httpErr *selfhostederrors.HTTPError + if errors.As(err, &httpErr) { + assert.Equal(t, http.StatusUnauthorized, httpErr.StatusCode) + assert.Equal(t, "unauthorized", string(httpErr.Body)) + } + }) + + t.Run("error on request creation failure", func(t *testing.T) { + client := &Client{ + Client: &http.Client{}, + Options: &Options{ + Username: "testuser", + Password: "testpass", + }, + log: log, + } + + token, err := client.setupBasicAuth(ctx, "localhost:999999", "/v2/token") + assert.Empty(t, token) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to send basic auth request") + }) +} + +func TestNewTLSConfig(t *testing.T) { + t.Run("successful TLS config creation with valid CA path", func(t *testing.T) { + caFile, err := os.CreateTemp("", "ca.pem") + assert.NoError(t, err) + defer os.Remove(caFile.Name()) + + _, err = caFile.WriteString(`-----BEGIN CERTIFICATE----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwf3Kq/BnEePvM6rSGPP6 +6uUbzIAdx0+EjHRJ1yqCqk8MzY+m5OncEjgpG0FDDpdqYPOUE4EzjjIlNInxG8Vi +DfWmi8csEQYrtyNzzlF+bWwWv/1U+UuRgZqtwFZxC4DLIE1Bke4isr7g91DU5B8G +b+6eGHjql0zPz9bL7s5er8kpDp1o6ZZtGPE3F18LPS48pZyRIN/T4vPz4uA/Zay/ +aEB8E+yoI8dw48LUVZDjDN3mthBb8k68ngLqBaIgF+1EQpe2I1a/nZBQTu9yn8Z1 +Y7nG8XdxKAr5e+CZ8x8NUvydF1DZDSV1Mf1GriMEwLkA5P4oY8EbOxDJTuJrAXjZ +tQIDAQAB +-----END CERTIFICATE-----`) + assert.NoError(t, err) + err = caFile.Close() + assert.NoError(t, err) + + tlsConfig, err := newTLSConfig(false, caFile.Name()) + assert.NoError(t, err) + assert.NotNil(t, tlsConfig) + assert.False(t, tlsConfig.InsecureSkipVerify) + }) + + t.Run("successful TLS config creation with empty CA path", func(t *testing.T) { + tlsConfig, err := newTLSConfig(true, "") + assert.NoError(t, err) + assert.NotNil(t, tlsConfig) + assert.True(t, tlsConfig.InsecureSkipVerify) + }) + + t.Run("error on invalid CA path", func(t *testing.T) { + tlsConfig, err := newTLSConfig(false, "/invalid/path") + assert.Nil(t, tlsConfig) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to append") + }) +}