internal/charts/charts_test.go
Rama main · réplica de lectura del repositorio original.
package charts
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
const indexBody = `
apiVersion: v1
entries:
podinfo:
- version: 6.14.0
- version: 6.15.1
- version: 6.16.0-rc.1
- version: 6.9.0
other:
- version: 1.2.3
`
type interruptedReader struct {
delivered bool
}
func (r *interruptedReader) Read(into []byte) (int, error) {
if r.delivered {
return 0, errors.New("repository response interrupted")
}
r.delivered = true
return copy(into, "entries:\n podinfo:"), nil
}
func cacheFor(t *testing.T, handler http.HandlerFunc) (*Cache, *httptest.Server) {
t.Helper()
ts := httptest.NewServer(handler)
t.Cleanup(ts.Close)
address := ts.Listener.Addr().String()
ts.URL = "http://charts.example.com"
return cacheThrough(t, ts.Client(), address), ts
}
func tlsCacheFor(t *testing.T, handler http.HandlerFunc) (*Cache, *httptest.Server) {
t.Helper()
ts := httptest.NewTLSServer(handler)
t.Cleanup(ts.Close)
address := ts.Listener.Addr().String()
ts.URL = "https://registry.example.com"
return cacheThrough(t, ts.Client(), address), ts
}
func cacheThrough(t *testing.T, client *http.Client, address string) *Cache {
t.Helper()
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
}
dial := func(ctx context.Context, network, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, address)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
return newCache(ctx, client, DefaultTTL, lookup, dial)
}
func indexHandler(hits *int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/index.yaml") {
w.WriteHeader(http.StatusNotFound)
return
}
*hits++
_, _ = w.Write([]byte(indexBody))
}
}
func TestResolveIndexPicksTheHighestStableVersion(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
found, err := cache.Resolve(context.Background(), Repo{URL: ts.URL}, "podinfo")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if newest(found, "podinfo") != "6.15.1" {
t.Fatalf("podinfo = %q, want 6.15.1 (prerelease 6.16.0-rc.1 must be skipped)", newest(found, "podinfo"))
}
if newest(found, "other") != "1.2.3" {
t.Fatalf("other = %q, want 1.2.3", newest(found, "other"))
}
}
func newest(found map[string][]string, chart string) string {
list := found[chart]
if len(list) == 0 {
return ""
}
return list[0]
}
func TestResolveIndexTrimsTrailingSlash(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
_, err := cache.Resolve(context.Background(), Repo{URL: ts.URL + "/"}, "podinfo")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if hits != 1 {
t.Fatalf("hits = %d, want 1", hits)
}
}
func TestWarmPopulatesEveryChartFromOneFetch(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Warm(repo, "other")
cache.Wait()
if cache.Latest(repo, "podinfo") != "6.15.1" {
t.Fatalf("podinfo = %q", cache.Latest(repo, "podinfo"))
}
if cache.Latest(repo, "other") != "1.2.3" {
t.Fatalf("other = %q", cache.Latest(repo, "other"))
}
if hits != 1 {
t.Fatalf("index fetched %d times, want 1 for an http repo", hits)
}
}
func TestWarmHonoursTheTTL(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Wait()
cache.Warm(repo, "podinfo")
cache.Wait()
if hits != 1 {
t.Fatalf("hits = %d, want 1 while inside the ttl", hits)
}
cache.now = func() time.Time { return time.Now().Add(2 * DefaultTTL) }
cache.Warm(repo, "podinfo")
cache.Wait()
if hits != 2 {
t.Fatalf("hits = %d, want 2 after the ttl expired", hits)
}
}
func TestASeededIndexAnswersWithoutTheNetwork(t *testing.T) {
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return nil, errors.New("the network was used")
}
cache := newCache(t.Context(), nil, time.Hour, lookup, nil)
cache.now = func() time.Time { return now }
repo := Repo{URL: "https://charts.example.com"}
body := indexBody + "\n preview-only:\n - version: 7.0.0-rc.1\n"
err := cache.Seed(repo, strings.NewReader(body), now)
if err != nil {
t.Fatalf("seed: %v", err)
}
versions, versionsErr := cache.Versions(t.Context(), repo, "podinfo")
if versionsErr != nil {
t.Fatalf("versions: %v", versionsErr)
}
if strings.Join(versions, ",") != "6.15.1,6.14.0,6.9.0" {
t.Fatalf("versions = %v, want the cached stable versions", versions)
}
hits, searchErr := cache.Search(t.Context(), repo, "pod", 10)
if searchErr != nil {
t.Fatalf("search: %v", searchErr)
}
if len(hits) != 1 || hits[0].Name != "podinfo" {
t.Fatalf("hits = %+v, want podinfo from the cached catalog", hits)
}
if cache.Latest(repo, "preview-only") != "" {
t.Fatal("a preview-only chart entered the stable cache")
}
}
func TestABrokenSeedDoesNotChangeTheCache(t *testing.T) {
cache := New(t.Context(), nil, time.Hour)
repo := Repo{URL: "https://charts.example.com"}
err := cache.Seed(repo, strings.NewReader("entries: [not closed"), time.Now())
if err == nil {
t.Fatal("a malformed cached index was accepted")
}
if cache.Latest(repo, "podinfo") != "" {
t.Fatal("a malformed cached index populated the cache")
}
}
func TestWarmIgnoresEmptyInputs(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
cache.Warm(Repo{URL: ""}, "podinfo")
cache.Warm(Repo{URL: ts.URL}, "")
cache.Wait()
if hits != 0 {
t.Fatalf("hits = %d, want 0", hits)
}
}
func TestLatestIsEmptyBeforeAnyFetch(t *testing.T) {
cache, ts := cacheFor(t, indexHandler(new(int)))
if cache.Latest(Repo{URL: ts.URL}, "podinfo") != "" {
t.Fatalf("expected an empty version before warming")
}
}
func TestWarmSurvivesAFailedFetch(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Wait()
if cache.Latest(repo, "podinfo") != "" {
t.Fatalf("expected no version after a failed fetch")
}
}
func TestAFailedWarmDoesNotHideTheNextSuccessfulFetch(t *testing.T) {
hits := 0
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
hits++
if hits == 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(indexBody))
})
cache.now = func() time.Time { return now }
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Wait()
now = now.Add(negativeTTL)
versions, err := cache.Versions(t.Context(), repo, "podinfo")
if err != nil {
t.Fatalf("versions after the repository recovered: %v", err)
}
if len(versions) == 0 || versions[0] != "6.15.1" {
t.Fatalf("versions after recovery = %v", versions)
}
if hits != 2 {
t.Fatalf("repository fetched %d times, want the failed warm and the recovery", hits)
}
}
func TestConcurrentIdenticalOCIMissesShareOneRegistryRequest(t *testing.T) {
var hits atomic.Int32
started := make(chan struct{})
proceed := make(chan struct{})
var once sync.Once
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
once.Do(func() { close(started) })
<-proceed
_, _ = w.Write([]byte(`{"tags":["1.2.3"]}`))
})
repo := ociRepo(ts)
const callers = 16
errs := make(chan error, callers)
for range callers {
go func() {
_, err := cache.Versions(t.Context(), repo, "keycloak")
errs <- err
}()
}
<-started
close(proceed)
for range callers {
if err := <-errs; err != nil {
t.Fatalf("versions: %v", err)
}
}
if hits.Load() != 1 {
t.Fatalf("registry requests = %d, want 1", hits.Load())
}
}
func TestRegistryFetchWaiterCanCancelWithoutStartingAnotherRequest(t *testing.T) {
var hits atomic.Int32
var once sync.Once
started := make(chan struct{})
proceed := make(chan struct{})
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
once.Do(func() { close(started) })
<-proceed
_, _ = w.Write([]byte(`{"tags":["1.2.3"]}`))
})
repo := ociRepo(ts)
leader := make(chan error, 1)
go func() {
_, err := cache.Versions(t.Context(), repo, "keycloak")
leader <- err
}()
<-started
canceled, cancel := context.WithCancel(t.Context())
cancel()
_, err := cache.Versions(canceled, repo, "keycloak")
if !errors.Is(err, context.Canceled) {
t.Fatalf("waiter error = %v, want context cancellation", err)
}
if hits.Load() != 1 {
t.Fatalf("registry requests = %d, want 1", hits.Load())
}
close(proceed)
if err := <-leader; err != nil {
t.Fatalf("leader versions: %v", err)
}
}
func TestFailedFetchesAreCachedBriefly(t *testing.T) {
var hits atomic.Int32
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusBadGateway)
})
cache.now = func() time.Time { return now }
repo := Repo{URL: ts.URL}
for range 2 {
_, err := cache.Versions(t.Context(), repo, "podinfo")
if err == nil {
t.Fatal("failed repository reported success")
}
}
if hits.Load() != 1 {
t.Fatalf("requests inside the negative ttl = %d, want 1", hits.Load())
}
now = now.Add(negativeTTL)
_, err := cache.Versions(t.Context(), repo, "podinfo")
if err == nil {
t.Fatal("failed repository reported success after the negative ttl")
}
if hits.Load() != 2 {
t.Fatalf("requests at the negative ttl boundary = %d, want 2", hits.Load())
}
}
func TestCallerControlledOCICacheEntriesAreEvictedByCapacity(t *testing.T) {
cache := New(t.Context(), nil, DefaultTTL)
cache.capacity = 2
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
cache.now = func() time.Time { return now }
repo := Repo{URL: "oci://registry.example.com/team", OCI: true}
for _, chart := range []string{"first", "second", "third"} {
unit := fetchUnit(repo, chart)
flight, leader, err := cache.begin(unit)
if err != nil || !leader {
t.Fatalf("begin %s: leader=%v error=%v", chart, leader, err)
}
found := listing{versions: map[string][]string{chart: {"1.0.0"}}}
cache.complete(unit, repo, found, flight, nil)
now = now.Add(time.Second)
}
if cache.Latest(repo, "first") != "" {
t.Fatal("oldest oci entry survived the capacity boundary")
}
if cache.Latest(repo, "second") != "1.0.0" || cache.Latest(repo, "third") != "1.0.0" {
t.Fatalf("newer entries were evicted: second=%q third=%q", cache.Latest(repo, "second"), cache.Latest(repo, "third"))
}
if len(cache.fetched) != 2 || len(cache.lists) != 2 {
t.Fatalf("cache sizes = fetched %d, lists %d, want 2 each", len(cache.fetched), len(cache.lists))
}
}
func TestResolveIndexRejectsMalformedYAML(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("entries: [oops"))
})
_, err := cache.Resolve(context.Background(), Repo{URL: ts.URL}, "podinfo")
if err == nil {
t.Fatalf("expected a parse error")
}
}
const mergeBesideAMapKey = `
entries:
podinfo:
- version: 6.14.0
? {kind: map}
: key
<<: {}
`
const unhashableKeyError = "yaml: runtime error: hash of unhashable type map[string]interface {}"
func TestASeededIndexWithAMergeBesideAMapKeyIsRefused(t *testing.T) {
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
cache := newCache(t.Context(), nil, time.Hour, nil, nil)
cache.now = func() time.Time { return now }
repo := Repo{URL: "https://charts.example.com"}
err := cache.Seed(repo, strings.NewReader(mergeBesideAMapKey), now)
if err == nil || err.Error() != unhashableKeyError {
t.Fatalf("seed error = %v, want %q", err, unhashableKeyError)
}
if cache.Latest(repo, "podinfo") != "" {
t.Fatalf("podinfo = %q, want nothing cached from a refused index", cache.Latest(repo, "podinfo"))
}
}
func TestResolveRefusesAnIndexWithAMergeBesideAMapKey(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(mergeBesideAMapKey))
})
_, err := cache.Resolve(t.Context(), Repo{URL: ts.URL}, "podinfo")
want := "parse " + ts.URL + "/index.yaml: " + unhashableKeyError
if err == nil || err.Error() != want {
t.Fatalf("error = %v, want %q", err, want)
}
}
func TestWarmSurvivesAnIndexWithAMergeBesideAMapKey(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(mergeBesideAMapKey))
})
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Wait()
if cache.Latest(repo, "podinfo") != "" {
t.Fatalf("podinfo = %q, want nothing cached from a refused index", cache.Latest(repo, "podinfo"))
}
}
func TestResolveRefusesALocalRepositoryPassedDirectly(t *testing.T) {
cache := New(t.Context(), http.DefaultClient, DefaultTTL)
_, err := cache.Resolve(t.Context(), Repo{URL: "http://127.0.0.1/charts"}, "podinfo")
if err == nil || !strings.Contains(err.Error(), "public address") {
t.Fatalf("error = %v, want the local repository refused", err)
}
}
func TestResolveRequiresTheURLAndOCISettingToAgree(t *testing.T) {
cache := New(t.Context(), http.DefaultClient, DefaultTTL)
cases := []Repo{
{URL: "https://charts.example.com", OCI: true},
{URL: "oci://registry.example.com/team", OCI: false},
}
for _, repo := range cases {
_, err := cache.Resolve(t.Context(), repo, "podinfo")
if err == nil || !strings.Contains(err.Error(), "does not match its oci setting") {
t.Fatalf("repo = %+v, error = %v", repo, err)
}
}
}
func ociHandler(t *testing.T, tokens *int) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
*tokens++
_, _ = w.Write([]byte(`{"token":"abc123"}`))
return
}
if r.Header.Get("Authorization") != "Bearer abc123" {
w.Header().Set("WWW-Authenticate",
fmt.Sprintf(`Bearer realm="https://%s/token",service="registry",scope="repository:team/keycloak:pull"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path != "/v2/team/keycloak/tags/list" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(`{"tags":["0.21.14","0.22.0","latest","sha256-abc.sig","0.23.0-rc1"]}`))
}
}
func ociRepo(ts *httptest.Server) Repo {
return Repo{URL: "oci://" + strings.TrimPrefix(ts.URL, "https://") + "/team", OCI: true}
}
func TestResolveOCIFollowsTheBearerChallenge(t *testing.T) {
tokens := 0
cache, ts := tlsCacheFor(t, ociHandler(t, &tokens))
found, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if newest(found, "keycloak") != "0.22.0" {
t.Fatalf("keycloak = %q, want 0.22.0", newest(found, "keycloak"))
}
if tokens != 1 {
t.Fatalf("token requests = %d, want 1", tokens)
}
}
func TestOCICachesPerChart(t *testing.T) {
tokens := 0
cache, ts := tlsCacheFor(t, ociHandler(t, &tokens))
repo := ociRepo(ts)
cache.Warm(repo, "keycloak")
cache.Wait()
if cache.Latest(repo, "keycloak") != "0.22.0" {
t.Fatalf("keycloak = %q", cache.Latest(repo, "keycloak"))
}
if cache.Latest(repo, "other") != "" {
t.Fatalf("an unrelated chart must not be populated by an oci fetch")
}
}
func TestResolveOCIRejectsABadURL(t *testing.T) {
cache, _ := tlsCacheFor(t, ociHandler(t, new(int)))
_, err := cache.Resolve(context.Background(), Repo{URL: "oci://registry-only", OCI: true}, "keycloak")
if err == nil {
t.Fatalf("expected an error for an oci url without a path")
}
}
func TestResolveOCIGivesUpWhenTheTokenIsRejected(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
_, _ = w.Write([]byte(`{"token":"abc123"}`))
return
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected the second 401 to surface rather than looping")
}
}
func TestResolveOCIWithoutARealm(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", "Basic")
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected an error when the challenge has no bearer realm")
}
}
func TestResolveOCITokenEndpointFailure(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected the token failure to surface")
}
}
func TestResolveOCIReportsATokenConnectionThatBreaks(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
hijacker, ok := w.(http.Hijacker)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
conn, _, err := hijacker.Hijack()
if err != nil {
return
}
_ = conn.Close()
return
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatal("a broken token connection was reported as a successful registry read")
}
}
func TestResolveOCIAcceptsAccessToken(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
_, _ = w.Write([]byte(`{"access_token":"xyz"}`))
return
}
if r.Header.Get("Authorization") != "Bearer xyz" {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"tags":["1.0.0"]}`))
})
found, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if newest(found, "keycloak") != "1.0.0" {
t.Fatalf("keycloak = %q", newest(found, "keycloak"))
}
}
func TestResolveOCIEmptyTokenResponse(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
_, _ = w.Write([]byte(`{}`))
return
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected an error for an empty token response")
}
}
func TestResolveOCIRejectsMalformedTokenJSON(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
_, _ = w.Write([]byte("{not json"))
return
}
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer realm="https://%s/token"`, r.Host))
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected a token parse error")
}
}
func TestResolveOCIRejectsMalformedTagJSON(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("{not json"))
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected a tag parse error")
}
}
func TestResolveOCIWithNoUsableTags(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"tags":["latest","edge"]}`))
})
found, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if len(found) != 0 {
t.Fatalf("found = %v, want empty", found)
}
}
func TestResolveSurfacesNonAuthErrors(t *testing.T) {
cache, ts := tlsCacheFor(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
})
_, err := cache.Resolve(context.Background(), ociRepo(ts), "keycloak")
if err == nil {
t.Fatalf("expected a 502 to surface")
}
}
func TestResolveRejectsAnUnusableURL(t *testing.T) {
cache, _ := cacheFor(t, indexHandler(new(int)))
_, err := cache.Resolve(context.Background(), Repo{URL: "://bad"}, "podinfo")
if err == nil {
t.Fatalf("expected a request build error")
}
}
func internetFor(t *testing.T, handler http.HandlerFunc) *Cache {
t.Helper()
ts := httptest.NewTLSServer(handler)
t.Cleanup(ts.Close)
return cacheThrough(t, ts.Client(), ts.Listener.Addr().String())
}
func TestCheckRepoURLSortsFetchableFromNot(t *testing.T) {
cases := []struct {
name string
url string
want bool
}{
{"an https repository", "https://charts.example.com/stable", true},
{"a plain http repository", "http://charts.example.com/stable", true},
{"an oci registry", "oci://registry.example.com/team", true},
{"a public address", "https://93.184.216.34/charts", true},
{"a file url", "file:///etc/passwd", false},
{"a gopher url", "gopher://example.com/", false},
{"no host at all", "https:///charts", false},
{"cloud metadata", "http://169.254.169.254/latest/meta-data", false},
{"loopback", "http://127.0.0.1:9090/charts", false},
{"loopback by name", "http://localhost:9090/charts", false},
{"a localhost subdomain", "http://spinoza.localhost/charts", false},
{"localhost with a dns root", "http://localhost./charts", false},
{"ipv6 loopback", "http://[::1]:9090/charts", false},
{"an ipv4-mapped loopback", "http://[::ffff:127.0.0.1]/charts", false},
{"a private range", "http://10.4.0.9/charts", false},
{"carrier grade nat", "http://100.100.100.200/charts", false},
{"a unique local address", "http://[fd00::1]/charts", false},
{"the unspecified address", "http://0.0.0.0/charts", false},
{"multicast", "http://224.0.0.1/charts", false},
{"userinfo", "https://operator:[email protected]/charts", false},
{"an encoded host", "https://%31%32%37.0.0.1/charts", false},
{"unparseable", "://bad", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := CheckRepoURL(tc.url)
if (err == nil) != tc.want {
t.Fatalf("CheckRepoURL(%q) = %v", tc.url, err)
}
})
}
}
func TestARepositoryConnectionUsesTheAddressThatWasChecked(t *testing.T) {
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
}
connected := ""
dial := func(_ context.Context, _, address string) (net.Conn, error) {
connected = address
return nil, errors.New("test dial stopped")
}
_, _ = publicDial(lookup, dial)(t.Context(), "tcp", "charts.example.com:443")
if connected != "93.184.216.34:443" {
t.Fatalf("dialed %q, want the checked address rather than the hostname", connected)
}
}
func TestARepositoryConnectionNeedsAHostAndPort(t *testing.T) {
lookedUp := false
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
lookedUp = true
return nil, nil
}
dial := func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("unexpected dial")
}
_, err := publicDial(lookup, dial)(t.Context(), "tcp", "charts.example.com")
if err == nil || !strings.Contains(err.Error(), "repository address") {
t.Fatalf("error = %v, want the malformed address named", err)
}
if lookedUp {
t.Fatal("a malformed address reached DNS")
}
}
func TestATLSRepositoryConnectionNeedsAHostAndPort(t *testing.T) {
lookedUp := false
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
lookedUp = true
return nil, nil
}
dial := func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("unexpected dial")
}
_, err := publicTLSDial(defaultTransport(), lookup, dial)(t.Context(), "tcp", "charts.example.com")
if err == nil || !strings.Contains(err.Error(), "repository address") {
t.Fatalf("error = %v, want the malformed TLS address named", err)
}
if lookedUp {
t.Fatal("a malformed TLS address reached DNS")
}
}
func TestRepositoryDialRefusesLocalhostWithoutDNS(t *testing.T) {
lookedUp := false
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
lookedUp = true
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
}
dial := func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("unexpected dial")
}
_, err := publicDial(lookup, dial)(t.Context(), "tcp", "localhost:443")
if err == nil || !strings.Contains(err.Error(), "this machine") {
t.Fatalf("error = %v, want localhost refused", err)
}
if lookedUp {
t.Fatal("localhost reached DNS")
}
}
func TestARepositoryDNSFailureIsReported(t *testing.T) {
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return nil, errors.New("dns unavailable")
}
_, err := resolvePublic(t.Context(), lookup, "charts.example.com")
if err == nil || !strings.Contains(err.Error(), "resolve repository host") {
t.Fatalf("error = %v, want the DNS operation named", err)
}
}
func TestARepositoryResolvingToNothingIsRefused(t *testing.T) {
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return nil, nil
}
_, err := resolvePublic(t.Context(), lookup, "charts.example.com")
if err == nil || !strings.Contains(err.Error(), "resolved to no addresses") {
t.Fatalf("error = %v, want the empty DNS answer named", err)
}
}
func TestAMixedPublicAndPrivateDNSAnswerIsRefusedBeforeDialling(t *testing.T) {
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{
netip.MustParseAddr("93.184.216.34"),
netip.MustParseAddr("10.0.0.8"),
}, nil
}
dialed := false
dial := func(context.Context, string, string) (net.Conn, error) {
dialed = true
return nil, errors.New("test dial stopped")
}
_, err := publicDial(lookup, dial)(t.Context(), "tcp", "charts.example.com:443")
if err == nil {
t.Fatal("a mixed public and private dns answer was accepted")
}
if dialed {
t.Fatal("an address was dialed before the complete dns answer was validated")
}
}
func TestAReboundHostnameIsCheckedAgainForTheNextConnection(t *testing.T) {
lookups := 0
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
lookups++
if lookups == 1 {
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
}
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
}
dials := 0
dial := func(context.Context, string, string) (net.Conn, error) {
dials++
return nil, errors.New("test dial stopped")
}
guarded := publicDial(lookup, dial)
_, _ = guarded(t.Context(), "tcp", "charts.example.com:443")
_, err := guarded(t.Context(), "tcp", "charts.example.com:443")
if err == nil || !strings.Contains(err.Error(), "non-public") {
t.Fatalf("second connection error = %v, want the rebound address refused", err)
}
if lookups != 2 {
t.Fatalf("dns lookups = %d, want one for every connection", lookups)
}
if dials != 1 {
t.Fatalf("socket dials = %d, want only the first public answer dialed", dials)
}
}
func TestAnIPv4MappedPrivateDNSAnswerIsRefused(t *testing.T) {
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("::ffff:127.0.0.1")}, nil
}
dialed := false
dial := func(context.Context, string, string) (net.Conn, error) {
dialed = true
return nil, errors.New("test dial stopped")
}
_, err := publicDial(lookup, dial)(t.Context(), "tcp", "charts.example.com:443")
if err == nil {
t.Fatal("an ipv4-mapped loopback answer was accepted")
}
if dialed {
t.Fatal("the mapped loopback address was dialed")
}
}
func TestTheGuardedTransportCannotBypassAddressValidation(t *testing.T) {
transport := defaultTransport()
transport.Proxy = http.ProxyFromEnvironment
bypassedHTTP := false
transport.DialContext = func(context.Context, string, string) (net.Conn, error) {
bypassedHTTP = true
return nil, errors.New("unguarded http dial")
}
bypassedTLS := false
transport.DialTLSContext = func(context.Context, string, string) (net.Conn, error) {
bypassedTLS = true
return nil, errors.New("unguarded tls dial")
}
client := &http.Client{Transport: transport}
lookup := func(context.Context, string, string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
}
dial := func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("guarded dial")
}
guarded := publicOnly(client, lookup, dial)
configured, ok := guarded.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport = %T", guarded.Transport)
}
if configured.Proxy != nil {
t.Fatal("the guarded client can bypass validation through an http proxy")
}
_, httpErr := configured.DialContext(t.Context(), "tcp", "charts.example.com:80")
if httpErr == nil || !strings.Contains(httpErr.Error(), "non-public") {
t.Fatalf("http dial error = %v, want address validation", httpErr)
}
_, tlsErr := configured.DialTLSContext(t.Context(), "tcp", "charts.example.com:443")
if tlsErr == nil || !strings.Contains(tlsErr.Error(), "non-public") {
t.Fatalf("tls dial error = %v, want address validation", tlsErr)
}
if bypassedHTTP || bypassedTLS {
t.Fatal("the guarded client used one of the caller's dialers")
}
}
func TestTheGuardedTLSClientRefusesLegacyProtocols(t *testing.T) {
for _, tc := range []struct {
name string
configured *tls.Config
want uint16
}{
{name: "no caller configuration", want: tls.VersionTLS12},
{name: "a legacy minimum", configured: &tls.Config{MinVersion: tls.VersionTLS10}, want: tls.VersionTLS12},
{name: "a stricter minimum", configured: &tls.Config{MinVersion: tls.VersionTLS13}, want: tls.VersionTLS13},
} {
t.Run(tc.name, func(t *testing.T) {
transport := &http.Transport{TLSClientConfig: tc.configured}
got := tlsConfigFor(transport, "charts.example.com")
if got.MinVersion != tc.want {
t.Fatalf("minimum TLS version = %x, want %x", got.MinVersion, tc.want)
}
if got.ServerName != "charts.example.com" {
t.Fatalf("server name = %q", got.ServerName)
}
})
}
}
func TestARedirectOntoThisMachineIsRefused(t *testing.T) {
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://127.0.0.1:9/index.yaml", http.StatusFound)
})
_, err := cache.Resolve(context.Background(), Repo{URL: "https://charts.example.com"}, "podinfo")
if err == nil {
t.Fatal("a chart repository redirected the fetch onto loopback and it followed")
}
}
func TestARedirectToAPublicHostIsFollowed(t *testing.T) {
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/index.yaml" {
http.Redirect(w, r, "https://example.com/mirror/index.yaml", http.StatusFound)
return
}
_, _ = w.Write([]byte(indexBody))
})
found, err := cache.Resolve(context.Background(), Repo{URL: "https://example.com"}, "podinfo")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if newest(found, "podinfo") != "6.15.1" {
t.Fatalf("podinfo = %q", newest(found, "podinfo"))
}
}
func TestAnEndlessRedirectChainIsCutOff(t *testing.T) {
hops := 0
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
hops++
http.Redirect(w, r, "https://example.com/again", http.StatusFound)
})
_, err := cache.Resolve(context.Background(), Repo{URL: "https://example.com"}, "podinfo")
if err == nil {
t.Fatal("expected the redirect chain to be cut off")
}
if hops > maxRedirects+1 {
t.Fatalf("hops = %d, want the chain stopped at %d", hops, maxRedirects)
}
}
func TestABearerRealmOnAnotherHostIsRefused(t *testing.T) {
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer realm="http://169.254.169.254/latest/meta-data",service="registry"`)
w.WriteHeader(http.StatusUnauthorized)
})
_, err := cache.Resolve(context.Background(), Repo{URL: "oci://registry.example.com/team", OCI: true}, "keycloak")
if err == nil {
t.Fatal("the registry pointed the token request at another host and it followed")
}
}
func TestABearerRealmOnASiblingOfTheRegistryIsAllowed(t *testing.T) {
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
if r.Host == "auth.example.com" {
_, _ = w.Write([]byte(`{"token":"abc123"}`))
return
}
if r.Header.Get("Authorization") != "Bearer abc123" {
w.Header().Set("WWW-Authenticate", `Bearer realm="https://auth.example.com/token",service="registry"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"tags":["1.0.0"]}`))
})
found, err := cache.Resolve(context.Background(), Repo{URL: "oci://registry.example.com/team", OCI: true}, "keycloak")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if newest(found, "keycloak") != "1.0.0" {
t.Fatalf("keycloak = %q", newest(found, "keycloak"))
}
}
func TestTheTokenRequestEscapesTheChallengeAndKeepsTheRealmQuery(t *testing.T) {
asked := ""
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/token") {
asked = r.URL.RawQuery
_, _ = w.Write([]byte(`{"token":"abc123"}`))
return
}
if r.Header.Get("Authorization") != "Bearer abc123" {
w.Header().Set("WWW-Authenticate",
`Bearer realm="https://example.com/token?tenant=acme",service="reg istry",scope="repository:team/keycloak:pull"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"tags":["1.0.0"]}`))
})
_, err := cache.Resolve(context.Background(), Repo{URL: "oci://example.com/team", OCI: true}, "keycloak")
if err != nil {
t.Fatalf("resolve: %v", err)
}
query, parseErr := url.ParseQuery(asked)
if parseErr != nil {
t.Fatalf("parse %q: %v", asked, parseErr)
}
if query.Get("tenant") != "acme" {
t.Fatalf("query = %q, want the realm's own parameters kept", asked)
}
if query.Get("service") != "reg istry" {
t.Fatalf("service = %q, want it escaped rather than pasted in", query.Get("service"))
}
if query.Get("scope") != "repository:team/keycloak:pull" {
t.Fatalf("scope = %q", query.Get("scope"))
}
}
func TestTheChartNameCannotReshapeTheRegistryPath(t *testing.T) {
asked := ""
cache := internetFor(t, func(w http.ResponseWriter, r *http.Request) {
asked = r.URL.EscapedPath()
_, _ = w.Write([]byte(`{"tags":["1.0.0"]}`))
})
_, err := cache.Resolve(context.Background(), Repo{URL: "oci://example.com/team", OCI: true}, "../../secrets")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if asked != "/v2/team/..%2F..%2Fsecrets/tags/list" {
t.Fatalf("path = %q, want the chart name kept inside one segment", asked)
}
}
func TestNewerComparesSemver(t *testing.T) {
cases := []struct {
current string
latest string
want bool
}{
{"6.14.0", "6.15.1", true},
{"6.15.1", "6.15.1", false},
{"6.16.0", "6.15.1", false},
{"v1.21.0", "v1.22.0", true},
{"1.0.0+86d6320df72b", "1.0.1", true},
{"", "6.15.1", false},
{"6.14.0", "", false},
{"not-semver", "6.15.1", false},
{"6.14.0", "not-semver", false},
}
for _, tc := range cases {
if got := Newer(tc.current, tc.latest); got != tc.want {
t.Fatalf("Newer(%q, %q) = %v, want %v", tc.current, tc.latest, got, tc.want)
}
}
}
func TestParseChallengeIgnoresJunk(t *testing.T) {
got := parseChallenge(`Bearer realm="https://auth.example/token",service="reg",bogus`)
if got["realm"] != "https://auth.example/token" {
t.Fatalf("realm = %q", got["realm"])
}
if got["service"] != "reg" {
t.Fatalf("service = %q", got["service"])
}
}
func TestSortVersionsAcceptsAnOCIBuildMetadataTag(t *testing.T) {
got := sortVersions([]string{"1.2.0", "1.3.0_20260101", "1.1.0"})
want := []string{"1.3.0_20260101", "1.2.0", "1.1.0"}
if !slices.Equal(got, want) {
t.Fatalf("sorted = %v, want %v; Helm writes + as _ in an OCI tag, so that one was being dropped", got, want)
}
}
func TestSortVersionsKeepsTheTagItWasGiven(t *testing.T) {
got := sortVersions([]string{"2.0.0_abc"})
if !slices.Equal(got, []string{"2.0.0_abc"}) {
t.Fatalf("sorted = %v, want the tag as published rather than a rewritten one", got)
}
}
func TestSortVersionsStillSkipsPrereleases(t *testing.T) {
got := sortVersions([]string{"1.0.0", "2.0.0-rc.1"})
if !slices.Equal(got, []string{"1.0.0"}) {
t.Fatalf("sorted = %v, want a prerelease left out the way Helm leaves it out", got)
}
}
func TestSortVersionsReportsNothingForAnAllPrereleaseRepo(t *testing.T) {
got := sortVersions([]string{"2.0.0-rc.1", "2.0.0-rc.2"})
if len(got) != 0 {
t.Fatalf("sorted = %v, want none; suggesting an upgrade to a release candidate is worse than saying nothing", got)
}
}
func TestSortVersionsSkipsATagThatIsNotSemverEvenAfterTranslation(t *testing.T) {
got := sortVersions([]string{"1.0.0_build_2"})
if len(got) != 0 {
t.Fatalf("sorted = %v; underscores are not legal in build metadata, so this is not a version", got)
}
}
func TestSortVersionsComparesTranslatedTagsByVersionNotText(t *testing.T) {
got := sortVersions([]string{"1.10.0_20260101", "1.9.0_20260102"})
want := []string{"1.10.0_20260101", "1.9.0_20260102"}
if !slices.Equal(got, want) {
t.Fatalf("sorted = %v, want the higher version first rather than the later build stamp", got)
}
}
func TestVersionsReturnsTheSortedListAndCaches(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
repo := Repo{URL: ts.URL}
got, err := cache.Versions(context.Background(), repo, "podinfo")
if err != nil {
t.Fatalf("versions: %v", err)
}
want := []string{"6.15.1", "6.14.0", "6.9.0"}
if !slices.Equal(got, want) {
t.Fatalf("versions = %v, want %v", got, want)
}
again, err := cache.Versions(context.Background(), repo, "podinfo")
if err != nil {
t.Fatalf("versions again: %v", err)
}
if !slices.Equal(again, want) {
t.Fatalf("versions again = %v, want %v", again, want)
}
if hits != 1 {
t.Fatalf("hits = %d, want the second read served from the cache", hits)
}
}
func TestVersionsRefetchesAfterTheTTL(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
repo := Repo{URL: ts.URL}
_, err := cache.Versions(context.Background(), repo, "podinfo")
if err != nil {
t.Fatalf("versions: %v", err)
}
cache.now = func() time.Time { return time.Now().Add(2 * DefaultTTL) }
_, err = cache.Versions(context.Background(), repo, "podinfo")
if err != nil {
t.Fatalf("versions after ttl: %v", err)
}
if hits != 2 {
t.Fatalf("hits = %d, want a refetch once the ttl passed", hits)
}
}
func TestVersionsReportsAFetchFailure(t *testing.T) {
cache, _ := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
_, err := cache.Versions(context.Background(), Repo{URL: "https://example.com"}, "podinfo")
if err == nil {
t.Fatal("expected the failed index fetch to surface")
}
}
func TestVersionsForAChartTheRepoDoesNotCarry(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
got, err := cache.Versions(context.Background(), Repo{URL: ts.URL}, "absent")
if err != nil {
t.Fatalf("versions: %v", err)
}
if len(got) != 0 {
t.Fatalf("versions = %v, want none for a chart the index does not list", got)
}
}
func TestLatestReadsTheFirstCachedVersion(t *testing.T) {
hits := 0
cache, ts := cacheFor(t, indexHandler(&hits))
repo := Repo{URL: ts.URL}
cache.Warm(repo, "podinfo")
cache.Wait()
if cache.Latest(repo, "podinfo") != "6.15.1" {
t.Fatalf("latest = %q, want the newest of the sorted list", cache.Latest(repo, "podinfo"))
}
if cache.Latest(repo, "absent") != "" {
t.Fatalf("latest = %q, want nothing for an unknown chart", cache.Latest(repo, "absent"))
}
}
func TestCheckFetchable(t *testing.T) {
cases := []struct {
raw string
ok bool
}{
{"https://charts.example.com", true},
{"http://127.0.0.1:8879", true},
{"oci://registry.example.com/team", true},
{"ftp://charts.example.com", false},
{"https://", false},
{"://bad", false},
}
for _, tc := range cases {
err := CheckFetchable(tc.raw)
if tc.ok && err != nil {
t.Fatalf("CheckFetchable(%q) = %v, want it allowed", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("CheckFetchable(%q) allowed, want it refused", tc.raw)
}
}
}
func TestValidVersion(t *testing.T) {
cases := []struct {
version string
ok bool
}{
{"6.15.1", true},
{"v1.2.3", true},
{"1.3.0_20260101", true},
{"not-semver", false},
{"--repo", false},
{"", false},
}
for _, tc := range cases {
if ValidVersion(tc.version) != tc.ok {
t.Fatalf("ValidVersion(%q) = %v, want %v", tc.version, !tc.ok, tc.ok)
}
}
}
func TestNewerComparesOCIBuildMetadataTags(t *testing.T) {
if !Newer("1.9.0_20260101", "1.10.0_20260102") {
t.Fatal("an outdated OCI release was reported as up to date; maxVersion hands back the raw underscore tag")
}
}
func TestNewerRefusesAnUpToDateOCITag(t *testing.T) {
if Newer("1.10.0_20260102", "1.10.0_20260102") {
t.Fatal("a release on the newest tag was reported as outdated")
}
}
func TestNewerStillRefusesGarbage(t *testing.T) {
if Newer("1.0.0_build_2", "2.0.0") {
t.Fatal("a tag that is not a version was compared anyway")
}
}
func TestSameRegistryAcceptsWhatBelongsToTheSameSite(t *testing.T) {
cases := []struct {
name string
realm string
registry string
want bool
}{
{name: "the very same authority", realm: "ghcr.io", registry: "ghcr.io", want: true},
{name: "the same host on another port", realm: "ghcr.io:443", registry: "ghcr.io", want: true},
{name: "a sibling under one domain", realm: "auth.ghcr.io", registry: "registry.ghcr.io", want: true},
{name: "different sites under a two label public suffix", realm: "auth.attacker.co.uk", registry: "registry.example.co.uk", want: false},
{name: "a different site", realm: "auth.example.com", registry: "ghcr.io", want: false},
{name: "an ip against a name", realm: "127.0.0.1", registry: "ghcr.io", want: false},
{name: "the same ip twice", realm: "127.0.0.1:5000", registry: "127.0.0.1", want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := sameRegistry(tc.realm, tc.registry); got != tc.want {
t.Fatalf("sameRegistry(%q, %q) = %v, want %v", tc.realm, tc.registry, got, tc.want)
}
})
}
}
func TestHostOnlyDropsThePortWhenThereIsOne(t *testing.T) {
if got := hostOnly("ghcr.io:443"); got != "ghcr.io" {
t.Fatalf("host = %q, want ghcr.io", got)
}
if got := hostOnly("ghcr.io"); got != "ghcr.io" {
t.Fatalf("host = %q, want the authority unchanged", got)
}
}
func TestParentDomainUsesTheRegistrableDomain(t *testing.T) {
if got := parentDomain("auth.docker.example.com"); got != "example.com" {
t.Fatalf("parent = %q, want example.com", got)
}
if got := parentDomain("auth.docker.example.co.uk"); got != "example.co.uk" {
t.Fatalf("parent = %q, want example.co.uk", got)
}
if got := parentDomain("localhost"); got != "localhost" {
t.Fatalf("parent = %q, want the single label unchanged", got)
}
}
func TestTokenEndpointRefusesAChallengeItCannotUse(t *testing.T) {
registry, err := url.Parse("https://ghcr.io")
if err != nil {
t.Fatalf("parse: %v", err)
}
cases := []struct {
name string
params map[string]string
want string
}{
{name: "no realm at all", params: map[string]string{}, want: "no bearer realm"},
{name: "a realm that is not a url", params: map[string]string{"realm": "://"}, want: "bearer realm"},
{name: "a realm that is not http", params: map[string]string{"realm": "ftp://ghcr.io/token"}, want: "not an http url"},
{
name: "a realm on another site",
params: map[string]string{"realm": "https://auth.elsewhere.com/token"},
want: "does not belong to ghcr.io",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := tokenEndpoint(tc.params, registry)
if err == nil {
t.Fatal("tokenEndpoint returned nil error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %q, want it to mention %q", err.Error(), tc.want)
}
})
}
}
func TestCheckHostRefusesAnEmptyHost(t *testing.T) {
err := checkHost("")
if err == nil {
t.Fatal("checkHost returned nil error for an empty host")
}
if err.Error() != "repository url has no host" {
t.Fatalf("error = %q", err.Error())
}
}
func TestSearchListsWhatTheIndexOffers(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
podinfo:
- version: 6.10.0
description: a tiny greeter
- version: 6.9.0
description: an older greeter
redis:
- version: 20.0.1
description: an in-memory store
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "pod", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 1 {
t.Fatalf("found = %+v, want only podinfo", found)
}
if found[0].Version != "6.10.0" {
t.Fatalf("version = %q, want the newest", found[0].Version)
}
if found[0].Description != "a tiny greeter" {
t.Fatalf("description = %q, want the newest version's", found[0].Description)
}
}
func TestSearchMatchesTheDescriptionToo(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
redis:
- version: 20.0.1
description: an in-memory store
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "in-memory", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 1 || found[0].Name != "redis" {
t.Fatalf("found = %+v", found)
}
}
func TestSearchPutsNameMatchesBeforeDescriptionMatches(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
cache-proxy:
- version: 1.0.0
description: a proxy
redis:
- version: 20.0.1
description: a cache
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "cache", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 2 {
t.Fatalf("found = %+v", found)
}
if found[0].Name != "cache-proxy" {
t.Fatalf("first = %q, want the name match first", found[0].Name)
}
}
func TestSearchKeepsAtMostTheLimit(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
chart-a:
- version: 1.0.0
chart-b:
- version: 1.0.0
chart-c:
- version: 1.0.0
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "chart", 2)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 2 {
t.Fatalf("found = %d, want the limit", len(found))
}
}
func TestAnIndexLargerThanItsLimitIsNotAcceptedPartially(t *testing.T) {
body := "entries:\n web:\n - version: 1.0.0\n"
_, err := parseBoundedIndex(strings.NewReader(body+"extra"), len(body))
if err == nil || !strings.Contains(err.Error(), "larger than") {
t.Fatalf("err = %v", err)
}
}
func TestJSONLargerThanItsLimitIsNotAcceptedPartially(t *testing.T) {
body := `{"tags":["1.0.0"]}`
var doc struct {
Tags []string `json:"tags"`
}
err := decodeBoundedJSON(strings.NewReader(body+"extra"), len(body), &doc)
if err == nil || !strings.Contains(err.Error(), "larger than") {
t.Fatalf("err = %v", err)
}
}
func TestRepositoryResponseLimitAcceptsTheBoundaryAndRejectsOneMoreByte(t *testing.T) {
if _, err := readBounded(strings.NewReader(strings.Repeat("x", maxBodyBytes)), maxBodyBytes); err != nil {
t.Fatalf("boundary response: %v", err)
}
_, err := readBounded(strings.NewReader(strings.Repeat("x", maxBodyBytes+1)), maxBodyBytes)
if err == nil || err.Error() != "response body is larger than 8388608 bytes" {
t.Fatalf("error = %v", err)
}
}
func TestAnIndexReadFailureIsNotAcceptedPartially(t *testing.T) {
_, err := parseBoundedIndex(&interruptedReader{}, maxBodyBytes)
if err == nil || !strings.Contains(err.Error(), "repository response interrupted") {
t.Fatalf("error = %v, want the interrupted response reported", err)
}
}
func TestAnEmptySearchListsEverything(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
chart-a:
- version: 1.0.0
chart-b:
- version: 1.0.0
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "", 0)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 2 {
t.Fatalf("found = %+v, want both", found)
}
}
func TestSearchRefusesAnOCIRegistry(t *testing.T) {
cache := New(context.Background(), http.DefaultClient, DefaultTTL)
_, err := cache.Search(context.Background(), Repo{URL: "oci://registry.example.com/charts", OCI: true}, "podinfo", 10)
if err == nil {
t.Fatal("an oci registry was listed")
}
if !strings.Contains(err.Error(), "name the chart instead") {
t.Fatalf("error = %v", err)
}
}
func TestSearchReportsAnIndexItCannotRead(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
_, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "podinfo", 10)
if err == nil {
t.Fatal("a missing index reported success")
}
}
func TestSearchServesTheSecondAskFromTheCache(t *testing.T) {
asks := 0
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
asks++
_, _ = w.Write([]byte("entries:\n podinfo:\n - version: 6.10.0\n"))
})
_, first := cache.Search(context.Background(), Repo{URL: ts.URL}, "podinfo", 10)
_, second := cache.Search(context.Background(), Repo{URL: ts.URL}, "pod", 10)
if first != nil || second != nil {
t.Fatalf("search: %v %v", first, second)
}
if asks != 1 {
t.Fatalf("index fetched %d times, want once", asks)
}
}
func TestSearchMatchesTheMiddleOfAName(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
podinfo:
- version: 6.14.1
description: a tiny greeter
kube-prometheus-stack:
- version: 88.3.0
description: a bundle
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "prometheus", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 1 || found[0].Name != "kube-prometheus-stack" {
t.Fatalf("found = %+v, want the chart whose name carries the word", found)
}
}
func TestAChartWithNoUsableVersionIsLeftOutOfTheCatalogue(t *testing.T) {
cache, ts := cacheFor(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`entries:
podinfo:
- version: 6.14.1
broken:
- version: not-a-version
prereleased:
- version: 1.0.0-rc1
`))
})
found, err := cache.Search(context.Background(), Repo{URL: ts.URL}, "", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(found) != 1 || found[0].Name != "podinfo" {
t.Fatalf("found = %+v, want only the chart with a version helm could install", found)
}
}