Files
package-updater/internal/master/registry/registry_test.go
2026-10-13 07:02:34 +00:00

337 lines
11 KiB
Go

package registry
import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
ggcrregistry "github.com/google/go-containerregistry/pkg/registry"
"github.com/google/go-containerregistry/pkg/v1/random"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var discardLog = slog.New(slog.NewTextHandler(io.Discard, nil))
func newClient() *Client { return New("test", discardLog) }
// request is one request the test registry received.
type request struct{ method, path string }
// testRegistry is an in-memory registry that records every request.
type testRegistry struct {
srv *httptest.Server
host string // "127.0.0.1:port", the registry part of a ref
mu sync.Mutex
requests []request
}
// newTestRegistry starts a registry. When user is set every request needs
// those basic-auth credentials.
func newTestRegistry(t *testing.T, user, pass string) *testRegistry {
t.Helper()
backend := ggcrregistry.New(ggcrregistry.Logger(log.New(io.Discard, "", 0)))
tr := &testRegistry{}
tr.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tr.mu.Lock()
tr.requests = append(tr.requests, request{r.Method, r.URL.Path})
tr.mu.Unlock()
if user != "" {
if u, p, ok := r.BasicAuth(); !ok || u != user || p != pass {
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
}
backend.ServeHTTP(w, r)
}))
t.Cleanup(tr.srv.Close)
tr.host = strings.TrimPrefix(tr.srv.URL, "http://")
return tr
}
// pushIndex stores a random multi-arch index as repo:tag and returns its
// digest. user and pass are used when the registry requires them.
func (tr *testRegistry) pushIndex(t *testing.T, repo, tag string, opts ...remote.Option) string {
t.Helper()
idx, err := random.Index(64, 1, 2)
require.NoError(t, err)
ref, err := name.ParseReference(tr.host + "/" + repo + ":" + tag)
require.NoError(t, err)
require.NoError(t, remote.WriteIndex(ref, idx, opts...))
d, err := idx.Digest()
require.NoError(t, err)
return d.String()
}
// manifestRequests returns the requests that touched a manifest.
func (tr *testRegistry) manifestRequests() []request {
tr.mu.Lock()
defer tr.mu.Unlock()
var out []request
for _, r := range tr.requests {
if strings.Contains(r.path, "/manifests/") {
out = append(out, r)
}
}
return out
}
// reset forgets the requests received so far.
func (tr *testRegistry) reset() {
tr.mu.Lock()
defer tr.mu.Unlock()
tr.requests = nil
}
// dockerConfig points DOCKER_CONFIG at a fresh directory holding config.json
// with the given content; "" leaves the directory empty.
func dockerConfig(t *testing.T, configJSON string) {
t.Helper()
dir := t.TempDir()
if configJSON != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o600))
}
t.Setenv("DOCKER_CONFIG", dir)
}
func TestDigestOfIndexIsIndexDigest(t *testing.T) {
dockerConfig(t, "")
reg := newTestRegistry(t, "", "")
want := reg.pushIndex(t, "team/app", "1.2")
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestDigestIgnoresDigestSuffix(t *testing.T) {
dockerConfig(t, "")
reg := newTestRegistry(t, "", "")
want := reg.pushIndex(t, "team/app", "1.2")
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2@sha256:"+strings.Repeat("0", 64))
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestDigestUsesHeadOnly(t *testing.T) {
dockerConfig(t, "")
reg := newTestRegistry(t, "", "")
reg.pushIndex(t, "team/app", "1.2")
reg.reset()
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
require.NoError(t, err)
assert.Equal(t, []request{{http.MethodHead, "/v2/team/app/manifests/1.2"}}, reg.manifestRequests())
for _, r := range reg.requests {
if !strings.Contains(r.path, "/manifests/") {
assert.Equal(t, request{http.MethodGet, "/v2/"}, r, "the only other request is the API ping")
}
}
}
func TestDigestSendsUserAgent(t *testing.T) {
dockerConfig(t, "")
var ua string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ua = r.Header.Get("User-Agent")
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
_, _ = New("package-updater-test/1", discardLog).Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
assert.Contains(t, ua, "package-updater-test/1")
}
func TestCredentialsFromDockerConfig(t *testing.T) {
reg := newTestRegistry(t, "bot", "s3cret")
want := reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
auth := base64.StdEncoding.EncodeToString([]byte("bot:s3cret"))
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
got, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestWrongCredentialsFail(t *testing.T) {
reg := newTestRegistry(t, "bot", "s3cret")
reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
auth := base64.StdEncoding.EncodeToString([]byte("bot:wrong"))
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
require.Error(t, err)
assert.NotContains(t, err.Error(), "wrong", "credentials do not leak into errors")
assert.NotContains(t, err.Error(), auth)
}
func TestAnonymousWithoutConfig(t *testing.T) {
dockerConfig(t, "")
open := newTestRegistry(t, "", "")
want := open.pushIndex(t, "team/app", "1.2")
protected := newTestRegistry(t, "bot", "s3cret")
got, err := newClient().Digest(context.Background(), open.host+"/team/app:1.2")
require.NoError(t, err)
assert.Equal(t, want, got, "anonymous access works without a config file")
_, err = newClient().Digest(context.Background(), protected.host+"/team/app:1.2")
assert.Error(t, err, "a registry that needs credentials fails without them")
}
func TestUnknownTagIsAnError(t *testing.T) {
dockerConfig(t, "")
reg := newTestRegistry(t, "", "")
reg.pushIndex(t, "team/app", "1.2")
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:nope")
assert.Error(t, err)
}
func TestRefWithoutTagIsAnError(t *testing.T) {
_, err := newClient().Digest(context.Background(), "team/app@sha256:"+strings.Repeat("a", 64))
assert.ErrorContains(t, err, "has no tag")
}
// A config.json whose credsStore names a helper that is not installed makes
// the lookup fail for every registry, including ones that would work
// anonymously. The master container has no credential helpers, so only
// inline "auths" entries are supported there.
func TestCredsStoreWithoutHelperFailsTheLookup(t *testing.T) {
reg := newTestRegistry(t, "", "")
reg.pushIndex(t, "team/app", "1.2")
dockerConfig(t, `{"credsStore":"no-such-helper-for-package-updater-tests"}`)
_, err := newClient().Digest(context.Background(), reg.host+"/team/app:1.2")
assert.ErrorContains(t, err, "error getting credentials")
assert.ErrorContains(t, err, "docker-credential-no-such-helper-for-package-updater-tests")
}
// A registry that leaves out Docker-Content-Digest must not make the client
// retry with a manifest download, which Docker Hub counts as a pull.
func TestMissingDigestHeaderDoesNotFallBackToDownload(t *testing.T) {
dockerConfig(t, "")
var mu sync.Mutex
var methods []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/manifests/") {
return
}
mu.Lock()
methods = append(methods, r.Method)
mu.Unlock()
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
w.Header().Set("Content-Length", "10")
}))
t.Cleanup(srv.Close)
_, err := newClient().Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
assert.Error(t, err)
assert.Equal(t, []string{http.MethodHead}, methods)
}
// ggcr copies up to 64 KiB of the registry's response into its errors. The
// registry host comes from an image name, so that text must not reach the
// stored poll error.
func TestRegistryErrorBodyDoesNotLeak(t *testing.T) {
dockerConfig(t, "")
tests := []struct {
name string
body string
want string
}{
{"raw body", "internal-secret-body", "registry answered 400 Bad Request"},
{"structured errors", `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"internal-secret-body","detail":"internal-secret-body"},{"code":"internal-secret-body"}]}`,
"registry answered 400 Bad Request (MANIFEST_UNKNOWN)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// HEAD answers carry no body, so the leak is in the GET /v2/ ping.
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(tt.body))
}))
t.Cleanup(srv.Close)
host := strings.TrimPrefix(srv.URL, "http://")
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
require.Error(t, err)
assert.NotContains(t, err.Error(), "internal-secret-body")
assert.Equal(t, "lookup "+host+"/a/b:1: "+tt.want, strings.Replace(err.Error(), "index.docker.io/", "", 1))
})
}
}
// ggcr puts the token server's answer into its error when the answer has no
// token, and the token server is named by the registry's challenge.
func TestNonRegistryErrorsAreFixedText(t *testing.T) {
dockerConfig(t, "")
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"detail":"internal-secret-body"}`))
}))
t.Cleanup(tokenSrv.Close)
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenSrv.URL+`/internal-secret-realm",service="internal-secret-service"`)
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(reg.Close)
host := strings.TrimPrefix(reg.URL, "http://")
_, err := newClient().Digest(context.Background(), host+"/a/b:1")
require.Error(t, err)
assert.NotContains(t, err.Error(), "internal-secret")
assert.Equal(t, "lookup "+host+"/a/b:1: lookup failed", err.Error())
}
func TestClassify(t *testing.T) {
refused := &url.Error{Op: "Get", URL: "http://internal-secret-host/v2/", Err: errors.New("connection refused")}
tests := []struct {
name string
err error
want string
}{
{"deadline", fmt.Errorf("x: %w", context.DeadlineExceeded), "timeout"},
{"canceled", context.Canceled, "timeout"},
{"url error", refused, "cannot reach registry"},
{"net error", &net.DNSError{Name: "internal-secret-host"}, "cannot reach registry"},
{"deadline inside url error", &url.Error{Op: "Get", URL: "http://x", Err: context.DeadlineExceeded}, "timeout"},
{"anything else", errors.New("internal-secret"), "lookup failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, classify(tt.err))
})
}
}