mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
498 lines
14 KiB
Go
498 lines
14 KiB
Go
package dockeragent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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_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_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)
|
|
}
|
|
})
|
|
}
|
|
|
|
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("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)
|
|
}
|
|
})
|
|
}
|