mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 19:41:17 +00:00
465 lines
14 KiB
Go
465 lines
14 KiB
Go
package updatedetection
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return f(r)
|
|
}
|
|
|
|
type errorReader struct {
|
|
err error
|
|
}
|
|
|
|
func (e *errorReader) Read(p []byte) (int, error) {
|
|
return 0, e.err
|
|
}
|
|
|
|
func newResponse(status int, headers http.Header, body io.Reader) *http.Response {
|
|
if headers == nil {
|
|
headers = http.Header{}
|
|
}
|
|
if body == nil {
|
|
body = bytes.NewReader(nil)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: status,
|
|
Status: http.StatusText(status),
|
|
Header: headers,
|
|
Body: io.NopCloser(body),
|
|
}
|
|
}
|
|
|
|
func TestRegistryCheckerConfig(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
if r.httpClient == nil || r.cache == nil || r.configs == nil {
|
|
t.Fatalf("expected registry checker to initialize")
|
|
}
|
|
|
|
cfg := RegistryConfig{Host: "example.com", Username: "user", Password: "pass", Insecure: true}
|
|
r.AddRegistryConfig(cfg)
|
|
|
|
r.mu.RLock()
|
|
stored := r.configs["example.com"]
|
|
r.mu.RUnlock()
|
|
if stored.Username != "user" || !stored.Insecure {
|
|
t.Fatalf("expected config to be stored")
|
|
}
|
|
}
|
|
|
|
func TestRegistryCache(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.cacheDigest("key-digest", "sha256:abc")
|
|
r.cacheError("key-error", "boom")
|
|
|
|
if r.CacheSize() != 2 {
|
|
t.Fatalf("expected cache size 2, got %d", r.CacheSize())
|
|
}
|
|
if entry := r.getCached("key-digest"); entry == nil || entry.digest != "sha256:abc" {
|
|
t.Fatalf("expected cached digest")
|
|
}
|
|
if entry := r.getCached("missing"); entry != nil {
|
|
t.Fatalf("expected missing cache entry to be nil")
|
|
}
|
|
|
|
r.cache.entries["expired"] = cacheEntry{
|
|
digest: "old",
|
|
expiresAt: time.Now().Add(-time.Minute),
|
|
}
|
|
if entry := r.getCached("expired"); entry != nil {
|
|
t.Fatalf("expected expired cache entry to be nil")
|
|
}
|
|
|
|
r.CleanupCache()
|
|
if r.CacheSize() != 2 {
|
|
t.Fatalf("expected expired cache entry to be removed")
|
|
}
|
|
}
|
|
|
|
func TestRegistryCheckImageUpdate(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("DigestPinned", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
info, err := r.CheckImageUpdate(ctx, "nginx@sha256:abc", "sha256:old")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if info.Error == "" {
|
|
t.Fatalf("expected digest-pinned error")
|
|
}
|
|
})
|
|
|
|
t.Run("CachedError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
key := "registry-1.docker.io/library/nginx:latest"
|
|
r.cache.entries[key] = cacheEntry{
|
|
err: "cached error",
|
|
expiresAt: time.Now().Add(time.Hour),
|
|
}
|
|
|
|
info, err := r.CheckImageUpdate(ctx, "nginx:latest", "sha256:old")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if info.Error != "cached error" {
|
|
t.Fatalf("expected cached error")
|
|
}
|
|
})
|
|
|
|
t.Run("CachedDigest", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
key := "registry-1.docker.io/library/nginx:latest"
|
|
r.cache.entries[key] = cacheEntry{
|
|
digest: "sha256:new",
|
|
expiresAt: time.Now().Add(time.Hour),
|
|
}
|
|
|
|
info, err := r.CheckImageUpdate(ctx, "nginx:latest", "sha256:old")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !info.UpdateAvailable || info.LatestDigest != "sha256:new" {
|
|
t.Fatalf("expected update available from cache")
|
|
}
|
|
})
|
|
|
|
t.Run("FetchErrorCaches", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusInternalServerError, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
info, err := r.CheckImageUpdate(ctx, "example.com/repo:tag", "sha256:old")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if info.Error == "" {
|
|
t.Fatalf("expected error from fetch")
|
|
}
|
|
if cached := r.getCached("example.com/repo:tag"); cached == nil || cached.err == "" {
|
|
t.Fatalf("expected error to be cached")
|
|
}
|
|
})
|
|
|
|
t.Run("FetchSuccessCaches", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
headers := http.Header{"Docker-Content-Digest": []string{"sha256:new"}}
|
|
return newResponse(http.StatusOK, headers, nil), nil
|
|
}),
|
|
}
|
|
|
|
info, err := r.CheckImageUpdate(ctx, "example.com/repo:tag", "sha256:old")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !info.UpdateAvailable {
|
|
t.Fatalf("expected update available")
|
|
}
|
|
if cached := r.getCached("example.com/repo:tag"); cached == nil || cached.digest == "" {
|
|
t.Fatalf("expected digest to be cached")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFetchDigest(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("AuthError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("auth fail")
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "registry-1.docker.io", "library/nginx", "latest"); err == nil {
|
|
t.Fatalf("expected auth error")
|
|
}
|
|
})
|
|
|
|
t.Run("InsecureScheme", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.AddRegistryConfig(RegistryConfig{Host: "insecure.local", Insecure: true})
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if req.URL.Scheme != "http" {
|
|
t.Fatalf("expected http scheme, got %q", req.URL.Scheme)
|
|
}
|
|
headers := http.Header{"Docker-Content-Digest": []string{"sha256:abc"}}
|
|
return newResponse(http.StatusOK, headers, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "insecure.local", "repo", "latest"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("RequestError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
if _, err := r.fetchDigest(ctx, "bad host", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected request creation error")
|
|
}
|
|
})
|
|
|
|
t.Run("TokenHeader", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.AddRegistryConfig(RegistryConfig{Host: "ghcr.io", Password: "pat"})
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if got := req.Header.Get("Authorization"); got != "Bearer pat" {
|
|
t.Fatalf("expected bearer token, got %q", got)
|
|
}
|
|
headers := http.Header{"Docker-Content-Digest": []string{"sha256:abc"}}
|
|
return newResponse(http.StatusOK, headers, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "ghcr.io", "owner/repo", "latest"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("DoError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("network")
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected request error")
|
|
}
|
|
})
|
|
|
|
t.Run("StatusUnauthorized", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusUnauthorized, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected unauthorized error")
|
|
}
|
|
})
|
|
|
|
t.Run("StatusNotFound", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusNotFound, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected not found error")
|
|
}
|
|
})
|
|
|
|
t.Run("StatusRateLimited", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusTooManyRequests, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected rate limit error")
|
|
}
|
|
})
|
|
|
|
t.Run("StatusOtherError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusInternalServerError, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected registry error")
|
|
}
|
|
})
|
|
|
|
t.Run("DigestHeader", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
headers := http.Header{"Docker-Content-Digest": []string{"sha256:abc"}}
|
|
return newResponse(http.StatusOK, headers, nil), nil
|
|
}),
|
|
}
|
|
|
|
digest, err := r.fetchDigest(ctx, "example.com", "repo", "latest")
|
|
if err != nil || digest != "sha256:abc" {
|
|
t.Fatalf("expected digest, got %q err %v", digest, err)
|
|
}
|
|
})
|
|
|
|
t.Run("EtagHeader", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
headers := http.Header{"Etag": []string{`"sha256:etag"`}}
|
|
return newResponse(http.StatusOK, headers, nil), nil
|
|
}),
|
|
}
|
|
|
|
digest, err := r.fetchDigest(ctx, "example.com", "repo", "latest")
|
|
if err != nil || digest != "sha256:etag" {
|
|
t.Fatalf("expected etag digest, got %q err %v", digest, err)
|
|
}
|
|
})
|
|
|
|
t.Run("NoDigest", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusOK, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.fetchDigest(ctx, "example.com", "repo", "latest"); err == nil {
|
|
t.Fatalf("expected missing digest error")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetAuthToken(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("DockerHubSuccess", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.AddRegistryConfig(RegistryConfig{Host: "registry-1.docker.io", Username: "user", Password: "pass"})
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
if !strings.HasPrefix(req.Header.Get("Authorization"), "Basic ") {
|
|
t.Fatalf("expected basic auth header")
|
|
}
|
|
body := `{"token":"tok"}`
|
|
return newResponse(http.StatusOK, nil, strings.NewReader(body)), nil
|
|
}),
|
|
}
|
|
|
|
token, err := r.getAuthToken(ctx, "registry-1.docker.io", "library/nginx")
|
|
if err != nil || token != "tok" {
|
|
t.Fatalf("expected token, got %q err %v", token, err)
|
|
}
|
|
})
|
|
|
|
t.Run("DockerHubRequestError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
if _, err := r.getAuthToken(ctx, "registry-1.docker.io", "bad\nrepo"); err == nil {
|
|
t.Fatalf("expected request error")
|
|
}
|
|
})
|
|
|
|
t.Run("DockerHubDoError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("network")
|
|
}),
|
|
}
|
|
|
|
if _, err := r.getAuthToken(ctx, "registry-1.docker.io", "library/nginx"); err == nil {
|
|
t.Fatalf("expected network error")
|
|
}
|
|
})
|
|
|
|
t.Run("DockerHubStatusError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
return newResponse(http.StatusInternalServerError, nil, nil), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.getAuthToken(ctx, "registry-1.docker.io", "library/nginx"); err == nil {
|
|
t.Fatalf("expected status error")
|
|
}
|
|
})
|
|
|
|
t.Run("DockerHubReadError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
body := &errorReader{err: errors.New("read")}
|
|
return newResponse(http.StatusOK, nil, body), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.getAuthToken(ctx, "registry-1.docker.io", "library/nginx"); err == nil {
|
|
t.Fatalf("expected read error")
|
|
}
|
|
})
|
|
|
|
t.Run("DockerHubJSONError", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.httpClient = &http.Client{
|
|
Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
|
|
body := "{"
|
|
return newResponse(http.StatusOK, nil, strings.NewReader(body)), nil
|
|
}),
|
|
}
|
|
|
|
if _, err := r.getAuthToken(ctx, "registry-1.docker.io", "library/nginx"); err == nil {
|
|
t.Fatalf("expected json error")
|
|
}
|
|
})
|
|
|
|
t.Run("GHCRToken", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.AddRegistryConfig(RegistryConfig{Host: "ghcr.io", Password: "pat"})
|
|
token, err := r.getAuthToken(ctx, "ghcr.io", "owner/repo")
|
|
if err != nil || token != "pat" {
|
|
t.Fatalf("expected ghcr token")
|
|
}
|
|
})
|
|
|
|
t.Run("GHCRAnonymous", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
token, err := r.getAuthToken(ctx, "ghcr.io", "owner/repo")
|
|
if err != nil || token != "" {
|
|
t.Fatalf("expected empty ghcr token")
|
|
}
|
|
})
|
|
|
|
t.Run("OtherRegistryBasicAuth", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
r.AddRegistryConfig(RegistryConfig{Host: "example.com", Username: "user", Password: "pass"})
|
|
token, err := r.getAuthToken(ctx, "example.com", "repo")
|
|
if err != nil || token != "" {
|
|
t.Fatalf("expected empty token for basic auth registry")
|
|
}
|
|
})
|
|
|
|
t.Run("OtherRegistryNoAuth", func(t *testing.T) {
|
|
r := NewRegistryChecker(zerolog.Nop())
|
|
token, err := r.getAuthToken(ctx, "example.com", "repo")
|
|
if err != nil || token != "" {
|
|
t.Fatalf("expected empty token for registry without auth")
|
|
}
|
|
})
|
|
}
|