mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
773 lines
25 KiB
Go
773 lines
25 KiB
Go
package dockeragent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
type errReadCloser struct {
|
|
err error
|
|
}
|
|
|
|
func (e errReadCloser) Read(_ []byte) (int, error) {
|
|
return 0, e.err
|
|
}
|
|
|
|
func (e errReadCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func newStringResponse(status int, headers map[string]string, body string) *http.Response {
|
|
resp := &http.Response{
|
|
StatusCode: status,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}
|
|
for key, value := range headers {
|
|
resp.Header.Set(key, value)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func TestRegistryChecker_CheckImageUpdate_CacheHits(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
|
|
t.Run("cached error", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
cacheKey := "example.test/repo:tag|//"
|
|
checker.cacheError(cacheKey, "cached error")
|
|
|
|
result := checker.CheckImageUpdate(context.Background(), "example.test/repo:tag", "sha256:current", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result for cached error")
|
|
}
|
|
if result.Error != "cached error" {
|
|
t.Errorf("Expected cached error, got %q", result.Error)
|
|
}
|
|
if result.UpdateAvailable {
|
|
t.Error("Expected no update when cached error is present")
|
|
}
|
|
})
|
|
|
|
t.Run("cached digest", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
cacheKey := "example.test/repo:tag|//"
|
|
checker.cacheDigest(cacheKey, "sha256:latest")
|
|
|
|
result := checker.CheckImageUpdate(context.Background(), "example.test/repo:tag", "sha256:current", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result for cached digest")
|
|
}
|
|
if result.LatestDigest != "sha256:latest" {
|
|
t.Errorf("Expected latest digest sha256:latest, got %q", result.LatestDigest)
|
|
}
|
|
if !result.UpdateAvailable {
|
|
t.Error("Expected update available for cached digest")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_CacheError_RateLimitBacksOffLonger(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
checker := NewRegistryChecker(logger)
|
|
|
|
checker.cacheError("limited", "rate limited")
|
|
checker.cacheError("transient", "registry error: 502")
|
|
|
|
checker.cache.mu.RLock()
|
|
limited := checker.cache.entries["limited"]
|
|
transient := checker.cache.entries["transient"]
|
|
checker.cache.mu.RUnlock()
|
|
|
|
// A refused HEAD still counts against the registry's allowance, so
|
|
// rate-limited lookups must back off well past the transient-error TTL.
|
|
if !limited.expiresAt.After(time.Now().Add(errorCacheTTL)) {
|
|
t.Errorf("Expected rate-limited entry to outlive the transient error TTL, expires %v", limited.expiresAt)
|
|
}
|
|
if transient.expiresAt.After(time.Now().Add(errorCacheTTL)) {
|
|
t.Errorf("Expected transient error entry to use the short TTL, expires %v", transient.expiresAt)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_CheckImageUpdate_ProBrokerRegistrySkipped(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
checker := NewRegistryChecker(logger)
|
|
|
|
// The entitled Pro registry requires a license credential the agent does
|
|
// not hold, so the checker must report nothing (no badge) instead of a
|
|
// permanent "authentication required" error on the Pulse Pro container.
|
|
result := checker.CheckImageUpdate(context.Background(), "license.pulserelay.pro/pulse-pro:6.1.1", "sha256:current", "amd64", "linux", "")
|
|
if result != nil {
|
|
t.Fatalf("Expected nil result for the entitled Pro registry, got %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_CheckImageUpdate_FetchPaths(t *testing.T) {
|
|
logger := zerolog.Nop()
|
|
|
|
t.Run("fetch error caches error", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
checker.httpClient = &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusInternalServerError, nil, ""), nil
|
|
}),
|
|
}
|
|
|
|
result := checker.CheckImageUpdate(context.Background(), "example.test/repo:tag", "sha256:current", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result on fetch error")
|
|
}
|
|
if result.Error != "registry error: 500" {
|
|
t.Fatalf("Expected registry error, got %q", result.Error)
|
|
}
|
|
|
|
cacheKey := "example.test/repo:tag|//"
|
|
cached := checker.getCached(cacheKey)
|
|
if cached == nil || cached.err != "registry error: 500" {
|
|
t.Fatalf("Expected cached error to be stored, got %+v", cached)
|
|
}
|
|
})
|
|
|
|
t.Run("fetch success caches digest", func(t *testing.T) {
|
|
checker := NewRegistryChecker(logger)
|
|
checker.httpClient = &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
headers := map[string]string{
|
|
"Docker-Content-Digest": "sha256:latest",
|
|
}
|
|
return newStringResponse(http.StatusOK, headers, ""), nil
|
|
}),
|
|
}
|
|
|
|
result := checker.CheckImageUpdate(context.Background(), "example.test/repo:tag", "sha256:current", "", "", "")
|
|
if result == nil {
|
|
t.Fatal("Expected result on fetch success")
|
|
}
|
|
if result.LatestDigest != "sha256:latest" {
|
|
t.Fatalf("Expected latest digest sha256:latest, got %q", result.LatestDigest)
|
|
}
|
|
if !result.UpdateAvailable {
|
|
t.Fatal("Expected update available for new digest")
|
|
}
|
|
|
|
cacheKey := "example.test/repo:tag|//"
|
|
cached := checker.getCached(cacheKey)
|
|
if cached == nil || cached.latestDigest != "sha256:latest" {
|
|
t.Fatalf("Expected cached digest to be stored, got %+v", cached)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_GetCached_ExpiredEntry(t *testing.T) {
|
|
checker := NewRegistryChecker(zerolog.Nop())
|
|
checker.cache.entries["expired"] = cacheEntry{
|
|
latestDigest: "sha256:old",
|
|
expiresAt: time.Now().Add(-time.Minute),
|
|
}
|
|
|
|
if got := checker.getCached("expired"); got != nil {
|
|
t.Fatalf("Expected expired cache entry to return nil, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_ForceCheck(t *testing.T) {
|
|
checker := NewRegistryChecker(zerolog.Nop())
|
|
checker.MarkChecked()
|
|
checker.cacheDigest("test-key", "sha256:test")
|
|
|
|
checker.ForceCheck()
|
|
|
|
checker.mu.RLock()
|
|
lastFullCheck := checker.lastFullCheck
|
|
checker.mu.RUnlock()
|
|
if !lastFullCheck.IsZero() {
|
|
t.Fatalf("expected ForceCheck to reset lastFullCheck, got %s", lastFullCheck)
|
|
}
|
|
|
|
checker.cache.mu.RLock()
|
|
cacheLen := len(checker.cache.entries)
|
|
checker.cache.mu.RUnlock()
|
|
if cacheLen != 0 {
|
|
t.Fatalf("expected ForceCheck to clear cache, found %d entries", cacheLen)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_StatusErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "unauthorized",
|
|
status: http.StatusUnauthorized,
|
|
wantErr: "authentication required",
|
|
},
|
|
{
|
|
name: "not found",
|
|
status: http.StatusNotFound,
|
|
wantErr: "image not found",
|
|
},
|
|
{
|
|
name: "rate limited",
|
|
status: http.StatusTooManyRequests,
|
|
wantErr: "rate limited",
|
|
},
|
|
{
|
|
name: "registry error",
|
|
status: http.StatusInternalServerError,
|
|
wantErr: "registry error: 500",
|
|
},
|
|
{
|
|
name: "missing digest",
|
|
status: http.StatusOK,
|
|
wantErr: "no digest in response",
|
|
},
|
|
}
|
|
|
|
expectedAccept := strings.Join([]string{
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.oci.image.index.v1+json",
|
|
}, ", ")
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var gotAccept string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
gotAccept = req.Header.Get("Accept")
|
|
return newStringResponse(tt.status, nil, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, _, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "", "", "")
|
|
if err == nil || err.Error() != tt.wantErr {
|
|
t.Fatalf("Expected error %q, got %v", tt.wantErr, err)
|
|
}
|
|
if gotAccept != expectedAccept {
|
|
t.Fatalf("Expected Accept header %q, got %q", expectedAccept, gotAccept)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_RequestError(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return nil, errors.New("boom")
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, _, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "", "", "")
|
|
if err == nil || !strings.Contains(err.Error(), "request:") {
|
|
t.Fatalf("Expected request error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_RequestCreationError(t *testing.T) {
|
|
checker := &RegistryChecker{httpClient: &http.Client{}}
|
|
|
|
_, _, err := checker.fetchDigest(context.Background(), "bad host", "repo", "tag", "", "", "")
|
|
if err == nil || !strings.Contains(err.Error(), "create request:") {
|
|
t.Fatalf("Expected create request error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_DigestHeaders(t *testing.T) {
|
|
expectedAccept := strings.Join([]string{
|
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.oci.image.index.v1+json",
|
|
}, ", ")
|
|
|
|
t.Run("docker content digest header", func(t *testing.T) {
|
|
var gotAccept string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
gotAccept = req.Header.Get("Accept")
|
|
headers := map[string]string{
|
|
"Docker-Content-Digest": "sha256:abc123",
|
|
}
|
|
return newStringResponse(http.StatusOK, headers, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, _, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
if digest != "sha256:abc123" {
|
|
t.Fatalf("Expected digest sha256:abc123, got %q", digest)
|
|
}
|
|
if gotAccept != expectedAccept {
|
|
t.Fatalf("Expected Accept header %q, got %q", expectedAccept, gotAccept)
|
|
}
|
|
})
|
|
|
|
t.Run("etag digest header", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
headers := map[string]string{
|
|
"Etag": "\"sha256:etag\"",
|
|
}
|
|
return newStringResponse(http.StatusOK, headers, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, _, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
if digest != "sha256:etag" {
|
|
t.Fatalf("Expected digest sha256:etag, got %q", digest)
|
|
}
|
|
})
|
|
|
|
t.Run("GET fallback hashes manifest body", func(t *testing.T) {
|
|
manifestBody := `{"schemaVersion":2,"config":{"digest":"sha256:config"}}`
|
|
var methods []string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
methods = append(methods, req.Method)
|
|
if req.Method == http.MethodHead {
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Content-Type": "application/vnd.oci.image.manifest.v1+json",
|
|
}, ""), nil
|
|
}
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Content-Type": "application/vnd.oci.image.manifest.v1+json",
|
|
}, manifestBody), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, headDigest, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
wantDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(manifestBody)))
|
|
if digest != wantDigest || headDigest != wantDigest {
|
|
t.Fatalf("Expected computed digest %q, got %q / %q", wantDigest, digest, headDigest)
|
|
}
|
|
if got := strings.Join(methods, ","); got != "HEAD,GET" {
|
|
t.Fatalf("Expected HEAD followed by GET, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("GET fallback resolves manifest list", func(t *testing.T) {
|
|
manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodHead {
|
|
return newStringResponse(http.StatusOK, nil, ""), nil
|
|
}
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Content-Type": "application/vnd.oci.image.index.v1+json",
|
|
"Docker-Content-Digest": "sha256:index",
|
|
}, manifestBody), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, headDigest, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "amd64", "linux", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
if digest != "sha256:amd64" || headDigest != "sha256:index" {
|
|
t.Fatalf("Expected resolved/index digests, got %q / %q", digest, headDigest)
|
|
}
|
|
})
|
|
|
|
t.Run("GET fallback preserves index digest when HEAD identifies a manifest list", func(t *testing.T) {
|
|
manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
|
|
var methods []string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
methods = append(methods, req.Method)
|
|
if req.Method == http.MethodHead {
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Content-Type": "application/vnd.oci.image.index.v1+json",
|
|
}, ""), nil
|
|
}
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Content-Type": "application/vnd.oci.image.index.v1+json",
|
|
}, manifestBody), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, headDigest, err := checker.fetchDigest(context.Background(), "example.test", "repo", "tag", "amd64", "linux", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
wantHeadDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(manifestBody)))
|
|
if digest != "sha256:amd64" || headDigest != wantHeadDigest {
|
|
t.Fatalf("Expected resolved/index digests %q / %q, got %q / %q", "sha256:amd64", wantHeadDigest, digest, headDigest)
|
|
}
|
|
if got := strings.Join(methods, ","); got != "HEAD,GET" {
|
|
t.Fatalf("Expected HEAD followed by GET, got %s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_AuthPaths(t *testing.T) {
|
|
t.Run("auth token error", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Host == "auth.docker.io" {
|
|
return newStringResponse(http.StatusInternalServerError, nil, ""), nil
|
|
}
|
|
return nil, errors.New("unexpected manifest request")
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, _, err := checker.fetchDigest(context.Background(), "registry-1.docker.io", "library/nginx", "latest", "", "", "")
|
|
if err == nil || err.Error() != "auth: token request failed: 500" {
|
|
t.Fatalf("Expected auth error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("auth token header set", func(t *testing.T) {
|
|
var gotAuth string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
switch req.URL.Host {
|
|
case "auth.docker.io":
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"token123"}`), nil
|
|
case "registry-1.docker.io":
|
|
gotAuth = req.Header.Get("Authorization")
|
|
headers := map[string]string{
|
|
"Docker-Content-Digest": "sha256:latest",
|
|
}
|
|
return newStringResponse(http.StatusOK, headers, ""), nil
|
|
default:
|
|
return nil, errors.New("unexpected host")
|
|
}
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, _, err := checker.fetchDigest(context.Background(), "registry-1.docker.io", "library/nginx", "latest", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected digest, got error %v", err)
|
|
}
|
|
if digest != "sha256:latest" {
|
|
t.Fatalf("Expected digest sha256:latest, got %q", digest)
|
|
}
|
|
if gotAuth != "Bearer token123" {
|
|
t.Fatalf("Expected Authorization header, got %q", gotAuth)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_GetAuthToken(t *testing.T) {
|
|
t.Run("docker hub", func(t *testing.T) {
|
|
var gotURL string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
gotURL = req.URL.String()
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"dockertoken"}`), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
token, err := checker.getAuthToken(context.Background(), "registry-1.docker.io", "library/nginx")
|
|
if err != nil {
|
|
t.Fatalf("Expected token, got error %v", err)
|
|
}
|
|
if token != "dockertoken" {
|
|
t.Fatalf("Expected dockertoken, got %q", token)
|
|
}
|
|
if !strings.Contains(gotURL, "service=registry.docker.io") || !strings.Contains(gotURL, "scope=repository:library/nginx:pull") {
|
|
t.Fatalf("Unexpected token URL %q", gotURL)
|
|
}
|
|
})
|
|
|
|
t.Run("ghcr", func(t *testing.T) {
|
|
var gotURL string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
gotURL = req.URL.String()
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"ghcrtoken"}`), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
token, err := checker.getAuthToken(context.Background(), "ghcr.io", "owner/repo")
|
|
if err != nil {
|
|
t.Fatalf("Expected token, got error %v", err)
|
|
}
|
|
if token != "ghcrtoken" {
|
|
t.Fatalf("Expected ghcrtoken, got %q", token)
|
|
}
|
|
if !strings.Contains(gotURL, "service=ghcr.io") || !strings.Contains(gotURL, "scope=repository:owner/repo:pull") {
|
|
t.Fatalf("Unexpected token URL %q", gotURL)
|
|
}
|
|
})
|
|
|
|
t.Run("other registry", func(t *testing.T) {
|
|
checker := &RegistryChecker{}
|
|
token, err := checker.getAuthToken(context.Background(), "example.test", "repo")
|
|
if err != nil {
|
|
t.Fatalf("Expected nil error, got %v", err)
|
|
}
|
|
if token != "" {
|
|
t.Fatalf("Expected empty token, got %q", token)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_FetchAuthToken(t *testing.T) {
|
|
t.Run("bad url", func(t *testing.T) {
|
|
checker := &RegistryChecker{httpClient: &http.Client{}}
|
|
_, err := checker.fetchAuthToken(context.Background(), "http://bad host")
|
|
if err == nil {
|
|
t.Fatal("Expected error for bad URL")
|
|
}
|
|
})
|
|
|
|
t.Run("request error", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return nil, errors.New("transport failure")
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err == nil {
|
|
t.Fatal("Expected request error")
|
|
}
|
|
})
|
|
|
|
t.Run("status error", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusInternalServerError, nil, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err == nil || err.Error() != "token request failed: 500" {
|
|
t.Fatalf("Expected status error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("read error", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
resp := &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: errReadCloser{err: errors.New("read failure")},
|
|
}
|
|
return resp, nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err == nil {
|
|
t.Fatal("Expected read error")
|
|
}
|
|
})
|
|
|
|
t.Run("oversized body", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusOK, nil, strings.Repeat("x", maxRegistryTokenBodyBytes+1)), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err == nil || !strings.Contains(err.Error(), "response body exceeds") {
|
|
t.Fatalf("Expected oversized body error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid json", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusOK, nil, "{"), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err == nil {
|
|
t.Fatal("Expected JSON error")
|
|
}
|
|
})
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"ok"}`), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
token, err := checker.fetchAuthToken(context.Background(), "https://auth.example.test/token")
|
|
if err != nil {
|
|
t.Fatalf("Expected token, got error %v", err)
|
|
}
|
|
if token != "ok" {
|
|
t.Fatalf("Expected token ok, got %q", token)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRegistryChecker_FetchDigest_ChallengeNegotiation(t *testing.T) {
|
|
// lscr.io-style flow (#1583): anonymous HEAD gets 401 with a Bearer
|
|
// challenge naming the token endpoint; the checker must negotiate a
|
|
// token and retry.
|
|
t.Run("negotiates token from challenge", func(t *testing.T) {
|
|
var tokenRequestURL string
|
|
var retryAuth string
|
|
headCalls := 0
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodGet && req.URL.Host == "ghcr.example" {
|
|
tokenRequestURL = req.URL.String()
|
|
return newStringResponse(http.StatusOK, nil, `{"token":"negotiated"}`), nil
|
|
}
|
|
headCalls++
|
|
if req.Header.Get("Authorization") == "" {
|
|
return newStringResponse(http.StatusUnauthorized, map[string]string{
|
|
"Www-Authenticate": `Bearer realm="https://ghcr.example/token",service="ghcr.example",scope="repository:linuxserver/sonarr:pull"`,
|
|
}, ""), nil
|
|
}
|
|
retryAuth = req.Header.Get("Authorization")
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Docker-Content-Digest": "sha256:abc",
|
|
}, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, headDigest, err := checker.fetchDigest(context.Background(), "lscr.example", "linuxserver/sonarr", "latest", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected success, got %v", err)
|
|
}
|
|
if digest != "sha256:abc" || headDigest != "sha256:abc" {
|
|
t.Fatalf("Expected sha256:abc, got %q / %q", digest, headDigest)
|
|
}
|
|
if headCalls != 2 {
|
|
t.Fatalf("Expected 2 HEAD calls, got %d", headCalls)
|
|
}
|
|
if retryAuth != "Bearer negotiated" {
|
|
t.Fatalf("Expected negotiated bearer token on retry, got %q", retryAuth)
|
|
}
|
|
if !strings.Contains(tokenRequestURL, "service=ghcr.example") ||
|
|
!strings.Contains(tokenRequestURL, "scope=repository%3Alinuxserver%2Fsonarr%3Apull") {
|
|
t.Fatalf("Token request missing service/scope: %q", tokenRequestURL)
|
|
}
|
|
})
|
|
|
|
t.Run("challenge without scope falls back to repository pull scope", func(t *testing.T) {
|
|
var tokenRequestURL string
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodGet && req.URL.Host == "auth.example" {
|
|
tokenRequestURL = req.URL.String()
|
|
return newStringResponse(http.StatusOK, nil, `{"access_token":"fallback"}`), nil
|
|
}
|
|
if req.Header.Get("Authorization") == "" {
|
|
return newStringResponse(http.StatusUnauthorized, map[string]string{
|
|
"Www-Authenticate": `Bearer realm="https://auth.example/token"`,
|
|
}, ""), nil
|
|
}
|
|
return newStringResponse(http.StatusOK, map[string]string{
|
|
"Docker-Content-Digest": "sha256:def",
|
|
}, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
digest, _, err := checker.fetchDigest(context.Background(), "reg.example", "some/repo", "latest", "", "", "")
|
|
if err != nil {
|
|
t.Fatalf("Expected success, got %v", err)
|
|
}
|
|
if digest != "sha256:def" {
|
|
t.Fatalf("Expected sha256:def, got %q", digest)
|
|
}
|
|
if !strings.Contains(tokenRequestURL, "scope=repository%3Asome%2Frepo%3Apull") {
|
|
t.Fatalf("Expected fallback pull scope, got %q", tokenRequestURL)
|
|
}
|
|
})
|
|
|
|
t.Run("non-https realm is rejected", func(t *testing.T) {
|
|
checker := &RegistryChecker{
|
|
httpClient: &http.Client{
|
|
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
|
return newStringResponse(http.StatusUnauthorized, map[string]string{
|
|
"Www-Authenticate": `Bearer realm="http://auth.example/token"`,
|
|
}, ""), nil
|
|
}),
|
|
},
|
|
}
|
|
|
|
_, _, err := checker.fetchDigest(context.Background(), "reg.example", "some/repo", "latest", "", "", "")
|
|
if err == nil || err.Error() != "authentication required" {
|
|
t.Fatalf("Expected authentication required, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseBearerChallenge(t *testing.T) {
|
|
params := parseBearerChallenge(`Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:linuxserver/sonarr:pull"`)
|
|
if params["realm"] != "https://ghcr.io/token" || params["service"] != "ghcr.io" || params["scope"] != "repository:linuxserver/sonarr:pull" {
|
|
t.Fatalf("Unexpected params: %#v", params)
|
|
}
|
|
if len(parseBearerChallenge(`Basic realm="x"`)) != 0 {
|
|
t.Fatalf("Expected empty params for non-bearer challenge")
|
|
}
|
|
if len(parseBearerChallenge("")) != 0 {
|
|
t.Fatalf("Expected empty params for empty header")
|
|
}
|
|
}
|