package helm import ( "context" "errors" "fmt" "slices" "strings" "sync" "sync/atomic" "testing" "time" k8sfake "k8s.io/client-go/kubernetes/fake" "github.com/sophotechlabs/spinoza/internal/api" "github.com/sophotechlabs/spinoza/internal/charts" ) type controlledValuesRunner struct { started chan struct{} proceed chan struct{} once sync.Once calls atomic.Int32 deadline time.Time } func (r *controlledValuesRunner) Run(ctx context.Context, _, _ []string) (string, error) { r.calls.Add(1) deadline, ok := ctx.Deadline() if ok { r.deadline = deadline } r.once.Do(func() { close(r.started) }) select { case <-ctx.Done(): return "", ctx.Err() case <-r.proceed: return "replicaCount: 1\n", nil } } func (r *controlledValuesRunner) Available() error { return nil } func searcher(t *testing.T, index Charts, repos []RepoEntry) *Service { t.Helper() return NewService(k8sfake.NewClientset(), nil, &stubRunner{}, index, repos, api.ContextRef{Name: "kind-spinoza"}) } func TestASearchWithoutRepositoriesSaysHowToAddOne(t *testing.T) { svc := searcher(t, &stubCharts{}, nil) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 0 { t.Fatalf("hits = %+v", found.Hits) } if !strings.Contains(found.Error, "helm repo add") { t.Fatalf("error = %q", found.Error) } } func TestASearchWithoutAnIndexSaysSo(t *testing.T) { svc := searcher(t, nil, []RepoEntry{{Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}}) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if !strings.Contains(found.Error, "not wired up") { t.Fatalf("error = %q", found.Error) } } func TestASearchAsksEveryRepositoryAndKeepsWhereEachHitCameFrom(t *testing.T) { index := &stubCharts{catalog: map[string][]charts.Chart{ "https://one.example.com": {{Name: "podinfo", Version: "6.10.0", Description: "a tiny app"}}, "https://two.example.com": {{Name: "podinfo-extras", Version: "1.2.3"}}, }} svc := searcher(t, index, []RepoEntry{ {Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}, {Name: "two", Repo: charts.Repo{URL: "https://two.example.com"}}, }) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 2 { t.Fatalf("hits = %+v", found.Hits) } if found.Hits[0].Chart != "podinfo" || found.Hits[0].Repo != "one" { t.Fatalf("first hit = %+v, want the exact name first", found.Hits[0]) } if found.Hits[0].Description != "a tiny app" || found.Hits[0].URL != "https://one.example.com" { t.Fatalf("first hit = %+v", found.Hits[0]) } if found.Hits[1].Chart != "podinfo-extras" { t.Fatalf("second hit = %+v", found.Hits[1]) } if found.Error != "" { t.Fatalf("error = %q", found.Error) } slices.Sort(index.searched) want := []string{"https://one.example.com|podinfo", "https://two.example.com|podinfo"} if !slices.Equal(index.searched, want) { t.Fatalf("searched = %v, want %v", index.searched, want) } } func TestASearchReportsTheRepositoriesItCouldNotRead(t *testing.T) { index := &stubCharts{ catalog: map[string][]charts.Chart{"https://one.example.com": {{Name: "podinfo", Version: "6.10.0"}}}, failures: map[string]error{"https://two.example.com": errors.New("404")}, } svc := searcher(t, index, []RepoEntry{ {Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}, {Repo: charts.Repo{URL: "https://two.example.com"}}, }) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 1 { t.Fatalf("hits = %+v, want the working repository still answered", found.Hits) } if !strings.Contains(found.Error, "https://two.example.com: 404") { t.Fatalf("error = %q, want the failing repository named by its url", found.Error) } } func TestASearchSaysWhenItHeldSomeBack(t *testing.T) { many := make([]charts.Chart, 0, searchLimit+5) for i := range searchLimit + 5 { many = append(many, charts.Chart{Name: "chart" + string(rune('a'+i%26)) + strings.Repeat("x", i), Version: "1.0.0"}) } index := &stubCharts{catalog: map[string][]charts.Chart{"https://one.example.com": many}} svc := searcher(t, index, []RepoEntry{{Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}}) found, err := svc.SearchCharts(t.Context(), "chart") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != searchLimit { t.Fatalf("hits = %d, want the limit", len(found.Hits)) } if !found.Truncated { t.Fatal("a capped search did not say so") } } func TestChartValuesAsksHelmForTheDefaults(t *testing.T) { runner := &stubRunner{out: "replicaCount: 1\n"} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{Name: "kind-spinoza"}, ) found, err := svc.ChartValues(t.Context(), ValuesRequest{ Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com", }) if err != nil { t.Fatalf("values: %v", err) } args := runner.args[0] if args[0] != "show" || args[1] != "values" || args[2] != "podinfo" { t.Fatalf("args = %v", args) } if !hasPair(args, "--repo", "https://charts.example.com") { t.Fatalf("args = %v", args) } if found.Values != "replicaCount: 1\n" { t.Fatalf("values = %q", found.Values) } } func TestChartValuesCacheUsesOneBoundedFetchForConcurrentReaders(t *testing.T) { runner := &controlledValuesRunner{started: make(chan struct{}), proceed: make(chan struct{})} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{Name: "kind-spinoza"}, ) req := ValuesRequest{Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com"} startedAt := time.Now() const callers = 16 errs := make(chan error, callers) for range callers { go func() { _, err := svc.ChartValues(t.Context(), req) errs <- err }() } <-runner.started close(runner.proceed) for range callers { if err := <-errs; err != nil { t.Fatalf("values: %v", err) } } if runner.calls.Load() != 1 { t.Fatalf("helm calls = %d, want 1", runner.calls.Load()) } if runner.deadline.Before(startedAt.Add(valuesTimeout - time.Second)) { t.Fatalf("helm deadline = %s, want about %s", runner.deadline.Sub(startedAt), valuesTimeout) } if runner.deadline.After(startedAt.Add(valuesTimeout + time.Second)) { t.Fatalf("helm deadline = %s, want at most %s", runner.deadline.Sub(startedAt), valuesTimeout) } } func TestChartValuesWaiterCanCancelWithoutStartingAnotherFetch(t *testing.T) { runner := &controlledValuesRunner{started: make(chan struct{}), proceed: make(chan struct{})} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{}, ) req := ValuesRequest{Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com"} leader := make(chan error, 1) go func() { _, err := svc.ChartValues(t.Context(), req) leader <- err }() <-runner.started canceled, cancel := context.WithCancel(t.Context()) cancel() _, err := svc.ChartValues(canceled, req) if !errors.Is(err, context.Canceled) { t.Fatalf("waiter error = %v, want context cancellation", err) } if runner.calls.Load() != 1 { t.Fatalf("helm calls = %d, want 1", runner.calls.Load()) } close(runner.proceed) if err := <-leader; err != nil { t.Fatalf("leader values: %v", err) } } func TestChartValuesCacheExpiresAtTheTTLBoundary(t *testing.T) { runner := &stubRunner{out: "{}"} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{}, ) now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC) svc.valuesNow = func() time.Time { return now } req := ValuesRequest{Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com"} if _, err := svc.ChartValues(t.Context(), req); err != nil { t.Fatalf("first values: %v", err) } now = now.Add(valuesTTL - time.Nanosecond) if _, err := svc.ChartValues(t.Context(), req); err != nil { t.Fatalf("cached values: %v", err) } if len(runner.args) != 1 { t.Fatalf("helm calls inside ttl = %d, want 1", len(runner.args)) } now = now.Add(time.Nanosecond) if _, err := svc.ChartValues(t.Context(), req); err != nil { t.Fatalf("values at ttl: %v", err) } if len(runner.args) != 2 { t.Fatalf("helm calls at ttl = %d, want 2", len(runner.args)) } } func TestChartValuesCacheHasAFixedCapacity(t *testing.T) { runner := &stubRunner{out: "{}"} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{}, ) now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC) svc.valuesNow = func() time.Time { return now } for at := range valuesCapacity + 1 { req := ValuesRequest{ Chart: "podinfo", Version: fmt.Sprintf("1.0.%d", at), RepoURL: "https://charts.example.com", } if _, err := svc.ChartValues(t.Context(), req); err != nil { t.Fatalf("values %d: %v", at, err) } now = now.Add(time.Second) } if len(svc.values) != valuesCapacity { t.Fatalf("cache size = %d, want %d", len(svc.values), valuesCapacity) } oldest := ValuesRequest{Chart: "podinfo", Version: "1.0.0", RepoURL: "https://charts.example.com"} if _, ok := svc.values[oldest]; ok { t.Fatal("the oldest values entry survived capacity eviction") } } func TestChartValuesRefusesWhatItCannotFetch(t *testing.T) { cases := map[string]ValuesRequest{ "a chart that is not a chart name": {Chart: "../etc", Version: "6.10.0", RepoURL: "https://charts.example.com"}, "a version that is not semantic": {Chart: "podinfo", Version: "latest", RepoURL: "https://charts.example.com"}, "a repository on this machine": {Chart: "podinfo", Version: "6.10.0", RepoURL: "http://localhost:8080"}, } for name, req := range cases { t.Run(name, func(t *testing.T) { runner := &stubRunner{} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{Name: "kind-spinoza"}, ) _, err := svc.ChartValues(t.Context(), req) if err == nil { t.Fatal("the request was accepted") } if len(runner.args) != 0 { t.Fatalf("helm was run anyway: %v", runner.args) } }) } } func TestChartValuesWithoutARunnerIsRefused(t *testing.T) { svc := NewService(k8sfake.NewClientset(), nil, nil, nil, nil, api.ContextRef{Name: "kind-spinoza"}) _, err := svc.ChartValues(t.Context(), ValuesRequest{ Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com", }) if err == nil { t.Fatal("chart values without a runner reported success") } } func TestChartValuesReportsWhatHelmSaid(t *testing.T) { runner := &stubRunner{err: errors.New("chart not found")} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{Name: "kind-spinoza"}, ) _, err := svc.ChartValues(t.Context(), ValuesRequest{ Chart: "podinfo", Version: "6.10.0", RepoURL: "https://charts.example.com", }) if err == nil { t.Fatal("a failed lookup reported success") } } func TestChartValuesFromAnOCIRegistryCarriesTheRef(t *testing.T) { runner := &stubRunner{out: "{}"} svc := NewService( k8sfake.NewClientset(), nil, runner, nil, actionRepositories(), api.ContextRef{Name: "kind-spinoza"}, ) _, err := svc.ChartValues(t.Context(), ValuesRequest{ Chart: "podinfo", Version: "6.10.0", RepoURL: "oci://registry.example.com/charts", OCI: true, }) if err != nil { t.Fatalf("values: %v", err) } args := runner.args[0] if args[2] != "oci://registry.example.com/charts/podinfo" { t.Fatalf("chart ref = %q", args[2]) } if slices.Contains(args, "--repo") { t.Fatalf("args = %v, want no --repo for an oci chart", args) } } func TestARegistryIsAskedForTheNameThatWasTyped(t *testing.T) { index := &stubCharts{lists: map[string][]string{ "oci://ghcr.io/acme/charts|podinfo": {"6.11.0", "6.10.2"}, }} svc := searcher(t, index, []RepoEntry{ {Name: "acme", Repo: charts.Repo{URL: "oci://ghcr.io/acme/charts", OCI: true}}, }) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 1 { t.Fatalf("hits = %+v, want the registry's chart", found.Hits) } if found.Hits[0].Chart != "podinfo" || found.Hits[0].Version != "6.11.0" { t.Fatalf("hit = %+v, want the newest tag", found.Hits[0]) } if found.Hits[0].URL != "oci://ghcr.io/acme/charts" || found.Hits[0].Repo != "acme" { t.Fatalf("hit = %+v, want the registry it came from", found.Hits[0]) } if len(index.searched) != 0 { t.Fatalf("searched = %v, want no index listing for a registry", index.searched) } } func TestARegistryIsNotAskedAboutSomethingThatIsNotAChartName(t *testing.T) { index := &stubCharts{} svc := searcher(t, index, []RepoEntry{ {Name: "acme", Repo: charts.Repo{URL: "oci://ghcr.io/acme/charts", OCI: true}}, }) found, err := svc.SearchCharts(t.Context(), "Pod Info") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 0 { t.Fatalf("hits = %+v", found.Hits) } if len(index.asked) != 0 { t.Fatalf("asked = %v, want the registry left alone", index.asked) } } func TestASearchReportsARegistryItCouldNotRead(t *testing.T) { index := &stubCharts{ catalog: map[string][]charts.Chart{"https://one.example.com": {{Name: "podinfo", Version: "6.14.1"}}}, failures: map[string]error{"oci://ghcr.io/acme/charts|podinfo": errors.New("status 403")}, } svc := searcher(t, index, []RepoEntry{ {Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}, {Name: "acme", Repo: charts.Repo{URL: "oci://ghcr.io/acme/charts", OCI: true}}, }) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 1 || found.Hits[0].Repo != "one" { t.Fatalf("hits = %+v, want only the index repository", found.Hits) } if !strings.Contains(found.Error, "acme: status 403") { t.Fatalf("error = %q, want the failing registry and reason", found.Error) } } func TestARegistryWithNoTagsForThatNameAddsNoHit(t *testing.T) { index := &stubCharts{lists: map[string][]string{"oci://ghcr.io/acme/charts|podinfo": {}}} svc := searcher(t, index, []RepoEntry{ {Name: "acme", Repo: charts.Repo{URL: "oci://ghcr.io/acme/charts", OCI: true}}, }) found, err := svc.SearchCharts(t.Context(), "podinfo") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 0 { t.Fatalf("hits = %+v", found.Hits) } } func TestTheSameChartInTwoRepositoriesIsListedOnceEach(t *testing.T) { index := &stubCharts{catalog: map[string][]charts.Chart{ "https://two.example.com": {{Name: "redis", Version: "20.0.1"}}, "https://one.example.com": {{Name: "redis", Version: "19.5.0"}}, }} svc := searcher(t, index, []RepoEntry{ {Name: "zeta", Repo: charts.Repo{URL: "https://two.example.com"}}, {Name: "alpha", Repo: charts.Repo{URL: "https://one.example.com"}}, }) found, err := svc.SearchCharts(t.Context(), "redis") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 2 { t.Fatalf("hits = %+v, want one per repository", found.Hits) } if found.Hits[0].Repo != "alpha" || found.Hits[1].Repo != "zeta" { t.Fatalf("hits = %+v, want the same name settled by repository", found.Hits) } } func TestAnExactNameBeatsOneThatMerelyContainsIt(t *testing.T) { index := &stubCharts{catalog: map[string][]charts.Chart{ "https://one.example.com": { {Name: "kube-redis", Version: "1.0.0"}, {Name: "redis-cluster", Version: "13.0.4"}, {Name: "redis", Version: "20.0.1"}, }, }} svc := searcher(t, index, []RepoEntry{{Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}}) found, err := svc.SearchCharts(t.Context(), "redis") if err != nil { t.Fatalf("search: %v", err) } order := make([]string, 0, len(found.Hits)) for _, hit := range found.Hits { order = append(order, hit.Chart) } want := []string{"redis", "redis-cluster", "kube-redis"} if !slices.Equal(order, want) { t.Fatalf("order = %v, want %v", order, want) } } func TestAnEmptyQueryStillListsWhatEachRepositoryHas(t *testing.T) { index := &stubCharts{catalog: map[string][]charts.Chart{ "https://one.example.com": {{Name: "podinfo", Version: "6.14.1"}, {Name: "redis", Version: "20.0.1"}}, }} svc := searcher(t, index, []RepoEntry{{Name: "one", Repo: charts.Repo{URL: "https://one.example.com"}}}) found, err := svc.SearchCharts(t.Context(), "") if err != nil { t.Fatalf("search: %v", err) } if len(found.Hits) != 2 { t.Fatalf("hits = %+v, want everything the repository offers", found.Hits) } }